revert(sandbox): withdraw the credential-document read denial

The `readDenyPaths` policy field shipped in the previous commit broke Linux
confinement outright. bwrap has to create the `/dev/null` bind's mount point
inside a tree its own profile has already made read-only, so it refused the
entire confinement whenever the parent directory was absent — every host that
has not stored a credential yet, including a fresh install:

  bwrap: Can't mkdir parents for /home/runner/.dsh/.env: Read-only file system

which the executor correctly classifies as SANDBOX_UNAVAILABLE, so every
confined bash call failed closed. Landlock cannot subtract from its own `/`
read grant, so it reported `partial` enforcement on every confined call for a
file it never hid, with no way to switch the denial off (schemastery fills an
omitted array with `[]`, so empty and omitted were indistinguishable).

A protection that breaks confinement where it works and misreports it where it
does not is worse than a documented absence. Revert the field, both expressible
backends, the enforcement downgrade, and the policy default; state the residue
plainly in the credentials-local READMEs — file mode stops other OS users, not
the model — and keep the OS-keychain provider recorded as the real answer.

The narrower discipline stands: no surface hoists the credential document into
`process.env`, and the model is never handed a resolved path to it.
This commit is contained in:
Yichen Jiang
2026-07-30 17:09:42 +08:00
parent 52ae578982
commit a90ccc4453
27 changed files with 38 additions and 262 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
README.md: 297dd7d5210bb30963a162c6a55a598c6d522aaf
README.zh.md: 1de92eb81409a7fabb25de94eb5372f0f16afb6f
README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd
README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c

View File

@@ -13,12 +13,6 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe).
- `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead.
## Read denials
`readDenyPaths` names absolute paths a **confined** execution must not read, whatever its mode otherwise permits. Omitted (or empty) denies the harness credential document `$DSH_HOME/.env`; a non-empty list replaces that default. Denials name exact paths rather than roots on purpose: denying the whole harness home would also take away the model's documented access to its own session log.
Enforcement is backend-shaped. Seatbelt appends a trailing `deny file-read* file-write*` (last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list, so a read grant on `/` cannot be subtracted from and `confine()` reports `partial` enforcement rather than pretending the boundary exists. `danger-full-access` confines nothing at all, so no denial applies there — the credential document is then protected only by its file mode, which does not stop a same-UID tool process.
## Surface
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.

View File

@@ -13,12 +13,6 @@
- `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。
- `workspaceRoot`:无 agent(智能体)的调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`;无论显式配置还是采用默认值,都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。
## 读取拒绝
`readDenyPaths` 列出**受约束**执行绝不可读取的绝对路径,无论其模式在其他方面允许什么。省略(或为空)时拒绝 harness 凭据文档 `$DSH_HOME/.env`;非空列表则替换该默认值。拒绝项有意点名确切路径而非根目录:拒绝整个 harness home 会连带拿走模型对自己会话日志的既定访问。
强制执行的形态由后端决定。Seatbelt 追加一条尾部 `deny file-read* file-write*`(最后匹配的规则胜出),bwrap 在任何工作区绑定之后把 `/dev/null` 映射到每个路径上;Landlock 的授权是纯粹的允许列表,`/` 上的读授权无法被扣除,因此 `confine()` 把强制执行报为 `partial`,而不是假装该边界存在。`danger-full-access` 根本不做任何约束,那里也就没有任何拒绝适用——凭据文档届时只受自身文件权限模式保护,而这挡不住同 UID 的工具进程。
## 接口
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。

View File

@@ -28,7 +28,6 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -38,7 +37,6 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -14,11 +14,10 @@
* @module @deepseek-ai/dsh-sandbox-policy
*/
import { join, resolve as resolvePath } from 'node:path'
import { resolve as resolvePath } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode } from './session-mode.ts'
@@ -50,16 +49,6 @@ export interface Config {
* `process.cwd()`). Normal agent calls use their session cwd instead.
*/
workspaceRoot?: string
/**
* Absolute paths confined executions must not read, whatever their mode
* otherwise permits. Omitted (or empty) denies the harness home's
* credential document (`$DSH_HOME/.env`) — exactly that file, so the model
* keeps the documented access to its own session log under the same home;
* a non-empty list replaces it. Backends that cannot express a read denial
* report `partial` enforcement instead of pretending, and
* `danger-full-access` confines nothing, so no denial applies there at all.
*/
readDenyPaths?: string[]
}
/** Inputs that select the sandbox policy for one capability call. */
@@ -83,15 +72,12 @@ export class SandboxPolicyService extends Service {
// No schema default: process.cwd() is resolved in the constructor so the
// stored root is always absolute regardless of how it was supplied.
workspaceRoot: z.string(),
readDenyPaths: z.array(z.string()),
})
/** The deployment default mode — the fallback beneath a session override. */
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
/** Absolute paths every confined execution is denied read access to. */
readonly readDenyPaths: readonly string[]
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
@@ -100,12 +86,6 @@ export class SandboxPolicyService extends Service {
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
// The credential document is the default denial; a configured list
// replaces it. Schemastery fills an omitted array with `[]`, so empty and
// omitted are the same request: protect the default document.
const denyPaths = config.readDenyPaths ?? []
this.readDenyPaths = (denyPaths.length > 0 ? denyPaths : [join(resolveDshHome(), '.env')])
.map(resolveWorkspaceRoot)
}
/**
@@ -122,7 +102,6 @@ export class SandboxPolicyService extends Service {
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
readDenyPaths: this.readDenyPaths,
}
}

View File

@@ -10,14 +10,9 @@ import { join, resolve, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
async function mounted(config: {
mode?: 'read-only' | 'workspace-write' | 'danger-full-access'
workspaceRoot?: string
readDenyPaths?: string[]
} = {}) {
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, config)
return ctx
@@ -46,35 +41,11 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
})
it('denies reading the harness credential document by default', async () => {
const ctx = await mounted()
// The exact file, not the whole home: the model keeps the documented
// access to its own session log under the same directory.
expect(ctx.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
})
it('defaults the denial list under programmatic construction too', () => {
// Constructing the service directly bypasses Schemastery, so the field
// arrives undefined rather than as the empty array the schema fills.
const service = new SandboxPolicyService(new Context(), {})
expect(service.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
})
it('replaces the default with a configured denial list', async () => {
const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] })
expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')])
// Schemastery fills an omitted array with `[]`, so empty reads as omitted.
const empty = await mounted({ readDenyPaths: [] })
expect(empty.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
})
it('resolves the deployment policy for an agentless call', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})
@@ -87,19 +58,16 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/projects/first'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
mode: 'read-only',
workspaceRoot: resolve('/projects/second'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})
@@ -119,7 +87,6 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
mode: 'workspace-write',
workspaceRoot: realpathSync.native(physical),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
} finally {
rmSync(root, { recursive: true, force: true })
@@ -133,7 +100,6 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
mode: 'danger-full-access',
workspaceRoot: resolve('/projects/approved'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})

View File

@@ -20,9 +20,6 @@
{
"path": "../sandbox"
},
{
"path": "../../util/paths"
},
{
"path": "../../core/session"
},