From a90ccc4453221da577fce51f75812ceb228568ae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:09:42 +0800 Subject: [PATCH] revert(sandbox): withdraw the credential-document read denial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...undaries-and-atomic-registration.i18n.yaml | 4 +-- ...tial-boundaries-and-atomic-registration.md | 9 +++-- ...l-boundaries-and-atomic-registration.zh.md | 9 +++-- docs/config-catalog.md | 12 +------ docs/cordis-catalog/services.md | 4 +-- docs/core-data-structures/sandbox.i18n.yaml | 6 ++-- docs/core-data-structures/sandbox.md | 14 +------- docs/core-data-structures/sandbox.zh.md | 14 +------- .../bash/bash-sandbox/tests/sandbox.spec.ts | 10 ++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../credentials-local/README.i18n.yaml | 4 +-- .../credentials/credentials-local/README.md | 7 ++-- .../credentials-local/README.zh.md | 7 ++-- packages/fs/tool-fs/tests/tools.spec.ts | 10 ++---- packages/sandbox/sandbox-local/src/index.ts | 7 +--- .../sandbox/sandbox-local/src/profiles.ts | 23 +----------- .../sandbox/sandbox-local/tests/local.spec.ts | 30 ---------------- .../sandbox-local/tests/seatbelt.e2e.ts | 33 +---------------- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +-- packages/sandbox/sandbox-policy/README.md | 6 ---- packages/sandbox/sandbox-policy/README.zh.md | 6 ---- packages/sandbox/sandbox-policy/package.json | 2 -- packages/sandbox/sandbox-policy/src/index.ts | 23 +----------- .../sandbox-policy/tests/policy.spec.ts | 36 +------------------ packages/sandbox/sandbox-policy/tsconfig.json | 3 -- packages/sandbox/sandbox/src/index.ts | 12 ------- pnpm-lock.yaml | 3 -- 27 files changed, 38 insertions(+), 262 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index e7c7b51af6..98f2b0cb0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 837aa3e7b8ed30c66aad880ab2d76eee376e1854 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: a00c3d93d2dc0451ed29613c4a804b0e518264ed +2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 837aa3e7b8..6fe5f554ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -16,7 +16,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. -**The confining sandbox is the only real read boundary, and it names the file.** `SandboxExecutionPolicy` grows `readDenyPaths`, defaulted by `sandbox-policy` to `$DSH_HOME/.env`. Seatbelt appends a trailing `deny file-read* file-write*` (SBPL's last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own `/` read grant, so `confine()` reports `partial` enforcement instead of claiming a boundary the process lacks. Denials name exact paths, not roots: denying the whole harness home would also take away the model's documented access to its own session log. Both READMEs state the residue plainly — under the shipped `danger-full-access` default nothing is confined and the file is protected only by the OS user — and record an OS-keychain provider as the real answer. +**The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. **One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. @@ -26,12 +26,11 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Alternatives considered -- **Denying the whole harness home** — one root would have covered the credential document and any future secret file, but it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. Exact paths keep the denial to what is actually secret. -- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. The sandbox denial is the boundary; hiding the pointer is not. +- **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. +- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see. - **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. -- **Treating `readDenyPaths: []` as an opt-out** — schemastery fills an omitted array with `[]`, so empty and omitted are indistinguishable at the constructor. Empty therefore means "protect the default document"; a deployment that stores credentials elsewhere names its own paths, and a denial on a path nothing reads costs nothing. - **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. ## Consequences -`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. A confined execution loses read access to `$DSH_HOME/.env` — deployments that deliberately let an agent read its own credential file must configure `readDenyPaths` themselves. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). +`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index a00c3d93d2..3eb3b02206 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -20,7 +20,7 @@ Status: implemented **`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 -**受限沙箱才是唯一真正的读取边界,而且它点名到具体文件。**`SandboxExecutionPolicy` 新增 `readDenyPaths`,由 `sandbox-policy` 默认设为 `$DSH_HOME/.env`。Seatbelt 在末尾追加一条 `deny file-read* file-write*`(SBPL 中最后一条匹配规则胜出),bwrap 则在所有工作区 bind 之后把 `/dev/null` 映射到每条路径上;Landlock 的授权是纯粹的允许列表,无法从它自己对 `/` 的读取授权中减去任何东西,因此 `confine()` 报告 `partial` 强制执行,而不是声称一条该进程其实并不具备的边界。拒绝点名的是确切路径,而不是根目录:把整个 harness home 都拒掉,会连带夺走模型对自身会话日志的成文访问权。两个 README 都直白写明残留风险——在已交付的 `danger-full-access` 默认值下没有任何东西受限,这个文件只靠 OS 用户身份保护——并记下 OS 钥匙串(keychain)提供方才是真正的答案。 +**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 **一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 @@ -30,12 +30,11 @@ Status: implemented ## 曾考虑的替代方案 -- **拒掉整个 harness home**——一个根目录本可覆盖凭据文档以及将来任何机密文件,但它同时也覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。用确切路径可以把拒绝范围限定在真正属于机密的东西上。 -- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。沙箱拒绝才是边界,藏起指针不是。 +- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。 +- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 - **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 -- **把 `readDenyPaths: []` 当作 opt-out**——schemastery 会把省略的数组填成 `[]`,因此在构造函数处空数组与省略无从分辨。于是空数组的含义就是「保护默认文档」;把凭据存在别处的部署自行点名它自己的路径,而在没人读取的路径上设一条拒绝并不产生任何代价。 - **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 ## 后果 -`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。受限执行会失去对 `$DSH_HOME/.env` 的读取权限——刻意让 agent 读取自身凭据文件的部署,必须自行配置 `readDenyPaths`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 +`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e8f02536b..dae7128fed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1027,22 +1027,12 @@ 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[] } ``` Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:45`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 016dff7b15..8cdc8b9065 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1059,7 +1059,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:143`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` @@ -1087,7 +1087,7 @@ overrideOf(session: Session): SandboxMode | undefined Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:79`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index c369d8066f..f8189f4e15 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 docs/core-data-structures/sandbox.md -sandbox.md: 566ac0edc0ba0600e2a1b5ecf18cc34e05e910ec -sandbox.zh.md: 24d8fbfc6c952246278192b5bed7cdc09223e7db +# pnpm run verify-translation-pairing --write +sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec +sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 566ac0edc0..9bc05fa06f 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. `readDenyPaths` names paths a confined execution must not read whatever its mode permits — the harness credential document by default — and backends that cannot express such a denial report `partial` enforcement rather than claiming a boundary the process lacks. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. ```ts type-equiv /** @@ -53,18 +53,6 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 24d8fbfc6c..9a52f12675 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。`readDenyPaths` 点名受限执行无论其模式允许什么都不得读取的路径——默认是 harness 凭据文档——无法表达此类拒绝的后端会把强制执行报为 `partial`,而不是声称一条该进程其实并不具备的边界。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 ```ts type-equiv /** @@ -53,18 +53,6 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } ``` diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index df4916b6b6..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -11,7 +11,6 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -75,11 +74,8 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } -/** The policy home's default read denial: the harness credential document. */ -const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] - function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { - return { mode, workspaceRoot, readDenyPaths: DEFAULT_DENY } + return { mode, workspaceRoot } } describe('the provider hand-off', () => { @@ -90,7 +86,7 @@ describe('the provider hand-off', () => { expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) expect(calls).toEqual([{ argv: ['bash', '-c', 'echo \'a b\' "c\'d"'], - policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }, + policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) }, }]) }) @@ -107,7 +103,7 @@ describe('the provider hand-off', () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) - expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }) + expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) }) }) it('an explicit workspaceRoot on the policy wins', async () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 553e321d7a..10de2583c2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2211,7 +2211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n readDenyPaths?: readonly string[];\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', }, { name: 'SandboxMode', diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 55d8f24b34..89a8576683 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -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/credentials/credentials-local/README.md -README.md: 2288d6d7133a7f356823e3e4f28746cfd28b2597 -README.zh.md: 959322c9ec670ed76b89f1f3a19246191b3ec02c +README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 +README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 2288d6d713..126140b107 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,12 +32,9 @@ External edits publish `credentials/updated` per changed reference after the sna ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns. Two things narrow that: +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. -- A **confining sandbox mode** denies the credential document specifically: [`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) defaults `readDenyPaths` to `$DSH_HOME/.env`, and the Seatbelt and bwrap backends enforce it (Landlock cannot subtract from its own `/` read grant and reports `partial`). The denial names the file, not the home, so the model keeps its documented access to its own session log. -- The harness never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). - -Neither makes an unconfined agent safe. A deployment that must keep provider keys away from its own agent should run a confining mode; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. +That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. ## Model Experience diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 959322c9ec..c22575115a 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,12 +32,9 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 -- **受限沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 -- harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 - -这两者都不能让未受限的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行受限模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 ## Model Experience diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index a3b658e6bf..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' @@ -113,9 +112,6 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -/** The policy home's default read denial: the harness credential document. */ -const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] - describe('session cwd resolution', () => { const execution = (cwd?: string) => cwd === undefined ? {} @@ -767,13 +763,13 @@ describe('sandbox escalation surface (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -806,7 +802,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 827f20d696..98dc86d23e 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -228,12 +228,7 @@ export class LocalSandboxProvider extends SandboxProvider { const selected = this.selectRunner(policy.mode) return { argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], - // Landlock grants are a pure allow-list, so it cannot subtract a read - // denial from its own `/` read grant: promising `full` there would - // misreport a boundary the process does not have. - enforcement: selected.runner === 'landlock' && (policy.readDenyPaths?.length ?? 0) > 0 - ? 'partial' - : selected.enforcement, + enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], } diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index 27ca150ef4..cee0f00852 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -5,14 +5,9 @@ */ import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' -import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -/** This policy's read denials, canonical and deduplicated like the writable roots. */ -function denyPaths(policy: SandboxPolicy): string[] { - return [...new Set((policy.readDenyPaths ?? []).map(path => canonicalPath(path)))] -} - /** * Build the bwrap profile arguments for one file-effect policy. * @param policy - file-effect policy to express as bwrap mounts. @@ -24,10 +19,6 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { args.push('--tmpfs', '/tmp') args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) } - // Read denials come last so a workspace bind can never re-expose one. - // `/dev/null` over the path reads as empty; the `-try` form tolerates a - // path that does not exist yet (no credential stored so far). - for (const path of denyPaths(policy)) args.push('--ro-bind-try', '/dev/null', path) return args } @@ -37,10 +28,6 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { * @returns launcher grant arguments before the trailing separator and command argv. */ export function landlockProfileArgs(policy: SandboxPolicy): string[] { - // Landlock grants are a pure allow-list: a read grant on `/` cannot be - // subtracted from, so a requested read denial is unenforceable here. The - // provider reports `partial` enforcement for exactly this case rather than - // pretending the boundary exists. const readWrite = ['/dev/null'] if (policy.mode === 'workspace-write') { readWrite.push('/tmp', policy.workspaceRoot) @@ -67,13 +54,5 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { if (roots.length > 0) { forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) } - // SBPL applies the last matching rule, so the read denial is appended after - // every allow above and governs both reads and writes of those paths. Both - // filters are emitted so a denial may name a file or a directory. - const denied = denyPaths(policy) - if (denied.length > 0) { - const filters = denied.map(path => `(literal ${sbplString(path)}) (subpath ${sbplString(path)})`).join(' ') - forms.push(`(deny file-read* file-write* ${filters})`) - } return ['-p', forms.join(' ')] } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index ceadaba184..f7cc952498 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -62,27 +62,6 @@ describe('profile dialects', () => { ]) }) - it('bwrap read denial: /dev/null over each denied path, after any workspace bind', () => { - expect(bwrapProfileArgs({ ...WW, readDenyPaths: ['/ws/secret.env'] })).toEqual([ - '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', - '--tmpfs', '/tmp', '--bind', '/ws', '/ws', - // The workspace bind above would otherwise re-expose the file. - '--ro-bind-try', '/dev/null', '/ws/secret.env', - ]) - }) - - it('landlock ignores read denials: a `/` read grant cannot subtract from itself', () => { - expect(landlockProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })) - .toEqual(landlockProfileArgs(RO)) - }) - - it('seatbelt read denial: a trailing deny naming the path as both a file and a directory', () => { - expect(seatbeltProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })).toEqual([ - '-p', - `${SEATBELT_RO_PROFILE} (deny file-read* file-write* (literal "/ws/secret.env") (subpath "/ws/secret.env"))`, - ]) - }) - it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => { // /dev/null specifically, NOT /dev: a whole-/dev grant would let confined // commands write real host paths beneath it (/dev/shm) under read-only. @@ -329,15 +308,6 @@ describe('the default landlock probe (launcher CLI contract)', () => { expect(sandbox.confine(['true'], RO).enforcement).toBe('partial') }) - it('reports partial enforcement when a read denial is requested it cannot express', async () => { - const launcher = fakeLauncher() - const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) - // Fully enforced for the write policy, yet the read denial is - // unexpressible in an allow-list that already grants `/` for reads. - expect(sandbox.confine(['true'], RO).enforcement).toBe('full') - expect(sandbox.confine(['true'], { ...RO, readDenyPaths: ['/ws/secret.env'] }).enforcement).toBe('partial') - }) - it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index a01e3a25a2..6d645b1a3b 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -70,37 +70,6 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu expect(result.stdout).toBe('dev-ok\n') }) - it('denies reading a credential document the mode would otherwise allow', async () => { - // The harness's own secret store: readable to the user, and the model's - // bash runs as that user — only the confinement can take it away. - const workdir = await tempDir(tmpdir()) - const secret = join(workdir, '.env') - await writeFile(secret, 'DEEPSEEK_API_KEY=sk-must-not-leak\n', { mode: 0o600 }) - const sandbox = await provider() - - const allowed = runConfined(sandbox, `cat ${secret}`, { mode: 'read-only', workspaceRoot: workdir }) - expect(allowed.result.stdout).toContain('sk-must-not-leak') - - const denied = runConfined(sandbox, `cat ${secret}`, { - mode: 'read-only', - workspaceRoot: workdir, - readDenyPaths: [secret], - }) - expect(denied.result.stdout).not.toContain('sk-must-not-leak') - expect(denied.result.status).not.toBe(0) - expect(denied.confined.enforcement).toBe('full') - // Everything else under the same directory stays readable: the denial is - // the credential document, not the harness home. - const sibling = join(workdir, 'notes.txt') - await writeFile(sibling, 'ordinary\n') - const neighbour = runConfined(sandbox, `cat ${sibling}`, { - mode: 'read-only', - workspaceRoot: workdir, - readDenyPaths: [secret], - }) - expect(neighbour.result.stdout).toBe('ordinary\n') - }) - it('read-only grants no temp area: a write under the user temp dir is denied too', async () => { // The per-user darwin temp dir is a workspace-write grant, not a // read-only one — under read-only the only write-shaped path is /dev/null. diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 78ec04a6a0..b21c8d885a 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -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 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 297dd7d521..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -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. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 1de92eb814..a201d48c81 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -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/..` 与进程工作目录解析保持一致。 diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index bb48bb0b35..d5f9270ed1 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -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" diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 74c05f76a1..1f5ba0bb00 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -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, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 7740abc058..63ca0cd3d5 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -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')], }) }) diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index 65c906d6c3..cb6fc623d0 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../sandbox" }, - { - "path": "../../util/paths" - }, { "path": "../../core/session" }, diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 11e690704e..781227f411 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,18 +40,6 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 367b2f4299..37f7e6cf29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3618,9 +3618,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox