From 6343d8f6e6793d93adb13fca805d52d3cada10f5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 3 Aug 2026 17:31:08 +0800 Subject: [PATCH] fix(sandbox): evidence-gate runner failures (round 1) --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 19 +-- .../feature/2026-07-06-sandbox.zh.md | 19 +-- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 6 +- docs/core-data-structures/sandbox.md | 33 ++++- docs/core-data-structures/sandbox.zh.md | 33 ++++- ...ice-misclassified-child-failures.i18n.yaml | 6 + ...ial-notice-misclassified-child-failures.md | 55 ++++++++ ...-notice-misclassified-child-failures.zh.md | 55 ++++++++ docs/postmortem/README.i18n.yaml | 4 +- docs/postmortem/README.md | 1 + docs/postmortem/README.zh.md | 1 + .../partial-landlock.cordis.snapshot.yml | 38 ++++++ .../acp-agent/partial-landlock.cordis.yml | 13 ++ examples/acp-agent/tests/acp.snapshot.ts | 13 ++ .../fixtures/partial-landlock-sandbox.ts | 26 ++++ .../partial-landlock-child-failure/input.json | 7 + .../session.jsonl | 26 ++++ .../stdout.expected.jsonl | 4 + packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 4 +- packages/bash/bash-sandbox/README.zh.md | 4 +- packages/bash/bash-sandbox/src/helpers.ts | 39 +++++- packages/bash/bash-sandbox/src/index.ts | 30 +++-- .../tests/partial-landlock.spec.ts | 125 ++++++++++++++++++ .../bash/bash-sandbox/tests/sandbox.spec.ts | 59 +++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 4 +- packages/fs/tool-fs-search/README.zh.md | 4 +- packages/fs/tool-fs-search/package.json | 4 + packages/fs/tool-fs-search/src/search-core.ts | 14 +- .../tool-fs-search/tests/integration.spec.ts | 60 +++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 24 +++- packages/pty/pty-local/tests/index.spec.ts | 4 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 4 +- packages/sandbox/sandbox-local/README.zh.md | 4 +- packages/sandbox/sandbox-local/src/index.ts | 72 +++++++--- .../sandbox/sandbox-local/tests/local.spec.ts | 47 +++++-- packages/sandbox/sandbox/README.i18n.yaml | 4 +- packages/sandbox/sandbox/README.md | 3 +- packages/sandbox/sandbox/README.zh.md | 3 +- packages/sandbox/sandbox/src/index.ts | 23 +++- pnpm-lock.yaml | 12 ++ scripts/type-equiv.manifest.json | 5 + 51 files changed, 805 insertions(+), 140 deletions(-) create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md create mode 100644 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md create mode 100644 examples/acp-agent/partial-landlock.cordis.snapshot.yml create mode 100644 examples/acp-agent/partial-landlock.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl create mode 100644 packages/bash/bash-sandbox/tests/partial-landlock.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index d5727d9c4b..e1c1e00880 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 4355ab57374f77f1733fff39e2fb4ccadcedaf6b -2026-07-06-sandbox.zh.md: 02c5337555b7466c2bac7fc4774cdd2c945178ae +2026-07-06-sandbox.md: 85c7f64d379e4fec18511b3b8566e84edb34c1a2 +2026-07-06-sandbox.zh.md: 0278b6302d2647c49980b10646e3b9158356b3ec diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 4355ab5737..85c7f64d37 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -50,7 +50,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its structured runner-failure evidence (`runnerFailureRules`, optional allowed exit codes plus fatal per-line signatures after exact informational-line exclusions); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -60,9 +60,9 @@ Left open, for the phase that needs them: whether network restriction arrives as #### Local backends and the shipped launcher -`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. +`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial signatures and runner-failure rules so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice `landlock-run:` line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Outer-shell failures are separate rules scoped to the resolved/configured argv0 and shell exit 126/127. `runnerCommand` keeps its operator-facing `runnerFailureSignatures` config and maps those entries into an internal fatal rule, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined. -The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. +The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing and print a fatal `landlock-run:` line. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. @@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. A runner failure outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE`; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. Foreground and background use one runner-failure classifier that requires the rule's exit-code gate and a remaining fatal line after informational exclusions. A match outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. +- **Unit:** pin platform selection and profiles, structured runner classification (including partial-Landlock notice-only child outcomes, fatal evidence, outer-shell failures, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. - **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip. -- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent. +- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bash `false` as an ordinary child result through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent. ## Deferred phases @@ -128,12 +128,12 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered - **Command-string heuristic preflight** — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal. -- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus `runnerFailureSignatures` classification carries the safety property instead. +- **Functionally probe even a platform's sole backend** — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus structured `runnerFailureRules` classification carries the safety property instead. - **Commit the built launcher binaries** — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree. - **Compile the launcher on install** — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback. - **Cross-compile both architectures from one builder** — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the `node-addon-require-builtin` model, the launcher repo's own pipeline). @@ -174,6 +174,7 @@ Costs and accepted limits: - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown `SANDBOX_UNAVAILABLE`, the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. +- **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher arrives as a registry dependency.** Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. - **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. @@ -184,7 +185,7 @@ Costs and accepted limits: ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. +- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground re-throws structured `SANDBOX_UNAVAILABLE` with the matched fatal line; a background task stamps `sandbox.runnerFailed` and renders its own marker. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 02c5337555..0278b6302d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -50,7 +50,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### seam:`ctx.sandbox` -`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串)、以及其 runner 失败方言(`runnerFailureSignatures`,runner 本身失败——因而命令从未运行——时的自我标识方式);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root)以及 `SandboxPolicy`(提供给约束后端的子集)。 +`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串),以及其结构化 runner 失败证据(`runnerFailureRules`,可选的允许退出码加上排除整行精确信息性行后按行匹配的致命签名);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode`(`read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement`(`full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root)以及 `SandboxPolicy`(提供给约束后端的子集)。 策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 @@ -60,9 +60,9 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 本地后端与随附 launcher -`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败签名,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。`runnerCommand` 作为运维人员对 bwrap 形状 runner 的断言跳过选择,但缺失或不可执行的命令仍被归类为沙箱失败,绝不无约束地运行负载。 +`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 `landlock-run:` 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。外层 shell 失败使用独立规则,并限定为解析后或配置的 argv0 与 shell 退出码 126/127。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置,并将这些条目映射到一条内部致命规则;但命令缺失或不可执行时,仍归类为沙箱失败,绝不无约束地运行负载。 -launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec。 +launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;launcher 失败以 125 退出且不 exec,并打印一行致命的 `landlock-run:` 诊断。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测和 CLI flag,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 @@ -70,7 +70,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。Runner 失败优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码门控与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `task_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 @@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及运行时上下文排序与具体化。 +- **单元测试:** 固定平台选择和 profile、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、致命证据、外层 shell 失败,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。 - **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 -- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 +- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。 ## 延迟阶段 @@ -128,12 +128,12 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 - **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。 -- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加 `runnerFailureSignatures` 分类承载了安全属性。 +- **即使平台仅有一个后端也功能性探测**:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。 - **提交构建好的 launcher 二进制**:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。 - **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。 - **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式,launcher 仓库自己的流水线)。 @@ -174,6 +174,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 +- **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 - **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 @@ -184,7 +185,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** Runner 失败在分类中优先于拒绝:匹配包装的 `runnerFailureSignatures` 的失败运行意味着命令从未运行——前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带 runner 的 stderr 行,后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。损坏的沙箱永远不会被读作失败的命令,且命令永远不会无约束运行。 +- **如何区分损坏的沙箱与失败的命令?** 只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行;后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc85531d4d..9fca775cc2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -235,7 +235,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) -Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:34`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-cli-demo` @@ -1037,7 +1037,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:19`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf18c6f73c..bab2162821 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1095,7 +1095,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:131`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index f8189f4e15..0949bb2279 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 -sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 +# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md +sandbox.md: 8483104e0aab78779acb52b232a205280c93cd2b +sandbox.zh.md: b283095f1c847a4b2fca5a109e7894d6e3479839 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..8483104e0a 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## Wrapped argv and classification dialects -`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. +`RunnerFailureRule` combines evidence that a runner failed before executing the command. A consumer requires a nonzero exit, the optional allowed-exit-code gate, and a case-insensitive fatal signature within one remaining stderr line. Case-insensitive exact full-line informational exclusions are removed first, so a benign runner notice cannot prove failure by itself. The matched line remains available as error detail; classification does not rewrite stderr. + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr classifiers. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureRules` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -An operator-configured local runner must supply at least one `runnerFailureSignatures` entry for its own pre-exec refusal dialect; the provider adds outer-shell missing and unexecutable forms automatically. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. +The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one entry for its own pre-exec refusal dialect. The provider maps those entries into one rule and adds a separate argv0-scoped rule for outer-shell missing or unexecutable failures. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status. ## Provider and fail-closed errors -`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case its failure signature carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. +`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case a matching structured rule carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy. Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict. diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 9a52f12675..b283095f1c 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -87,7 +87,27 @@ interface SandboxPolicy extends SandboxExecutionPolicy { ## 包装后的 argv 与分类方言 -`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 方言。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 用于识别沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 +`RunnerFailureRule` 汇集用于判定 runner 在执行命令前失败的证据。消费方要求进程以非零状态退出,并同时满足可选的允许退出码门控,以及余下某一 stderr 行中不区分大小写的致命签名。系统会先按不区分大小写的整行精确匹配移除信息性排除项,因此无害的 runner 通知本身不能证明失败。匹配到的行仍可用作错误详情;分类过程不会重写 stderr。 + +```ts type-equiv +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} +``` + +`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 分类器。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureRules` 用于识别沙箱 runner 在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 ```ts type-equiv /** @@ -110,18 +130,19 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } ``` -运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 +面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一条条目。提供方会将这些条目映射到一条规则,并另加一条按 argv0 限定的外层 shell 规则,用于识别命令缺失或不可执行。这使得可执行的自定义 runner 拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 ## 提供方与 fail-closed 错误 -`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的 runner 也可能在执行时 fail-closed,此时匹配到的结构化规则承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml new file mode 100644 index 0000000000..b11b34b8b7 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.i18n.yaml @@ -0,0 +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/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md +0004-landlock-partial-notice-misclassified-child-failures.md: ccedba6094ffec9de95fbd52d58add4a0e6e5105 +0004-landlock-partial-notice-misclassified-child-failures.zh.md: 89d85d835d58753721927be85a5581b12a490fcf diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md new file mode 100644 index 0000000000..ccedba6094 --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md @@ -0,0 +1,55 @@ +# Post-mortem 0004: Landlock partial-enforcement notice misclassified child failures + +English | [中文](0004-landlock-partial-notice-misclassified-child-failures.zh.md) + +Status: resolved + +## Executive summary + +On kernels with an older Landlock ABI, the launcher prints a benign partial-enforcement notice before executing every child. The harness treated that shared `landlock-run:` prefix plus any nonzero child exit as launcher failure, so ordinary outcomes such as ripgrep's exit 1 for no matches surfaced as `SANDBOX_UNAVAILABLE`; filesystem search then hid that structured error behind `SEARCH_FAILED`. Broad signature rules and missing partial-ABI composition coverage let the defect through. Runner classification now requires status-gated fatal evidence after exact informational exclusions, search preserves structured executor errors, and an assembled keyless scenario pins the affected product path. + +## Summary + +The native launcher contract distinguishes two kinds of stderr lines. A partially enforcing kernel prints exactly `landlock-run: partial enforcement (older Landlock ABI)` and continues into the child. A launcher failure prints another `landlock-run:` line and exits 125 without executing the child. + +The harness represented both with one case-insensitive `landlock-run: ` substring. Its consumer classified any nonzero exit carrying that substring as runner failure. The child's status was therefore attached to the launcher's informational line: `false`, ripgrep's no-match exit 1, invalid-pattern exit 2, and even a child-selected exit 125 could be blamed on the sandbox despite successful confinement and execution. + +Filesystem search added a second attribution error. `runRipgrep()` caught every rejected bash run that was not aborted and replaced it with a generic cwd/shell-start `SEARCH_FAILED`, including the structured `SandboxUnavailableError` produced by the sandbox executor. + +## Impact + +On partial-ABI Landlock hosts, legitimate nonzero child outcomes could appear as sandbox infrastructure failure. `glob` and `grep` were especially visible because ripgrep uses exit 1 as successful empty search. When a real sandbox failure did occur through filesystem search, callers lost its `SANDBOX_UNAVAILABLE` code and received an incorrect startup diagnosis. + +The defect did not weaken confinement or run a command unconfined. Its security effect was availability and diagnostic integrity: a valid confined result was rejected or mislabeled. + +## Timeline + +- The native launcher contract defined exit 125 for launcher failures, a fatal `landlock-run:` line for every such failure, and the exact partial-enforcement notice for successful child execution. +- The sandbox provider reduced that contract to `runnerFailureSignatures: ['landlock-run: ']`; the bash consumer combined the prefix with any nonzero exit and reported stderr's first line. +- Unit tests covered clean success, denial diagnostics, and fatal runner prefixes. Real-runner tests self-skipped without a usable kernel and did not force partial enforcement followed by a nonzero child. +- A minimal POSIX wrapper that prints the notice and `exec`s its payload reproduced the failure with `false` and ripgrep no-match. +- Structured rules, shared foreground/background classification, search error preservation, and assembled replay coverage closed the two attribution gaps. + +## Root cause + +The public sandbox result type could express only a bag of substrings. It could not state that Landlock failure requires exit 125, that evidence must occur within one fatal line, or that one exact line under the same prefix is informational. The boolean consumer consequently joined unrelated facts from different processes and selected the first stderr line for detail even when a later line was the fatal evidence. + +The test matrix mirrored that representation. Fake providers emitted either no runner line or an unambiguously fatal prefix; they never emitted a benign runner line before a child-controlled nonzero exit. Real Landlock coverage depended on the host ABI, so full-ABI hosts could not exercise the notice. Filesystem-search tests modeled raw spawn errors but not a structured error thrown by the real sandboxed bash composition. + +Stderr remains an in-band attribution channel. A confined child can deliberately reproduce a runner's gated fatal line and exit status, causing an availability/diagnostic false attribution. The tighter conjunction prevents the accidental collision in this incident but does not authenticate the writer; an out-of-band status protocol remains separate hardening, not a sandbox-bypass fix. + +## Guardrails added + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) carries optional allowed exit codes, case-insensitive per-line fatal signatures, and case-insensitive exact informational-line exclusions. +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line, keeps bwrap/Seatbelt/custom behavior, and separates argv0-scoped outer-shell failures using exit 126/127. +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) uses one evidence-returning classifier for foreground and background execution. Fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr. +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) retains abort as `SEARCH_ABORTED`, propagates existing `HarnessError` instances unchanged, and applies `SEARCH_FAILED` only to untyped start failures. Ripgrep still owns exit 0/1/other semantics inside the adapter. +- Deterministic tests use a POSIX fake partial-Landlock launcher to cover `true`, `false`, child exit 125, permission denial, real fatal diagnostics, and foreground/background parity. A real search composition covers empty grep/glob, invalid regex, and `SANDBOX_UNAVAILABLE` propagation. +- The `examples/acp-agent` keyless snapshot runs direct bash `false` through a test-only partial-Landlock provider, keeping the product regression pinned independently of filesystem-search implementation choices. + +## Lessons + +- Process attribution requires a conjunction of independent evidence; a shared prefix is not a protocol. +- Informational and fatal diagnostics can share a namespace, so exclusions must be exact and narrow while unknown fatal lines stay fail-closed. +- An adapter must preserve structured failures owned by the seam below it instead of replacing them with its own nearest generic category. +- Platform-dependent behavior needs a deterministic fake at the native boundary plus one assembled product path; a self-skipping real-kernel test cannot carry that regression alone. diff --git a/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md new file mode 100644 index 0000000000..89d85d835d --- /dev/null +++ b/docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.zh.md @@ -0,0 +1,55 @@ +# 事故复盘(postmortem) 0004:Landlock 部分强制执行通知导致子进程失败被误归类 + +[English](0004-landlock-partial-notice-misclassified-child-failures.md) | 中文 + +Status: resolved + +## 摘要 + +在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;文件系统搜索随后又用 `SEARCH_FAILED` 遮蔽了这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据;搜索会保留结构化执行器错误,并由一个组装后的无密钥场景固定受影响的产品路径。 + +## 概述 + +原生 launcher 契约区分两类 stderr 行。内核只能部分强制执行时,会精确打印 `landlock-run: partial enforcement (older Landlock ABI)`,然后继续执行子进程。launcher 失败则打印另一行 `landlock-run:` 诊断,在不执行子进程的情况下以 125 退出。 + +harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情况。消费方只要发现非零退出同时携带该子串,就会归类为 runner 失败。因此,子进程的退出状态被错误地关联到 launcher 的信息性行:`false`、ripgrep 无匹配时的退出码 1、无效 pattern 的退出码 2,乃至由子进程自行选择的退出码 125,都可能在约束与执行均成功的情况下被错误归因为沙箱故障。 + +文件系统搜索又造成第二处归因错误。`runRipgrep()` 会捕获 bash 执行器除中止外抛出的所有错误,并将其替换为关于 cwd 或 shell 启动的通用 `SEARCH_FAILED`,其中也包括沙箱执行器产生的结构化 `SandboxUnavailableError`。 + +## 影响 + +在 Landlock ABI 只能部分强制执行的主机上,合法的非零子进程结果可能表现为沙箱基础设施故障。`glob` 和 `grep` 尤其容易暴露该问题,因为 ripgrep 把退出码 1 用作成功的空搜索。当文件系统搜索中确实发生沙箱故障时,调用方也会丢失其 `SANDBOX_UNAVAILABLE` 错误码,转而收到错误的启动诊断。 + +该缺陷没有削弱约束,也没有让命令在无约束状态下运行。其安全影响在于可用性与诊断完整性:有效的受限结果会被拒绝或错误标记。 + +## 时间线 + +- 原生 launcher 契约规定:launcher 失败使用退出码 125,每次此类失败都会打印一行致命的 `landlock-run:` 诊断;成功执行子进程时则打印精确的部分强制执行通知。 +- 沙箱提供方把该契约简化为 `runnerFailureSignatures: ['landlock-run: ']`;bash 消费方将此前缀与任意非零退出组合,并报告 stderr 的第一行。 +- 单元测试覆盖了无诊断的成功、拒绝诊断和致命 runner 前缀。真实 runner 测试在没有可用内核时会自行跳过,也没有强制构造「部分强制执行通知后跟非零子进程退出」的情况。 +- 一个最小 POSIX 包装脚本会打印该通知并 `exec` 其负载;它通过 `false` 与 ripgrep 无匹配场景复现了故障。 +- 结构化规则、前台与后台共享的分类逻辑、搜索错误保留和组装后的回放覆盖共同弥补了这两处归因缺口。 + +## 根因 + +公开的沙箱结果类型只能表达一组子字符串。它无法表示 Landlock 失败必须使用退出码 125、证据必须出现在一行致命诊断内,或同一前缀下有一行精确文本属于信息性通知。消费方的布尔判定逻辑因此把来自不同进程且互不相关的事实组合在一起;即便致命证据位于后续行,它仍选用 stderr 的第一行作为详细信息。 + +测试矩阵与这种表示方式一致。模拟提供方要么不输出 runner 行,要么输出含义明确的致命前缀,从不在由子进程控制的非零退出前输出无害 runner 行。真实 Landlock 覆盖依赖主机 ABI,因此使用完整 ABI 的主机无法覆盖该通知。文件系统搜索测试模拟了原始 spawn 错误,却没有覆盖真实沙箱化 bash 组合抛出的结构化错误。 + +stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的门控致命诊断行与退出状态,造成可用性或诊断误归因。更严格的多项证据合取可以避免本次事故中的意外冲突,但无法验证写入者身份;带外状态协议仍属于独立的加固工作,而非沙箱绕过修复。 + +## 已添加的防护措施 + +- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。 +- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,保留 bwrap/Seatbelt/自定义 runner 的行为,并以退出码 126/127 单独处理按 argv0 限定的外层 shell 失败。 +- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 为前台与后台执行使用同一个返回证据的分类器。致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。 +- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 把中止保留为 `SEARCH_ABORTED`,原样传播既有 `HarnessError` 实例,并且仅将无类型的启动失败归为 `SEARCH_FAILED`。适配器内部仍按 ripgrep 自身的退出码 0/1/其他语义处理。 +- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖 `true`、`false`、子进程退出码 125、权限拒绝、真实致命诊断,以及前台/后台一致性。真实搜索组合覆盖空 grep/glob、无效正则表达式和 `SANDBOX_UNAVAILABLE` 传播。 +- `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归。 + +## 教训 + +- 进程归因需要多项独立证据同时成立;共享前缀不是协议。 +- 信息性诊断与致命诊断可以共享同一命名空间,因此排除规则必须精确且范围狭窄,同时对未知的致命行保持失败关闭。 +- 适配器必须保留下层 seam 所拥有的结构化失败,而不能用自身最接近的通用类别将其替换。 +- 平台相关行为需要在原生边界放置确定性的模拟实现,并覆盖一条组装后的产品路径;会自行跳过的真实内核测试无法独自固定该回归。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e027911357..c706a54f4a 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/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 docs/postmortem/README.md -README.md: 4858f8841e92a895f2d1a840b59b42758e83d952 -README.zh.md: 127eb19422f1eb6738c1d246791064096f4382f9 +README.md: ffde0057304856b7c7718e3dd1f4743c48ee193f +README.zh.md: cf8cfef52bf006619cf31087756fc8b3555b707d diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index 4858f8841e..ffde005730 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -15,3 +15,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | | [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock partial-enforcement notice misclassified child failures | diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 127eb19422..cf8cfef52b 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -15,3 +15,4 @@ | [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | | [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI | +| [0004](0004-landlock-partial-notice-misclassified-child-failures.md) | Landlock 部分强制执行通知导致子进程失败被误归类 | diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml new file mode 100644 index 0000000000..685f47d814 --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless partial-Landlock composition: replay the authored model turns and +# replace the shipping provider with a deterministic older-ABI stand-in. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml new file mode 100644 index 0000000000..41725299a8 --- /dev/null +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -0,0 +1,13 @@ +# Live counterpart for the partial-Landlock snapshot overlay. It replaces only +# the sandbox provider; the authored scenario is skipped in record mode. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + disabled: true + - insert: + - id: partial-landlock-sandbox + name: './tests/fixtures/partial-landlock-sandbox.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 166e0cf0fe..e837af910b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -47,6 +47,7 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) +const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -131,6 +132,18 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + // Authored keyless replay through a test-only partial-Landlock provider: + // the exact compatibility notice must stay ordinary stderr when the wrapped + // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. + { + name: 'partial-landlock-child-failure', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { DSH_PERMISSION_MODE: 'read-only' }, + posixOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts new file mode 100644 index 0000000000..2ce25885f0 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -0,0 +1,26 @@ +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' + +/** Snapshot-only provider that reproduces an older-ABI Landlock launch. */ +export default class PartialLandlockSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { + argv: [ + 'bash', + '-c', + `printf '%s\\n' '${NOTICE}' >&2; exec "$@"`, + 'partial-landlock-run', + ...argv, + ], + enforcement: 'partial', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: [NOTICE], + }], + } + } +} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json new file mode 100644 index 0000000000..57f5effa73 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl new file mode 100644 index 0000000000..d731ba4811 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1785218500000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785218500001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785218500002,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"44444444-4444-4444-8444-444444444445"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785218500003,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":1785218500004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"44444444-4444-4444-8444-444444444446"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785218500005,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785218500006,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785218500007,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1785218500008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1785218500009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"partial-landlock-call","name":"bash","argumentsDelta":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1785218500010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785218500011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":11,"time":1785218500012,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785218500013,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"44444444-4444-4444-8444-444444444447"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785218500014,"data":{"turn":1,"step":1,"callId":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}} +{"type":"tool/result","seq":14,"time":1785218500015,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"partial-landlock-call"},"content":[{"type":"tool-result","toolCallId":"partial-landlock-call","content":[{"type":"text","text":"[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"}],"isError":false}],"role":"user","id":"44444444-4444-4444-8444-444444444448"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785218500016,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785218500017,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1785218500018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":18,"time":1785218500019,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_EXIT_PRESERVED"}}} +{"type":"assistant/chunk","seq":19,"time":1785218500020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"type":"assistant/chunk","seq":20,"time":1785218500021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":21,"time":1785218500022,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1785218500023,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_EXIT_PRESERVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"44444444-4444-4444-8444-444444444449"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785218500024,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1785218500025,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl new file mode 100644 index 0000000000..98a85f5207 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index 640eb1810f..e1f518e8f1 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/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/bash/bash-sandbox/README.md -README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7 -README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb +README.md: 851bedea3589efe164cf49b7e6db1a5f3d64df24 +README.zh.md: f4eb13dd11f8a22dd0a0d594a615567571b1ccde diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 035a8ad240..851bedea35 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). -- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. +- **Runner failures are sandbox failures, never command failures.** Foreground and background execution use the same structured classifier: a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. - **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. +If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the matched fatal stderr line as its detail and preserves the original stderr collection. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index cee27a9baa..f4eb13dd11 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台与后台执行使用同一个结构化分类器:先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 - **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index a98f47216e..a58354c40d 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -5,6 +5,13 @@ */ import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} /** * Quote one string as a single-quoted POSIX shell word. @@ -26,13 +33,33 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin } /** - * Classify a failed run against the selected backend's runner-failure dialect. - * @param result - settled foreground run. - * @param signatures - case-insensitive runner-failure substrings from the active wrap. - * @returns whether the failed run matches that runner-failure dialect. + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. */ -export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean { - return matchesSignature(result.exitCode, result.stderr.text, signatures) +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + const fatalSignatures = rule.fatalSignatures.map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined } /** diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index f46bad4006..854789f428 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -11,7 +11,14 @@ import { Context } from 'cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' @@ -51,7 +58,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { mode: ConfinedSandboxMode enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] }>() constructor(ctx: Context, config: Config) { @@ -84,10 +91,11 @@ export class SandboxBashExecutor extends LocalBashExecutor { } const confined = this.confine(spec.command, { ...policy, mode }) const result = await super.run({ ...spec, command: confined.command }) - // Runner failure outranks denial because the command did not run. Throw the - // same fail-closed error as confine-time discovery with the first stderr line. - if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) { - throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0]) + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) } return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } } @@ -99,8 +107,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { // Install facts synchronously; promise settlement cannot run before start() returns. const confined = this.confine(spec.command, { ...policy, mode }) const proc = super.start({ ...spec, command: confined.command }) - const { enforcement, denialSignatures, runnerFailureSignatures } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules }) return proc } @@ -113,7 +121,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { if (facts !== undefined) { this.processFacts.delete(proc) // Runner failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures) + const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), @@ -136,14 +144,14 @@ export class SandboxBashExecutor extends LocalBashExecutor { command: string enforcement: SandboxEnforcement denialSignatures: readonly string[] - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } { const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) return { command: `exec ${confined.argv.map(shellQuote).join(' ')}`, enforcement: confined.enforcement, denialSignatures: confined.denialSignatures, - runnerFailureSignatures: confined.runnerFailureSignatures, + runnerFailureRules: confined.runnerFailureRules, } } } diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts new file mode 100644 index 0000000000..9554533598 --- /dev/null +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -0,0 +1,125 @@ +/** + * Deterministic composition proof for the partial-Landlock diagnostic: the + * real local provider and sandbox bash executor wrap commands through a POSIX + * fake launcher that prints the native informational line before exec. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const FATAL = 'landlock-run: landlock ruleset error: Invalid argument' + +const contexts: Context[] = [] +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** Write a fake native launcher that reports partial enforcement, then execs or fails. */ +async function fakeLauncher(fatal: boolean): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-')) + tempDirs.push(dir) + const launcher = join(dir, 'landlock-run') + const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : '' + await writeFile(launcher, `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro|--rw) shift 2 ;; + --) shift; break ;; + *) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;; + esac +done +printf '%s\\n' '${NOTICE}' >&2 +${fatalBranch}exec "$@" +`, { mode: 0o755 }) + return launcher +} + +async function setup(fatal = false): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { + platform: 'linux', + probeBwrap: () => false, + probeLandlock: () => 'partial', + landlockLauncher: await fakeLauncher(fatal), + } + await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 }) + return ctx.bash as SandboxBashExecutor +} + +describe('partial Landlock runner-failure classification', () => { + it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => { + const bash = await setup() + for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) { + const result = await bash.run(bash.resolve({ command })) + expect(result.exitCode).toBe(exitCode) + expect(result.stderr.text).toBe(`${NOTICE}\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + } + }) + + it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => { + const bash = await setup(true) + const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value) + expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`Runner failure: ${FATAL}`) + expect((error as Error).message).not.toContain(NOTICE) + }) + + it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => { + const bash = await setup() + const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + }) + + it('applies the same evidence rule to notice-only background exits', async () => { + const bash = await setup() + for (const command of ['false', 'exit 125']) { + const task = bash.start(bash.resolve({ command })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + } + }) + + it('classifies a background notice plus child Permission denied as denial', async () => { + const bash = await setup() + const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })) + await task.done + expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) + expect(task.readOutput().delta).toContain(NOTICE) + }) + + it('makes a background fatal line outrank denial text after the notice', async () => { + const bash = await setup(true) + const task = bash.start(bash.resolve({ command: 'true' })) + await task.done + expect(task.sandbox).toEqual({ + mode: 'read-only', + denied: false, + enforcement: 'partial', + runnerFailed: true, + }) + const output = task.readOutput().delta + expect(output).toContain(NOTICE) + expect(output).toContain(FATAL) + }) +}) diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 90a67999c8..03dede1303 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -30,12 +30,12 @@ interface ConfineCall { /** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const -/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ -const RUNNER_FAILURE = ['fake-runner: '] as const +/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */ +const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const /** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ const passthrough = (argv: readonly string[]): ConfinedArgv => - ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }) + ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }) /** * Boot a context with a recording fake `ctx.sandbox` (behavior injectable @@ -93,7 +93,7 @@ describe('the provider hand-off', () => { it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => { // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix: // the sentinel only prints if the executor spawned the WRAPPED argv. - const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' })) expect(result.stdout.text).toBe('1') expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) @@ -234,10 +234,47 @@ describe('classifyDenial', () => { }) describe('classifyRunnerFailure', () => { - it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => { - const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'] - expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true) - expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true) + it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => { + const rules = [{ + allowedExitCodes: [126, 127], + fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'], + }] + expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail) + .toBe('bash: /Opt/Runners/bwrap: No such file or directory') + expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail) + .toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND') + expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined() + }) + + it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => { + const notice = 'landlock-run: partial enforcement (older Landlock ABI)' + const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }] + expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined() + expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined() + expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules)) + .toEqual({ detail: `${notice}: extra detail` }) + expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules)) + .toEqual({ detail: 'landlock-run: exec failed: No such file or directory' }) + }) + + it.each([ + 'landlock-run: usage error: missing `-- ...` command', + 'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)', + 'landlock-run: cannot open rule path: /gone: No such file or directory', + 'landlock-run: landlock ruleset error: Invalid argument', + 'landlock-run: exec failed: Permission denied', + 'landlock-run: out of memory', + 'landlock-run: future fatal diagnostic', + ])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => { + const rules = [{ + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }] + expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal }) }) }) @@ -253,7 +290,7 @@ describe('result facts', () => { }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { - const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })) + const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) }) @@ -284,7 +321,7 @@ describe('background sandbox facts', () => { it('a foreground runner failure throws the fail-closed error, never a task result', async () => { // The wrap's runner prefix on a failed run means the SANDBOX broke and // the command never ran — the late twin of the confine-time throw, with - // the runner's own first stderr line carried as the cause. + // the matched fatal stderr line carried as the cause. const { bash } = await setup() const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' })) await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) @@ -315,7 +352,7 @@ describe('background sandbox facts', () => { let call = 0 const { bash } = await setup({}, (argv) => { const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick - return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE } + return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE } }) const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' })) const quick = bash.start(bash.resolve({ command: 'true' })) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7091f95447..8252aded19 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1801,7 +1801,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConfinedArgv', - declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}', + declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', }, { name: 'ConfinedSandboxMode', @@ -2347,6 +2347,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, + { + name: 'RunnerFailureRule', + declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index bedb8289c1..8000387878 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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/fs/tool-fs-search/README.md -README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4 -README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60 +README.md: 93df279e958c569cd0b30618d3c3e80f773d452b +README.zh.md: 5cec79577dc896c2a95f9539a9911adcdc23cfe1 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index b12ffda986..93df279e95 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -46,7 +46,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax ## Errors -Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. +Search-owned failures carry `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). An existing structured `HarnessError` rejected by the bash executor, including `SANDBOX_UNAVAILABLE`, propagates unchanged; only an untyped spawn, cwd, or shell-start rejection becomes `SEARCH_FAILED`, while an aborted signal remains `SEARCH_ABORTED`. ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. ## Model Experience @@ -114,7 +114,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. +Search-owned failures render as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata; structured bash-executor failures retain their owning name and code. #### Token effect diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 7dd6cdf9a2..5cec79577d 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -46,7 +46,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 错误 -搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 +搜索层自身的失败携带 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。bash 执行器拒绝并返回的既有结构化 `HarnessError`(包括 `SANDBOX_UNAVAILABLE`)会原样传播;只有无类型的 spawn、cwd 或 shell 启动拒绝会转换为 `SEARCH_FAILED`,中止信号仍为 `SEARCH_ABORTED`。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 ## 模型体验 @@ -114,7 +114,7 @@ glob 描述会说明配置所指定的超限结果排序方式。已生成的 [` #### 模型看到的内容 -失败会规范化为 `Error: `,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。 +搜索层自身的失败会渲染为 `Error: `,并附带结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据;来自 bash 执行器的结构化失败则保留其原有名称和错误码。 #### Token 影响 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index bf9cf15aa0..1ee39d93ab 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,10 +44,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 402fc9d655..eb29963ef0 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -152,10 +152,9 @@ function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxByt * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / * `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's - * infrastructure failures (pre-aborted signal, unusable workdir, missing - * shell) — is translated into the same taxonomy: a pre-aborted signal becomes - * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as - * `cause`. + * infrastructure failures becomes `SEARCH_ABORTED` when the forwarded signal + * aborted, propagates an existing structured {@link HarnessError} unchanged, + * and wraps only untyped spawn/workdir/shell-start errors as `SEARCH_FAILED`. * * @param ctx - the plugin context; execution uses its `bash` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. @@ -182,12 +181,13 @@ export async function runRipgrep( try { result = await ctx.bash.run(spec) } catch (error: unknown) { - // The seam contract: run() REJECTS only for infrastructure failures — a - // pre-aborted signal, an unusable workdir, a missing shell. Translate them - // so these failures stay machine-routable under the SEARCH_* taxonomy. + // Abort owns the outcome even when the executor rejects during teardown. if (spec.signal?.aborted === true) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) } + // Infrastructure implementations may already provide a stable harness + // error (notably SANDBOX_UNAVAILABLE); preserve that owning taxonomy. + if (error instanceof HarnessError) throw error throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) } if (result.aborted) { diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 8cb96e7e66..3d6ef25761 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,6 +18,10 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -68,6 +72,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) afterEach(async () => { + await ctx.fiber.dispose() await rm(dir, { recursive: true, force: true }) }) @@ -192,4 +197,59 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () expect(text(result)).toContain('could not start') }) }) + + it('preserves rg semantics through partial Landlock and propagates a real structured sandbox failure', async () => { + await ctx.fiber.dispose() + const launcher = join(dir, 'landlock-run') + const failMarker = join(dir, 'fail-runner') + await writeFile(launcher, `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro|--rw) shift 2 ;; + --) shift; break ;; + *) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;; + esac +done +printf '%s\\n' 'landlock-run: partial enforcement (older Landlock ABI)' >&2 +if [ -e ${ToolFsSearch.singleQuote(failMarker)} ]; then + printf '%s\\n' 'landlock-run: landlock ruleset error: fixture failure' >&2 + exit 125 +fi +exec "$@" +`, { mode: 0o755 }) + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSandboxProvider, {}) + ;(ctx.sandbox as LocalSandboxProvider).internals = { + platform: 'linux', + probeBwrap: () => false, + probeLandlock: () => 'partial', + landlockLauncher: launcher, + } + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: dir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxBashExecutor, { cwd: dir, timeoutMs: 20_000 }) + await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) + + const grepNoMatch = await call('grep', { pattern: 'does-not-exist' }) + expect(grepNoMatch.isError).toBe(false) + expect(text(grepNoMatch)).toBe('No matches found') + + const globNoFiles = await call('glob', { pattern: '*.does-not-exist' }) + expect(globNoFiles.isError).toBe(false) + expect(text(globNoFiles)).toBe('No files found') + + const invalidRegex = await call('grep', { pattern: '(unclosed' }) + expect(invalidRegex.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) + + await writeFile(failMarker, '') + const sandboxFailure = await call('grep', { pattern: 'alpha' }) + expect(sandboxFailure.error).toMatchObject({ + info: { name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }, + }) + expect(text(sandboxFailure)).toContain('Runner failure: landlock-run: landlock ruleset error: fixture failure') + expect(text(sandboxFailure)).not.toContain('could not start its search command') + }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 389d8dbe05..3b9b5ad6fe 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { join, sep } from 'node:path' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' +import { CallId, HarnessError, createUserMessage } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' @@ -373,7 +373,7 @@ describe('workdir derivation and signal forwarding', () => { const controller = new AbortController() bash.handler = () => { controller.abort('cancel search') - throw new Error('executor stopped on abort') + throw new HarnessError('structured executor failure during abort', 'SANDBOX_UNAVAILABLE') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) @@ -403,6 +403,26 @@ describe('workdir derivation and signal forwarding', () => { expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) + + it('propagates an existing structured bash failure unchanged', async () => { + const { ctx, bash } = await setup() + const upstream = new HarnessError('sandbox runner failed', 'SANDBOX_UNAVAILABLE') + bash.handler = () => { throw upstream } + + const rejection = ToolFsSearch.runRipgrep( + ctx, + { signal: testToolSignal } as never, + 'grep', + "rg --json --regexp='x'", + 20_000_000, + ) + await expect(rejection).rejects.toBe(upstream) + + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.error).toMatchObject({ info: { name: 'HarnessError', code: 'SANDBOX_UNAVAILABLE' } }) + expect(text(result)).toContain('sandbox runner failed') + expect(text(result)).not.toContain('could not start') + }) }) describe('exit semantics and failure classification', () => { diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d9d8aa16d1..abacdaaf9f 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -16,7 +16,7 @@ import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' class EmptySandbox extends SandboxProvider { confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } @@ -25,7 +25,7 @@ class RecordingSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6fa7804980..52d832a7fd 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -26,7 +26,7 @@ class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { this.calls.push({ argv, policy }) - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b1bc368ab2..15748147de 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -31,7 +31,7 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d6b4a08968..35a4eb5739 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -31,7 +31,7 @@ afterEach(async () => { class PassthroughSandbox extends SandboxProvider { confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } } } diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 1bcf308a49..2f3b8d24d0 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3 -README.zh.md: 5c02b0ce3fac1e39a16f8fc32517856996c30e23 +README.md: d53f138a9a752a6ea62992f269bf76680a24d49f +README.zh.md: b0f599af04f101712029585677b7a25fc11e3908 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 923d983be8..d53f138a9a 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -6,9 +6,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal. -Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. -Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. +Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Separate outer-shell rules use the resolved/configured argv0 with missing or unexecutable wording and exit 126/127. `runnerCommand` remains the operator-facing config key for a custom runner's fatal signatures and skips probes. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 5c02b0ce3f..b0f599af04 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -6,9 +6,9 @@ 包(package)根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。 -不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 +不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。 -策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形式 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会被拒绝。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 +策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。独立的外层 shell 规则使用解析后或配置的 argv0、缺失或不可执行诊断文本,以及退出码 126/127。`runnerCommand` 仍是面向运维人员的配置键,用于指定自定义 runner 的致命签名,并会跳过探测。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。 Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 98dc86d23e..f8ccdc0c9d 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,18 +1,23 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr dialects. Missing or unusable + * reports each wrap's enforcement and stderr classification facts. Missing or unusable * confinement fails closed rather than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run' +import { + LAUNCHER_BIN, + LAUNCHER_FAILURE_EXIT, + launcherPath as landlockLauncherPath, + probe as defaultProbeLandlock, +} from 'node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -142,15 +147,36 @@ const DENIAL_SIGNATURES = { } as const satisfies Record /** - * Runner-owned stderr prefixes cover both internal refusal and shell-level - * not-found errors. Consumers match these before denial text because the - * command never ran on this path. + * Runner-owned fatal diagnostics. Landlock's launcher contract reserves exit + * 125 for launcher failure and emits the partial-ABI notice before successful + * child execution, so both the status gate and the exact exclusion are needed. */ -const RUNNER_FAILURE_SIGNATURES = { - bwrap: ['bwrap: '], - landlock: [`${LAUNCHER_BIN}: `], - seatbelt: ['sandbox-exec: '], -} as const satisfies Record +const RUNNER_FAILURE_RULES = { + bwrap: [{ fatalSignatures: ['bwrap: '] }], + landlock: [{ + allowedExitCodes: [LAUNCHER_FAILURE_EXIT], + fatalSignatures: [`${LAUNCHER_BIN}: `], + informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], + }], + seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], +} as const satisfies Record + +/** + * Failure shapes emitted by the outer `bash -c 'exec ...'` before the runner + * starts. Shells vary between 126 and 127 for a missing path containing `/`, + * but keep the configured/resolved argv0 and missing/unexecutable wording. + */ +function outerShellFailureRules(argv0: string): readonly RunnerFailureRule[] { + return [{ + allowedExitCodes: [126, 127], + fatalSignatures: [ + `exec: ${argv0}: not found`, + `${argv0}: No such file or directory`, + `${argv0}: Permission denied`, + `exec: ${argv0}: cannot execute`, + ], + }] +} /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless @@ -204,7 +230,7 @@ export class LocalSandboxProvider extends SandboxProvider { * @param argv - the exact argv the caller is about to spawn. * @param policy - the file-effect policy this execution runs under. * @returns the wrapped argv plus the selected backend's enforcement completeness, denial - * signatures, and runner-failure signatures; throws the fail-closed + * signatures, and structured runner-failure rules; throws the fail-closed * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner. */ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { @@ -214,23 +240,25 @@ export class LocalSandboxProvider extends SandboxProvider { argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv], enforcement: 'full', denialSignatures: DENIAL_SIGNATURES.runnerCommand, - // The operator names the configured runner's own pre-exec refusal dialect; the consumer - // additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the - // missing/unexecutable outer-shell shapes ourselves. - runnerFailureSignatures: [ - ...this.configuredRunnerFailureSignatures, - `exec: ${argv0}: not found`, - `${argv0}: No such file or directory`, - `${argv0}: Permission denied`, + // Preserve the operator-facing signature config as one internal rule; + // outer-shell launch failures remain a separate, argv0-scoped rule. + runnerFailureRules: [ + ...outerShellFailureRules(argv0), + { fatalSignatures: this.configuredRunnerFailureSignatures }, ], } } const selected = this.selectRunner(policy.mode) + const runnerArgv = this.runnerArgv(selected.runner, policy) + const argv0 = runnerArgv[0] as string return { - argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], + argv: [...runnerArgv, '--', ...argv], enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], - runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], + runnerFailureRules: [ + ...outerShellFailureRules(argv0), + ...RUNNER_FAILURE_RULES[selected.runner], + ], } } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f7cc952498..94078facc6 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -23,6 +23,19 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } +/** Expected argv0-scoped rule for failures in bash's outer `exec`. */ +function outerShellRule(argv0: string) { + return { + allowedExitCodes: [126, 127], + fatalSignatures: [ + `exec: ${argv0}: not found`, + `${argv0}: No such file or directory`, + `${argv0}: Permission denied`, + `exec: ${argv0}: cannot execute`, + ], + } +} + async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) { const ctx = new Context() await ctx.plugin(LocalSandboxProvider, config) @@ -115,11 +128,9 @@ describe('runnerCommand config', () => { // wrap through an outer `bash -c 'exec …'` — a missing or // unexecutable runner fails with the OUTER shell's argv0-scoped // shapes, and those classify as sandbox failures like any rung. - runnerFailureSignatures: [ - 'fake-runner: profile rejected', - 'exec: fake-runner: not found', - 'fake-runner: No such file or directory', - 'fake-runner: Permission denied', + runnerFailureRules: [ + outerShellRule('fake-runner'), + { fatalSignatures: ['fake-runner: profile rejected'] }, ], }) expect(probeBwrap).not.toHaveBeenCalled() @@ -163,7 +174,10 @@ describe('the platform chains', () => { argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['read-only file system'], - runnerFailureSignatures: ['bwrap: '], + runnerFailureRules: [ + outerShellRule('bwrap'), + { fatalSignatures: ['bwrap: '] }, + ], }) expect(probeLandlock).not.toHaveBeenCalled() }) @@ -178,14 +192,21 @@ describe('the platform chains', () => { argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['permission denied'], - runnerFailureSignatures: ['landlock-run: '], + runnerFailureRules: [ + outerShellRule(launcher), + { + allowedExitCodes: [125], + fatalSignatures: ['landlock-run: '], + informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'], + }, + ], }) expect(probeLandlock).toHaveBeenCalledWith(launcher) }) it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => { // The safety property moves to execution time: an unusable sandbox-exec - // refuses to run the command, and the wrap's runnerFailureSignatures let + // refuses to run the command, and the wrap's runnerFailureRules let // the consumer classify that as a sandbox failure, not a task failure. const probeSeatbelt = vi.fn(() => true) const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt }) @@ -194,7 +215,10 @@ describe('the platform chains', () => { argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [ + outerShellRule('sandbox-exec'), + { fatalSignatures: ['sandbox-exec: '] }, + ], }) expect(probeSeatbelt).not.toHaveBeenCalled() }) @@ -360,7 +384,10 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'], enforcement: 'full', denialSignatures: ['operation not permitted'], - runnerFailureSignatures: ['sandbox-exec: '], + runnerFailureRules: [ + outerShellRule(exec), + { fatalSignatures: ['sandbox-exec: '] }, + ], }) }) diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 83b28c41b8..4fe0ad79e6 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/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/README.md -README.md: 99f0641560937f66df6db76ae55c90595329792f -README.zh.md: 5d7dfb303ea2405e725d162698c3ab3d39485636 +README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 +README.zh.md: 310b6bd5f526678e61d72846f154e7226495fca6 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 99f0641560..1b522b2c72 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. -The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. +The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus the selected backend's enforcement completeness, denial dialect (`denialSignatures`), and structured runner-failure evidence (`runnerFailureRules`); when no backend is usable it throws rather than passing the argv through unconfined. The [core type catalog](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) owns the exact classifier shape. Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy. @@ -39,4 +39,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions. - **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here. - **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output. +- **Runner diagnostics are in-band** — exit status plus stderr evidence cannot prove which process wrote a matching line, so a confined child that deliberately mimics its runner can cause an availability/diagnostic false attribution. This cannot bypass confinement; an out-of-band runner-status channel is deferred. - **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity. diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index 5d7dfb303e..310b6bd5f5 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -4,7 +4,7 @@ 抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包(package):只依赖 cordis(及 harness 错误基类),绝不依赖后端。 -用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件操作被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是统一的跨后端签名集合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。 +用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。 策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。 @@ -39,4 +39,5 @@ sandbox mode "" is requested but no sandbox backend is usable on this host - **文件操作是完整的策略词汇**:该 seam 不表达网络、进程、系统调用、设备或凭据限制。 - **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。 - **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。 +- **Runner 诊断使用带内通道**:退出状态与 stderr 证据无法证明匹配行由哪个进程写入,因此受限子进程若故意模仿 runner,就可能造成可用性或诊断误归因。这无法绕过约束;带外 runner 状态通道暂缓实现。 - **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 781227f411..aeca1ba8d6 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -62,6 +62,22 @@ export interface SandboxPolicy extends SandboxExecutionPolicy { mode: ConfinedSandboxMode } +/** + * Evidence that identifies a sandbox runner failing before it executes the + * wrapped command. A consumer first applies {@link allowedExitCodes} when + * present, removes {@link informationalLines} by case-insensitive exact line + * equality, then matches {@link fatalSignatures} case-insensitively within + * each remaining stderr line. Exit status alone never proves runner failure. + */ +export interface RunnerFailureRule { + /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */ + allowedExitCodes?: readonly number[] + /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */ + fatalSignatures: readonly string[] + /** Benign stderr lines excluded by exact full-line equality before fatal matching. */ + informationalLines?: readonly string[] +} + /** * A {@link SandboxProvider.confine} result: the argv to spawn in place of * the caller's own, plus the enforcement completeness the selected backend @@ -82,11 +98,12 @@ export interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * Case-insensitive signatures for runner failure before command execution. - * Consumers check these before denial signatures: runner failure means the + * Structured runner-failure evidence rules. Consumers require a matching + * fatal stderr line (after informational exclusions) and any rule-specific + * exit-code gate before checking denial signatures: runner failure means the * command never ran, while denial means confinement worked and blocked it. */ - runnerFailureSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b634898ea..a947b4b0cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3018,6 +3018,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3027,6 +3030,15 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3d1fcc8f9d..f8e56eab8d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -889,6 +889,11 @@ "symbol": "SandboxPolicyRequest", "source": "packages/sandbox/sandbox-policy/src/index.ts" }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "RunnerFailureRule", + "source": "packages/sandbox/sandbox/src/index.ts" + }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv",