From 49864414d11c9e704a72bd70462b03286c888c46 Mon Sep 17 00:00:00 2001 From: xuwenhao Date: Mon, 20 Jul 2026 11:17:59 +0800 Subject: [PATCH 1/3] feat(agent-core-v2): add OS-level sandbox for Bash and file tools Add an optional [sandbox] config section that wraps Bash command execution in an OS-level sandbox (bubblewrap on Linux, sandbox-exec on macOS) and tightens file-tool path policies through the permission chain: - New session/sandbox domain (L3): config section, policy resolution, bwrap/seatbelt backends, and a session-scoped ISandboxService that decides per command (sandboxed / excluded / unsandboxed / blocked). - Bash tool wraps its shell argv when sandboxed and appends an explanatory hint when the output shows sandbox-denial signatures. - Three new permission policies: sandboxed Bash auto-approve (auto_allow_sandboxed_bash), sandbox filesystem hard denies (deny_read / deny_write / read-only mode / sensitive files), and outside-workspace ask. - Fail-open with a one-time warning when no backend is available; require = true switches to fail-closed. - Network domain allowlisting config is reserved for a follow-up (Phase 3); setting allowed_domains emits a warning for now. - Docs: docs/en|zh/configuration/sandbox.md. --- .changeset/feat-agent-sandbox.md | 5 + docs/.vitepress/config.ts | 2 + docs/en/configuration/sandbox.md | 112 ++++++++ docs/zh/configuration/sandbox.md | 112 ++++++++ .../agent-core-v2/docs/config-manifest.toml | 27 +- .../scripts/check-domain-layers.mjs | 5 + .../permissionPolicyService.ts | 6 + .../policies/sandbox-fs-deny.ts | 107 +++++++ .../policies/sandbox-outside-workspace-ask.ts | 42 +++ .../policies/sandboxed-bash-approve.ts | 20 ++ packages/agent-core-v2/src/index.ts | 16 ++ .../src/os/backends/node-local/tools/bash.ts | 53 +++- .../session/sandbox/backends/bwrapBackend.ts | 83 ++++++ .../sandbox/backends/sandboxBackend.ts | 25 ++ .../sandbox/backends/seatbeltBackend.ts | 69 +++++ .../src/session/sandbox/configSection.ts | 94 ++++++ .../src/session/sandbox/pathRules.ts | 40 +++ .../src/session/sandbox/sandbox.ts | 23 ++ .../src/session/sandbox/sandboxPolicy.ts | 60 ++++ .../src/session/sandbox/sandboxService.ts | 179 ++++++++++++ .../src/session/sandbox/sandboxTypes.ts | 55 ++++ .../agent-core-v2/src/tool/toolContract.ts | 2 + .../permissionPolicyService.test.ts | 125 ++++++++ .../policies/sandbox-fs-deny.test.ts | 173 +++++++++++ .../sandbox-outside-workspace-ask.test.ts | 118 ++++++++ .../policies/sandboxed-bash-approve.test.ts | 82 ++++++ .../os/backends/node-local/tools/bash.test.ts | 89 +++++- .../test/session/sandbox/bwrapBackend.test.ts | 154 ++++++++++ .../session/sandbox/bwrapIntegration.test.ts | 164 +++++++++++ .../session/sandbox/configSection.test.ts | 108 +++++++ .../session/sandbox/sandboxPolicy.test.ts | 85 ++++++ .../session/sandbox/sandboxService.test.ts | 270 ++++++++++++++++++ .../session/sandbox/seatbeltBackend.test.ts | 105 +++++++ 33 files changed, 2597 insertions(+), 13 deletions(-) create mode 100644 .changeset/feat-agent-sandbox.md create mode 100644 docs/en/configuration/sandbox.md create mode 100644 docs/zh/configuration/sandbox.md create mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts create mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts create mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/sandboxed-bash-approve.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/backends/seatbeltBackend.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/configSection.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/pathRules.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/sandbox.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/sandboxService.ts create mode 100644 packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts create mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts create mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.test.ts create mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/sandboxed-bash-approve.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/bwrapBackend.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/bwrapIntegration.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/configSection.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/seatbeltBackend.test.ts diff --git a/.changeset/feat-agent-sandbox.md b/.changeset/feat-agent-sandbox.md new file mode 100644 index 0000000000..f68ccb4d52 --- /dev/null +++ b/.changeset/feat-agent-sandbox.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add an optional OS-level sandbox for Bash command execution and file-tool path policies, configured through a new `[sandbox]` section. On Linux commands run under bubblewrap, on macOS under sandbox-exec: reads of configured sensitive paths are masked, writes are restricted to the workspace roots (workspace-write mode) or tmpdir (read-only mode), and the network is disabled by default. Sandboxed commands skip permission prompts by default (`auto_allow_sandboxed_bash`), sandbox filesystem denies (deny_read / deny_write / sensitive files) are hard boundaries evaluated before auto-approvals, and file tools reading or writing outside the workspace fall back to an approval prompt. When no sandbox backend is available the CLI warns once and runs unsandboxed; `require = true` switches to fail-closed. See `docs/en/configuration/sandbox.md` for the full reference. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f35266ad53..4fd0110c7b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -81,6 +81,7 @@ const config = withMermaid(defineConfig({ { text: '配置覆盖', link: '/zh/configuration/overrides' }, { text: '环境变量', link: '/zh/configuration/env-vars' }, { text: '数据路径', link: '/zh/configuration/data-locations' }, + { text: '沙箱', link: '/zh/configuration/sandbox' }, ], }, ], @@ -158,6 +159,7 @@ const config = withMermaid(defineConfig({ { text: 'Config Overrides', link: '/en/configuration/overrides' }, { text: 'Environment Variables', link: '/en/configuration/env-vars' }, { text: 'Data Locations', link: '/en/configuration/data-locations' }, + { text: 'Sandbox', link: '/en/configuration/sandbox' }, ], }, ], diff --git a/docs/en/configuration/sandbox.md b/docs/en/configuration/sandbox.md new file mode 100644 index 0000000000..0e98517777 --- /dev/null +++ b/docs/en/configuration/sandbox.md @@ -0,0 +1,112 @@ +# Sandbox + +Kimi Code can run the agent's `Bash` commands inside an OS-level sandbox — the same approach as claude-code (sandbox-runtime) and codex: the whole filesystem is mounted read-only inside the sandbox, only the workspace and a small set of roots stay writable, sensitive paths can be masked, and the network is cut off by default. The sandbox is enforced by the kernel (Linux namespaces via [bubblewrap](https://github.com/containers/bubblewrap), macOS Seatbelt via `sandbox-exec`), so it holds regardless of what the command line does — unlike the lexical path checks the file tools already perform. + +The sandbox only wraps `Bash` command execution. File tools (Read/Write/Edit/Grep/Glob) are covered separately through the permission policies described in [Interaction with the permission system](#interaction-with-the-permission-system). + +## Quick start + +```toml +# ~/.kimi-code/config.toml +[sandbox] +enabled = true +``` + +On Linux, install bubblewrap first (`apt install bubblewrap` / `dnf install bubblewrap` / `pacman -S bubblewrap`). On macOS no extra dependency is needed (`sandbox-exec` ships with the system). See [Platform support](#platform-support) and [Failure behavior](#failure-behavior). + +## Platform support + +| Platform | Backend | Requirement | +| --- | --- | --- | +| Linux | bubblewrap (`bwrap`) | `bwrap` on PATH, with user namespaces usable (the availability probe actually runs a trivial sandboxed command, so restrictions like Ubuntu 24.04's AppArmor user-namespace limits are detected) | +| macOS | Seatbelt (`sandbox-exec`) | None (system binary) | +| Windows | Not supported | Commands run unsandboxed; a warning is logged once per session | + +The backend is probed lazily on the first sandboxed command and cached for the rest of the session. + +## Configuration reference + +All fields are optional; the defaults below apply when a field is omitted. + +```toml +[sandbox] +enabled = false # Master switch. Default: off. +mode = "workspace-write" # "workspace-write" | "read-only". Write baseline inside the sandbox. +require = false # true = refuse to run Bash when no backend is available (fail-closed). +auto_allow_sandboxed_bash = true # Sandboxed Bash calls skip the approval prompt. +excluded_commands = [] # Command prefixes that bypass the sandbox. + +[sandbox.filesystem] +deny_read = [] # Paths masked so the sandboxed process cannot read them. +allow_write = [] # Extra writable roots on top of the mode defaults. +deny_write = [] # Paths inside writable roots re-protected as read-only. + +[sandbox.network] +enabled = false # false = no network inside the sandbox. +allowed_domains = [] # Reserved for the Phase 3 domain allowlist proxy; inert today (see note). +allow_unix_sockets = [] # Reserved (e.g. an ssh-agent socket); inert today. +``` + +### `mode` + +- **`workspace-write`** (default): writable roots are the session working directory, its additional directories, the system temp directory, and everything in `filesystem.allow_write`. +- **`read-only`**: writable roots shrink to the system temp directory plus `filesystem.allow_write`. The workspace itself is read-only — useful for audit / review-style sessions. Writes outside the writable roots are also hard-denied by the permission layer in this mode (see below). + +### `filesystem` rule semantics + +`deny_read`, `allow_write`, and `deny_write` entries are **literal paths, not glob patterns**: + +- A leading `~` is expanded to your home directory. +- An entry matches the path itself or anything beneath it (a trailing `/**` is accepted and simply marks the subtree explicitly). Matching is separator-aware — `/foo` never matches `/foo-evil/x`. +- Other glob syntax (`*`, `**` in the middle, `?`) is not supported. +- `deny_read` beats the writable roots, `deny_write` beats `allow_write` / the mode defaults — deny always wins. +- On Linux, `deny_read` / `deny_write` entries that do not exist are skipped (bubblewrap cannot mount over a non-existent path). + +Sensitive files (`~/.ssh/id_rsa`, `.env`, cloud credentials, …) are always denied by the permission layer while the sandbox is enabled, even without listing them in `deny_read` — see below. + +### `network` + +With `enabled = false` (default) the sandboxed command runs in a private network namespace with no connectivity (Linux) or with all network operations denied (macOS). + +::: warning +`allowed_domains` is accepted by the schema but **has no effect yet** — the domain-allowlist proxy ships in Phase 3. Setting it prints a one-time warning; network access is still governed solely by `network.enabled`. +::: + +### `excluded_commands` matching semantics + +Some commands cannot work inside a sandbox (e.g. `docker`, which needs its daemon socket). `excluded_commands` entries bypass the sandbox and run through the normal approval flow instead. Matching: + +1. The command line is split into segments on `&&`, `||`, `;`, `|`, and newlines. +2. Leading `VAR=value` environment assignments are stripped from each segment. +3. An entry matches when it equals the segment text or is a prefix of it followed by a space — so `docker` matches `docker ps` but not `docker-compose up`, and multi-word entries like `"git push"` work as expected. + +## Failure behavior + +- **Fail-open (default)**: if the sandbox backend is missing or unusable (no `bwrap`, restricted user namespaces, unsupported platform), a warning is logged once and commands run unsandboxed. +- **Fail-closed**: with `require = true`, a missing backend makes every `Bash` call fail with an explanatory error instead of running. + +When a sandboxed command's output shows sandbox-denial signatures (e.g. `Operation not permitted`, a `bwrap:` error), the tool result carries a hint suggesting you add the command to `sandbox.excluded_commands` in case it was a false positive. + +## Interaction with the permission system + +While `sandbox.enabled = true`, three permission policies take effect (in addition to the OS-level enforcement on `Bash`): + +1. **Sandboxed Bash skip-approval** — a `Bash` call that will run inside the sandbox is approved without prompting (disable with `auto_allow_sandboxed_bash = false`). User `deny` rules and the checks below still take precedence. +2. **Hard deny** — evaluated before mode-based auto-approval (`--auto` / `--yolo`): + - reads/searches matching `filesystem.deny_read`; + - writes matching `filesystem.deny_write`; + - in `read-only` mode, writes outside the writable roots; + - any access to a **sensitive file** (env files, SSH keys, cloud credentials — the same patterns the file tools already guard), which is upgraded from "ask" to a hard "deny" while the sandbox is enabled. +3. **Outside-workspace ask** — file-tool accesses (read/write/search) outside the writable roots ask for approval instead of passing silently. + +## Known limitations + +- Only `Bash` is wrapped by the OS sandbox. External hooks run unsandboxed (same as claude-code), and the `Grep` tool's `rg` subprocess is not sandboxed separately — its search roots are already guarded by the workspace path checks plus the policies above. +- On Linux, `deny_read` masks only paths that exist when the command starts (bubblewrap cannot mount over a non-existent path). +- `network.allowed_domains` and `network.allow_unix_sockets` are reserved schema fields with no behavior yet (Phase 3). +- Windows is not supported; commands run unsandboxed there. + +## Next steps + +- [Configuration files](./config-files.md) — complete reference for all configurable fields +- [Config overrides](./overrides.md) — how config files, command-line options, and environment variables interact diff --git a/docs/zh/configuration/sandbox.md b/docs/zh/configuration/sandbox.md new file mode 100644 index 0000000000..1d26a5a003 --- /dev/null +++ b/docs/zh/configuration/sandbox.md @@ -0,0 +1,112 @@ +# 沙箱 + +Kimi Code 可以把 Agent 的 `Bash` 命令放进 OS 级沙箱里运行——与 claude-code(sandbox-runtime)和 codex 同一思路:沙箱内整个文件系统只读挂载,只有工作区和少量目录可写,敏感路径可以被遮蔽,网络默认断开。沙箱由内核强制生效(Linux 用 [bubblewrap](https://github.com/containers/bubblewrap) 的 namespace,macOS 用 `sandbox-exec` 的 Seatbelt),无论命令行怎么写都绕不过——这与文件工具已有的词法路径检查是互补关系。 + +沙箱只包裹 `Bash` 命令的执行。文件工具(Read/Write/Edit/Grep/Glob)通过权限策略覆盖,见[与权限系统的关系](#与权限系统的关系)。 + +## 快速开始 + +```toml +# ~/.kimi-code/config.toml +[sandbox] +enabled = true +``` + +Linux 上需要先装 bubblewrap(`apt install bubblewrap` / `dnf install bubblewrap` / `pacman -S bubblewrap`);macOS 无需额外依赖(`sandbox-exec` 系统自带)。见[平台支持](#平台支持)与[失败行为](#失败行为)。 + +## 平台支持 + +| 平台 | 后端 | 要求 | +| --- | --- | --- | +| Linux | bubblewrap(`bwrap`) | `bwrap` 在 PATH 上,且 user namespace 可用(可用性探测会真的跑一条沙箱命令,所以 Ubuntu 24.04 的 AppArmor user-namespace 限制也能被发现) | +| macOS | Seatbelt(`sandbox-exec`) | 无(系统自带) | +| Windows | 不支持 | 命令直接非沙箱运行,每个会话打一次警告日志 | + +后端在第一条沙箱命令时惰性探测,结果缓存到会话结束。 + +## 配置项参考 + +所有字段都可省略,省略时按下表默认值生效。 + +```toml +[sandbox] +enabled = false # 总开关,默认关。 +mode = "workspace-write" # "workspace-write" | "read-only",沙箱内写权限基准。 +require = false # true = 无可用后端时拒绝执行 Bash(fail-closed)。 +auto_allow_sandboxed_bash = true # 沙箱内执行的 Bash 免审批。 +excluded_commands = [] # 命中前缀的命令段不进沙箱。 + +[sandbox.filesystem] +deny_read = [] # 遮蔽后沙箱内进程读不到的路径。 +allow_write = [] # 在模式默认之外额外可写的根目录。 +deny_write = [] # 在可写集合内再排除、恢复只读的路径。 + +[sandbox.network] +enabled = false # false = 沙箱内断网。 +allowed_domains = [] # Phase 3 域名白名单代理的预留字段,当前不生效(见下方说明)。 +allow_unix_sockets = [] # 预留字段(如 ssh-agent socket),当前不生效。 +``` + +### `mode` + +- **`workspace-write`(默认)**:可写根 = 会话工作目录 + additionalDirs + 系统临时目录 + `filesystem.allow_write`。 +- **`read-only`**:可写根收缩为系统临时目录 + `filesystem.allow_write`,工作区本身只读——适合审计、评审类会话。此模式下写出可写根之外的路径也会被权限层硬拒绝(见下文)。 + +### `filesystem` 规则语义 + +`deny_read`、`allow_write`、`deny_write` 的条目是**字面路径,不是 glob 模式**: + +- 开头的 `~` 展开为用户主目录。 +- 条目匹配路径自身及其下所有内容(结尾写 `/**` 也可以,只是显式标记子树)。匹配按分隔符对齐——`/foo` 不会匹配 `/foo-evil/x`。 +- 不支持其他 glob 语法(`*`、中间的 `**`、`?`)。 +- `deny_read` 优先于可写根,`deny_write` 优先于 `allow_write` 和模式默认——deny 永远赢。 +- Linux 下 `deny_read` / `deny_write` 里不存在的路径会被跳过(bubblewrap 无法在不存在的路径上挂载)。 + +敏感文件(`~/.ssh/id_rsa`、`.env`、云厂商凭证等)在沙箱启用时一律被权限层硬拒绝,无需写进 `deny_read`——见下文。 + +### `network` + +`enabled = false`(默认)时,沙箱内命令运行在独立网络命名空间、无任何连接(Linux),或所有网络操作被拒绝(macOS)。 + +::: warning +`allowed_domains` 目前**只被 schema 接受,不生效**——域名白名单代理在 Phase 3 落地。配置了它会打一次警告;网络访问仍只由 `network.enabled` 决定。 +::: + +### `excluded_commands` 匹配语义 + +有些命令在沙箱里无法工作(比如要访问 daemon socket 的 `docker`)。`excluded_commands` 命中的命令不进沙箱,改走正常审批流程。匹配规则: + +1. 命令行按 `&&`、`||`、`;`、`|`、换行切成段。 +2. 每段剥掉开头的 `VAR=value` 环境变量赋值。 +3. 条目等于段文本、或作为段文本前缀且后跟空格即命中——`docker` 能匹配 `docker ps` 但不匹配 `docker-compose up`;`"git push"` 这类带空格的多词条目也按此规则工作。 + +## 失败行为 + +- **fail-open(默认)**:后端缺失或不可用(没装 `bwrap`、user namespace 受限、平台不支持)时,打一次警告日志,命令按非沙箱运行。 +- **fail-closed**:`require = true` 时,后端不可用会让每个 `Bash` 调用直接报错,不会执行。 + +当沙箱命令的输出出现沙箱拒绝特征(如 `Operation not permitted`、`bwrap:` 报错)时,工具结果会追加提示,建议把命令加入 `sandbox.excluded_commands`,便于处理误伤。 + +## 与权限系统的关系 + +`sandbox.enabled = true` 时,三条权限策略生效(叠加在 `Bash` 的 OS 级沙箱之上): + +1. **沙箱 Bash 免审批**——将在沙箱内运行的 `Bash` 调用直接批准,不再询问(可用 `auto_allow_sandboxed_bash = false` 关闭)。用户配置的 `deny` 规则和下面的检查仍然优先。 +2. **硬拒绝**——在模式自动批准(`--auto` / `--yolo`)之前判定: + - 读/搜索命中 `filesystem.deny_read`; + - 写命中 `filesystem.deny_write`; + - `read-only` 模式下写出可写根之外; + - 任意 operation 命中**敏感文件**(env 文件、SSH 私钥、云凭证——与文件工具既有守护相同的模式),沙箱启用期间从「询问」升级为硬「拒绝」。 +3. **越界询问**——文件工具的访问(读/写/搜索)越出可写根时,转为询问审批,不再静默放行。 + +## 已知限制 + +- 只有 `Bash` 走 OS 沙箱。外部 hooks 不在沙箱内运行(与 claude-code 一致);`Grep` 工具的 `rg` 子进程不单独过沙箱——其搜索根已被工作区路径检查和上述策略覆盖。 +- Linux 下 `deny_read` 只遮蔽命令启动时已存在的路径(bubblewrap 无法在不存在的路径上挂载)。 +- `network.allowed_domains` 与 `network.allow_unix_sockets` 是预留 schema 字段,暂无行为(Phase 3)。 +- Windows 不支持,命令按非沙箱运行。 + +## 下一步 + +- [配置文件](./config-files.md)——所有可配置字段的完整参考 +- [配置覆盖](./overrides.md)——配置文件、命令行参数与环境变量如何配合 diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index e95ea6da00..d76fe47cb1 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (21 sections · 2 overlay(s)) +# Index (22 sections · 2 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts @@ -25,6 +25,7 @@ # models src/app/kosongConfig/configSection.ts # permission src/agent/permissionRules/configSection.ts # providers src/app/kosongConfig/configSection.ts +# sandbox src/session/sandbox/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts # task src/agent/task/configSection.ts @@ -283,6 +284,30 @@ merge_all_available_skills = true # env: record # source: record +# ########################################################################## +# sandbox +# owner: src/session/sandbox/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[sandbox] +# enabled: boolean +# mode: "workspace-write" | "read-only" +# require: boolean +# auto_allow_sandboxed_bash: boolean +# excluded_commands: string[] + +# [sandbox.filesystem] + # deny_read: string[] + # allow_write: string[] + # deny_write: string[] + +# [sandbox.network] + # enabled: boolean + # allowed_domains: string[] + # allow_unix_sockets: string[] + # ########################################################################## # services # owner: src/app/auth/configSection.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 5bc5f5580e..a80fa25a00 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -153,6 +153,11 @@ const DOMAIN_LAYER = new Map([ ['modelCatalog', 3], ['agentProfileCatalog', 3], ['agentFileCatalog', 3], + // `sandbox` is the Session-scope OS command-sandbox decision point (bwrap / + // seatbelt policy + backend probe); it consumes `config` (L2), + // `workspaceContext` (L1) and the `os/interface` host bridges (L1), and is + // consumed by the L3 `tool` contract and the L6 `os/backends` Bash tool. + ['sandbox', 3], // L4 — agent behaviour // `activityView` is the Agent-scope read model folding the agent's own event // bus into the activity projection (`agent.activity.updated`); it owns no diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts index 38d774b766..28d7218fd9 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -18,6 +18,9 @@ import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPol import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask'; import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask'; import { GitCwdWriteApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/git-cwd-write-approve'; +import { SandboxFsDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/sandbox-fs-deny'; +import { SandboxOutsideWorkspaceAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sandbox-outside-workspace-ask'; +import { SandboxedBashApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/sandboxed-bash-approve'; import { SensitiveFileAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sensitive-file-access-ask'; import { SessionApprovalHistoryPermissionPolicyService } from '#/agent/permissionPolicy/policies/session-approval-history'; import { UserConfiguredAllowPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-allow'; @@ -47,12 +50,15 @@ export class AgentPermissionPolicyService this.policies = [ this.instantiation.createInstance(AutoModeAskUserQuestionDenyPermissionPolicyService), this.instantiation.createInstance(UserConfiguredDenyPermissionPolicyService), + this.instantiation.createInstance(SandboxFsDenyPermissionPolicyService), this.instantiation.createInstance(AutoModeApprovePermissionPolicyService), this.instantiation.createInstance(SessionApprovalHistoryPermissionPolicyService), this.instantiation.createInstance(UserConfiguredAskPermissionPolicyService), this.instantiation.createInstance(UserConfiguredAllowPermissionPolicyService), this.instantiation.createInstance(SensitiveFileAccessAskPermissionPolicyService), + this.instantiation.createInstance(SandboxOutsideWorkspaceAskPermissionPolicyService), this.instantiation.createInstance(GitControlPathAccessAskPermissionPolicyService), + this.instantiation.createInstance(SandboxedBashApprovePermissionPolicyService), this.instantiation.createInstance(YoloModeApprovePermissionPolicyService), this.instantiation.createInstance(DefaultToolApprovePermissionPolicyService), this.instantiation.createInstance(GitCwdWriteApprovePermissionPolicyService), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts new file mode 100644 index 0000000000..7198f4d544 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts @@ -0,0 +1,107 @@ +import { tmpdir } from 'node:os'; + +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IConfigService, type IConfigService as ConfigService } from '#/app/config/config'; +import { IHostEnvironment, type IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { resolveSandboxConfig } from '#/session/sandbox/configSection'; +import { isWithinAnyRoot, matchesPathRule } from '#/session/sandbox/pathRules'; +import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import type { ResolvedSandboxPolicy, SandboxConfig } from '#/session/sandbox/sandboxTypes'; +import { + ISessionWorkspaceContext, + type ISessionWorkspaceContext as WorkspaceContext, +} from '#/session/workspaceContext/workspaceContext'; +import { isSensitiveFile } from '#/tool/path-access'; +import type { ToolFileAccess } from '#/tool/toolContract'; + +import { fileAccesses } from './path-utils'; + +export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { + readonly name = 'sandbox-fs-deny'; + + constructor( + @IConfigService private readonly config: ConfigService, + @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IHostEnvironment private readonly env: HostEnvironment, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + const config = resolveSandboxConfig(this.config); + if (config?.enabled !== true) return undefined; + const accesses = fileAccesses(context); + if (accesses.length === 0) return undefined; + const policy = this.resolvedPolicy(config); + for (const access of accesses) { + const denied = this.denyAccess(access, config, policy); + if (denied !== undefined) return denied; + } + return undefined; + } + + private denyAccess( + access: ToolFileAccess, + config: SandboxConfig, + policy: ResolvedSandboxPolicy, + ): PermissionPolicyResult | undefined { + if (isSensitiveFile(access.path)) { + return { + kind: 'deny', + message: + `Access to "${access.path}" is denied: it matches a sensitive-file pattern ` + + `(env / credential / SSH key) and the sandbox upgrades sensitive files to a hard deny.`, + }; + } + const homeDir = this.env.homeDir; + const reads = + access.operation === 'read' || + access.operation === 'search' || + access.operation === 'readwrite'; + if (reads) { + const rule = (config.filesystem?.denyRead ?? []).find((entry) => + matchesPathRule(access.path, entry, homeDir), + ); + if (rule !== undefined) { + return { + kind: 'deny', + message: `Read access to "${access.path}" is denied by sandbox rule filesystem.deny_read ("${rule}").`, + reason: { matched_rule: rule }, + }; + } + } + const writes = access.operation === 'write' || access.operation === 'readwrite'; + if (writes) { + const rule = (config.filesystem?.denyWrite ?? []).find((entry) => + matchesPathRule(access.path, entry, homeDir), + ); + if (rule !== undefined) { + return { + kind: 'deny', + message: `Write access to "${access.path}" is denied by sandbox rule filesystem.deny_write ("${rule}").`, + reason: { matched_rule: rule }, + }; + } + if (policy.mode === 'read-only' && !isWithinAnyRoot(access.path, policy.writableRoots)) { + return { + kind: 'deny', + message: + `Write access to "${access.path}" is denied: sandbox mode is read-only ` + + `and the path is outside the writable roots.`, + reason: { sandbox_mode: policy.mode }, + }; + } + } + return undefined; + } + + private resolvedPolicy(config: SandboxConfig): ResolvedSandboxPolicy { + return resolveSandboxPolicy( + config, + { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }, + { tmpdir: tmpdir(), homeDir: this.env.homeDir }, + ); + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts new file mode 100644 index 0000000000..6f3965e07f --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts @@ -0,0 +1,42 @@ +import { tmpdir } from 'node:os'; + +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IConfigService, type IConfigService as ConfigService } from '#/app/config/config'; +import { IHostEnvironment, type IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { resolveSandboxConfig } from '#/session/sandbox/configSection'; +import { isWithinAnyRoot } from '#/session/sandbox/pathRules'; +import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import { + ISessionWorkspaceContext, + type ISessionWorkspaceContext as WorkspaceContext, +} from '#/session/workspaceContext/workspaceContext'; + +import { fileAccesses } from './path-utils'; + +export class SandboxOutsideWorkspaceAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'sandbox-outside-workspace-ask'; + + constructor( + @IConfigService private readonly config: ConfigService, + @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IHostEnvironment private readonly env: HostEnvironment, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + const config = resolveSandboxConfig(this.config); + if (config?.enabled !== true) return undefined; + const policy = resolveSandboxPolicy( + config, + { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }, + { tmpdir: tmpdir(), homeDir: this.env.homeDir }, + ); + const access = fileAccesses(context).find( + (fileAccess) => !isWithinAnyRoot(fileAccess.path, policy.writableRoots), + ); + return access === undefined ? undefined : { kind: 'ask' }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandboxed-bash-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandboxed-bash-approve.ts new file mode 100644 index 0000000000..b767697f40 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandboxed-bash-approve.ts @@ -0,0 +1,20 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IConfigService, type IConfigService as ConfigService } from '#/app/config/config'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { resolveSandboxConfig } from '#/session/sandbox/configSection'; + +export class SandboxedBashApprovePermissionPolicyService implements PermissionPolicy { + readonly name = 'sandboxed-bash-approve'; + + constructor(@IConfigService private readonly config: ConfigService) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + const sandbox = resolveSandboxConfig(this.config); + if (sandbox?.enabled !== true) return undefined; + if (sandbox.autoAllowSandboxedBash === false) return undefined; + return context.execution.sandbox?.kind === 'sandboxed' ? { kind: 'approve' } : undefined; + } +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e46356dc12..eb7206967f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -304,6 +304,22 @@ export * from '#/app/gateway/gatewayService'; export * from '#/session/workspaceContext/workspaceContext'; export * from '#/session/workspaceContext/workspaceContextService'; +import '#/session/sandbox/configSection'; +export { + SANDBOX_SECTION, + SandboxConfigSchema, + resolveSandboxConfig, + sandboxFromToml, + sandboxToToml, +} from '#/session/sandbox/configSection'; +export * from '#/session/sandbox/sandboxTypes'; +export * from '#/session/sandbox/sandboxPolicy'; +export * from '#/session/sandbox/pathRules'; +export * from '#/session/sandbox/backends/sandboxBackend'; +export * from '#/session/sandbox/backends/bwrapBackend'; +export * from '#/session/sandbox/backends/seatbeltBackend'; +export * from '#/session/sandbox/sandbox'; +export * from '#/session/sandbox/sandboxService'; export * from '#/session/workspaceCommand/workspaceCommand'; export * from '#/session/workspaceCommand/workspaceCommandService'; export * from '#/app/projectLocalConfig/projectLocalConfig'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index d28c7bebd6..243994a4ae 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -11,6 +11,8 @@ * - `ctx` — `ISessionContext`, session cwd used to render the shell prompt * - `tasks` — `IAgentTaskService`, owns foreground/detached task * lifecycle (timeouts, detach, user interrupt) + * - `sandbox` — `ISandboxService`, decides per command whether the shell + * argv is wrapped in the OS sandbox (bwrap/seatbelt) * * Execution goes through `ISessionProcessRunner`, never directly via * `node:child_process`. @@ -38,6 +40,8 @@ import { IAgentTaskService } from '#/agent/task/task'; import { resolveAgentTaskConfig } from '#/agent/task/configSection'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { ISandboxService } from '#/session/sandbox/sandbox'; +import type { SandboxDecision } from '#/session/sandbox/sandboxTypes'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; @@ -60,6 +64,10 @@ const MAX_TIMEOUT_S = 5 * 60; const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60; const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60; +const SANDBOX_DENIAL_RE = /Operation not permitted|bwrap: |sandbox-exec: /; +const SANDBOX_DENIAL_HINT = + '[sandbox] 命令可能被沙箱拦截。如属误伤,可将命令加入配置 sandbox.excluded_commands。'; + export const BashInputSchema = z .object({ command: z.string().min(1, 'Command cannot be empty.').describe('The command to execute.'), @@ -187,6 +195,7 @@ export class BashTool implements BuiltinTool { @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IConfigService private readonly config: IConfigService, + @ISandboxService private readonly sandbox: ISandboxService, ) { this.isWindowsBash = this.env.osKind === 'Windows'; this.renderedDescription = renderBashDescription(this.env.shellName); @@ -218,7 +227,12 @@ export class BashTool implements BuiltinTool { return this.renderedDescription; } - resolveExecution(args: BashInput): ToolExecution { + async resolveExecution(args: BashInput): Promise { + const effectiveCwd = args.cwd ?? this.ctx.cwd; + const decision = await this.sandbox.decide(args.command, effectiveCwd); + if (decision.kind === 'blocked') { + return { isError: true, output: decision.reason }; + } const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; return { description: args.run_in_background @@ -227,24 +241,28 @@ export class BashTool implements BuiltinTool { display: { kind: 'command', command: args.command, - cwd: args.cwd ?? this.ctx.cwd, + cwd: effectiveCwd, description: args.description, language: 'bash', }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), + sandbox: decision, execute: ({ signal, onUpdate, onForegroundTaskStart }) => - this.execution(args, signal, onUpdate, onForegroundTaskStart), + this.execution(args, decision, signal, onUpdate, onForegroundTaskStart), }; } - private spawn(effectiveCwd: string, command: string): Promise { + private spawn( + effectiveCwd: string, + command: string, + decision: SandboxDecision, + ): Promise { const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; - const shellArgs = [ - this.env.shellPath, - '-c', - `cd ${shellQuote(shellCwd)} && ${command}`, - ]; + const shellArgs: readonly string[] = + decision.kind === 'sandboxed' + ? decision.argv + : [this.env.shellPath, '-c', `cd ${shellQuote(shellCwd)} && ${command}`]; const noninteractiveEnv: Record = { NO_COLOR: '1', @@ -258,6 +276,7 @@ export class BashTool implements BuiltinTool { private async execution( args: BashInput, + decision: SandboxDecision, signal: AbortSignal, onUpdate?: (update: ToolUpdate) => void, onForegroundTaskStart?: (taskId: string) => void, @@ -279,7 +298,7 @@ export class BashTool implements BuiltinTool { const builder = new ToolResultBuilder(); let proc: IProcess; try { - proc = await this.spawn(effectiveCwd, command); + proc = await this.spawn(effectiveCwd, command, decision); } catch (error) { return { isError: true, @@ -358,12 +377,24 @@ export class BashTool implements BuiltinTool { ); } - return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); + return this.annotateSandboxDenial( + await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs), + decision, + ); } finally { collectForegroundOutput = false; } } + private annotateSandboxDenial( + result: ExecutableToolResult, + decision: SandboxDecision, + ): ExecutableToolResult { + if (decision.kind !== 'sandboxed') return result; + if (typeof result.output !== 'string' || !SANDBOX_DENIAL_RE.test(result.output)) return result; + return { ...result, output: `${result.output}\n${SANDBOX_DENIAL_HINT}` }; + } + private validateRunRequest( args: BashInput, signal: AbortSignal, diff --git a/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts b/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts new file mode 100644 index 0000000000..212f5fe8de --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts @@ -0,0 +1,83 @@ +/** + * `sandbox` domain (L3) — bubblewrap (Linux) sandbox backend. + * + * Probes availability by running `bwrap --ro-bind / / -- true` (exit 0 also + * proves user namespaces work, e.g. the Ubuntu 24.04 apparmor restriction + * surfaces as a non-zero probe), and wraps an argv as + * `bwrap --die-with-parent --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp …` + * with one `--bind` per writable root, `--tmpfs` (dir) / `--ro-bind /dev/null` + * (file) masks for `denyRead`, `--ro-bind` overrides for `denyWrite`, and + * `--unshare-net` when the network is disabled. Bind order matters: all masks + * come after the writable binds so they override them. `denyRead` masks apply + * anywhere (the root bind is readable), while `denyWrite` outside the writable + * roots is already read-only and skipped. Path classification is injectable + * (`PathKindProbe`) for tests; the default uses `node:fs`. + */ + +import { statSync } from 'node:fs'; + +import type { ResolvedSandboxPolicy } from '../sandboxTypes'; + +import type { ISandboxBackend, PathKind, PathKindProbe, SpawnProbe } from './sandboxBackend'; + +export const BWRAP_DETECT_ARGV: readonly string[] = ['bwrap', '--ro-bind', '/', '/', '--', 'true']; + +export class BwrapSandboxBackend implements ISandboxBackend { + readonly id = 'bwrap' as const; + + constructor(private readonly pathKind: PathKindProbe = defaultPathKind) {} + + async detect(spawn: SpawnProbe): Promise { + try { + return (await spawn([...BWRAP_DETECT_ARGV])) === 0; + } catch { + return false; + } + } + + wrap(argv: readonly string[], policy: ResolvedSandboxPolicy): readonly string[] { + const args: string[] = [ + 'bwrap', + '--die-with-parent', + '--ro-bind', '/', '/', + '--dev', '/dev', + '--proc', '/proc', + '--tmpfs', '/tmp', + ]; + + const writableRoots = policy.writableRoots.filter((root) => this.pathKind(root) !== 'missing'); + for (const root of writableRoots) { + args.push('--bind', root, root); + } + + for (const p of policy.denyRead) { + const kind = this.pathKind(p); + if (kind === 'dir') args.push('--tmpfs', p); + else if (kind === 'file') args.push('--ro-bind', '/dev/null', p); + } + + for (const p of policy.denyWrite) { + if (!writableRoots.some((root) => isWithinPath(p, root))) continue; + if (this.pathKind(p) === 'missing') continue; + args.push('--ro-bind', p, p); + } + + if (!policy.networkEnabled) args.push('--unshare-net'); + + args.push('--', ...argv); + return args; + } +} + +function isWithinPath(p: string, root: string): boolean { + if (root === '/') return true; + return p === root || p.startsWith(`${root}/`); +} + +function defaultPathKind(p: string): PathKind { + try { + return statSync(p).isDirectory() ? 'dir' : 'file'; + } catch { + return 'missing'; + } +} diff --git a/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts b/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts new file mode 100644 index 0000000000..6028dd33f6 --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts @@ -0,0 +1,25 @@ +/** + * `sandbox` domain (L3) — OS sandbox backend contract. + * + * A sandbox backend knows how to probe its own availability (`detect`, given a + * spawn primitive) and how to wrap a fully-formed argv with its sandbox + * (`wrap`), applying a `ResolvedSandboxPolicy`. Implementations: bubblewrap + * (Linux) and seatbelt / `sandbox-exec` (macOS). Windows has no backend — the + * service reports `unsupported-platform`. `SpawnProbe` runs an argv and + * resolves with the exit code; `PathKind` classifies a path so `wrap` can pick + * the right bind strategy (injectable for tests). + */ + +import type { ResolvedSandboxPolicy, SandboxBackendId } from '../sandboxTypes'; + +export type SpawnProbe = (argv: string[]) => Promise; + +export type PathKind = 'file' | 'dir' | 'missing'; + +export type PathKindProbe = (path: string) => PathKind; + +export interface ISandboxBackend { + readonly id: SandboxBackendId; + detect(spawn: SpawnProbe): Promise; + wrap(argv: readonly string[], policy: ResolvedSandboxPolicy): readonly string[]; +} diff --git a/packages/agent-core-v2/src/session/sandbox/backends/seatbeltBackend.ts b/packages/agent-core-v2/src/session/sandbox/backends/seatbeltBackend.ts new file mode 100644 index 0000000000..74e0f7b90f --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/backends/seatbeltBackend.ts @@ -0,0 +1,69 @@ +/** + * `sandbox` domain (L3) — seatbelt / `sandbox-exec` (macOS) sandbox backend. + * + * Probes availability by running `sandbox-exec -p '(version 1)(allow default)' + * /usr/bin/true`, and wraps an argv as `sandbox-exec -p `. The + * generated SBPL profile is a conservative minimum modeled on codex / srt: + * `(deny default)` plus process/IPC basics, `(allow file-read*)`, `deny + * file-read*` masks for `denyRead` (in seatbelt an explicit deny always beats + * allow), `allow file-write*` only under the writable roots, `deny file-write*` + * overrides, and `(allow network*)` only when the network is enabled. Paths are + * embedded as SBPL string literals with `\` and `"` escaped. Developed without + * a macOS host — profile generation is fully covered by unit tests. + */ + +import type { ResolvedSandboxPolicy } from '../sandboxTypes'; + +import type { ISandboxBackend, SpawnProbe } from './sandboxBackend'; + +export const SEATBELT_DETECT_ARGV: readonly string[] = [ + 'sandbox-exec', + '-p', + '(version 1)(allow default)', + '/usr/bin/true', +]; + +export class SeatbeltSandboxBackend implements ISandboxBackend { + readonly id = 'seatbelt' as const; + + async detect(spawn: SpawnProbe): Promise { + try { + return (await spawn([...SEATBELT_DETECT_ARGV])) === 0; + } catch { + return false; + } + } + + wrap(argv: readonly string[], policy: ResolvedSandboxPolicy): readonly string[] { + return ['sandbox-exec', '-p', buildSeatbeltProfile(policy), ...argv]; + } +} + +export function buildSeatbeltProfile(policy: ResolvedSandboxPolicy): string { + const rules: string[] = [ + '(version 1)', + '(deny default)', + '(allow process-exec)', + '(allow process-fork)', + '(allow signal (target self))', + '(allow sysctl-read)', + '(allow mach-lookup)', + '(allow ipc-posix-shm)', + '(allow file-read*)', + ]; + for (const p of policy.denyRead) { + rules.push(`(deny file-read* (subpath "${escapeSbpl(p)}"))`); + } + for (const root of policy.writableRoots) { + rules.push(`(allow file-write* (subpath "${escapeSbpl(root)}"))`); + } + for (const p of policy.denyWrite) { + rules.push(`(deny file-write* (subpath "${escapeSbpl(p)}"))`); + } + if (policy.networkEnabled) rules.push('(allow network*)'); + return rules.join('\n'); +} + +function escapeSbpl(p: string): string { + return p.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} diff --git a/packages/agent-core-v2/src/session/sandbox/configSection.ts b/packages/agent-core-v2/src/session/sandbox/configSection.ts new file mode 100644 index 0000000000..b27be3373a --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/configSection.ts @@ -0,0 +1,94 @@ +/** + * `sandbox` domain (L3) — `sandbox` config-section schema and TOML transforms. + * + * Owns the `[sandbox]` configuration section (OS-level command sandbox for Bash: + * enable/mode/fail-closed/excluded commands plus the nested `[sandbox.filesystem]` + * and `[sandbox.network]` tables), including the snake_case ↔ camelCase TOML + * transforms for the nested tables. Self-registered at module load via + * `registerConfigSection`, so the `config` domain never imports this domain's + * types. `resolveSandboxConfig` reads the section through `IConfigService` and + * warns once when `network.allowed_domains` is set — the domain allowlist lands + * in Phase 3; until then only `network.enabled` takes effect. + */ + +import { z } from 'zod'; + +import { type IConfigService } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + cloneRecord, + isPlainObject, + plainObjectToToml, + transformPlainObject, +} from '#/app/config/toml'; + +import type { SandboxConfig } from './sandboxTypes'; + +export const SANDBOX_SECTION = 'sandbox'; + +export const SandboxModeSchema = z.enum(['workspace-write', 'read-only']); + +export const SandboxFilesystemConfigSchema = z.object({ + denyRead: z.array(z.string()).optional(), + allowWrite: z.array(z.string()).optional(), + denyWrite: z.array(z.string()).optional(), +}); + +export const SandboxNetworkConfigSchema = z.object({ + enabled: z.boolean().optional(), + allowedDomains: z.array(z.string()).optional(), + allowUnixSockets: z.array(z.string()).optional(), +}); + +export const SandboxConfigSchema = z.object({ + enabled: z.boolean().optional(), + mode: SandboxModeSchema.optional(), + require: z.boolean().optional(), + autoAllowSandboxedBash: z.boolean().optional(), + excludedCommands: z.array(z.string()).optional(), + filesystem: SandboxFilesystemConfigSchema.optional(), + network: SandboxNetworkConfigSchema.optional(), +}); + +export const sandboxFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const raw = transformPlainObject(rawSnake); + if (isPlainObject(raw['filesystem'])) { + raw['filesystem'] = transformPlainObject(raw['filesystem']); + } + if (isPlainObject(raw['network'])) { + raw['network'] = transformPlainObject(raw['network']); + } + return raw; +}; + +export const sandboxToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const nested = cloneRecord(value); + if (isPlainObject(nested['filesystem'])) { + nested['filesystem'] = plainObjectToToml(nested['filesystem'], undefined); + } + if (isPlainObject(nested['network'])) { + nested['network'] = plainObjectToToml(nested['network'], undefined); + } + return plainObjectToToml(nested, rawSnake); +}; + +let warnedAllowedDomains = false; + +export function resolveSandboxConfig(config: IConfigService): SandboxConfig | undefined { + const section = config.get(SANDBOX_SECTION); + const allowedDomains = section?.network?.allowedDomains; + if (!warnedAllowedDomains && allowedDomains !== undefined && allowedDomains.length > 0) { + warnedAllowedDomains = true; + console.warn( + '[sandbox] network.allowed_domains 尚未生效(Phase 3 实现域名白名单代理);当前网络策略只认 network.enabled。', + ); + } + return section; +} + +registerConfigSection(SANDBOX_SECTION, SandboxConfigSchema, { + fromToml: sandboxFromToml, + toToml: sandboxToToml, +}); diff --git a/packages/agent-core-v2/src/session/sandbox/pathRules.ts b/packages/agent-core-v2/src/session/sandbox/pathRules.ts new file mode 100644 index 0000000000..a52018438f --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/pathRules.ts @@ -0,0 +1,40 @@ +/** + * `sandbox` domain (L3) — sandbox filesystem path-rule matching. + * + * Pure matchers shared by the sandbox permission policies (and reusable by + * future sandbox consumers): `matchesPathRule` compares an absolute path + * against one `filesystem.deny_read` / `deny_write` entry, and + * `isWithinAnyRoot` tests containment in the resolved writable roots. An + * entry matches its own path or anything beneath it — exact in practice for + * files, a subtree mask for directories, mirroring the bwrap `--tmpfs` / + * `/dev/null` masks; a trailing `/**` marks an explicit subtree root. Entries + * are `~`-expanded against the host home directory and normalized; + * containment goes through `isWithinDirectory`, so shared-prefix escapes + * (`/foo-evil`) never match. Other glob syntax is not supported. + */ + +import { normalize } from 'pathe'; + +import { isWithinDirectory } from '#/tool/path-access'; + +const SUBTREE_SUFFIX = '/**'; + +export function matchesPathRule(path: string, rule: string, homeDir: string): boolean { + const expanded = expandHome(rule, homeDir); + const base = expanded.endsWith(SUBTREE_SUFFIX) + ? expanded.slice(0, -SUBTREE_SUFFIX.length) + : expanded; + const normalizedBase = normalize(base); + if (normalizedBase === '') return false; + return isWithinDirectory(path, normalizedBase); +} + +export function isWithinAnyRoot(path: string, roots: readonly string[]): boolean { + return roots.some((root) => isWithinDirectory(path, root)); +} + +function expandHome(p: string, homeDir: string): string { + if (p === '~') return homeDir; + if (p.startsWith('~/')) return `${homeDir}/${p.slice(2)}`; + return p; +} diff --git a/packages/agent-core-v2/src/session/sandbox/sandbox.ts b/packages/agent-core-v2/src/session/sandbox/sandbox.ts new file mode 100644 index 0000000000..b43a606a3d --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/sandbox.ts @@ -0,0 +1,23 @@ +/** + * `sandbox` domain (L3) — `ISandboxService` contract. + * + * Session-scoped decision point for the OS-level command sandbox: given a Bash + * command line and its working directory, `decide` resolves the `[sandbox]` + * config section and the probed backend into a `SandboxDecision` — wrap and run + * sandboxed, run excluded, run unsandboxed (disabled / no backend / unsupported + * platform), or block (fail-closed `require = true` with no backend). The + * wrapped argv is a complete shell invocation, ready for the process runner. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { SandboxDecision } from './sandboxTypes'; + +export interface ISandboxService { + readonly _serviceBrand: undefined; + + decide(command: string, cwd: string): Promise; +} + +export const ISandboxService: ServiceIdentifier = + createDecorator('sandboxService'); diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts new file mode 100644 index 0000000000..c8a4b7c637 --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts @@ -0,0 +1,60 @@ +/** + * `sandbox` domain (L3) — effective sandbox policy resolution. + * + * Pure function folding the `[sandbox]` config section, the session workspace + * roots, and host path facts into a `ResolvedSandboxPolicy`: the writable-root + * set for the mode (`workspace-write` = workspace + additionalDirs + tmpdir + + * `filesystem.allowWrite`; `read-only` = tmpdir + `filesystem.allowWrite`), + * and the deny lists, all as normalized absolute paths with `~` expanded + * against the host home directory. `denyRead` defaults to empty — sensitive + * files stay guarded by the `isSensitiveFile` permission policy (Phase 2). + */ + +import { normalize } from 'pathe'; + +import type { ResolvedSandboxPolicy, SandboxConfig, SandboxMode } from './sandboxTypes'; + +export interface SandboxWorkspaceRoots { + readonly workDir: string; + readonly additionalDirs: readonly string[]; +} + +export interface SandboxPathEnv { + readonly tmpdir: string; + readonly homeDir: string; +} + +export function resolveSandboxPolicy( + config: SandboxConfig, + workspace: SandboxWorkspaceRoots, + env: SandboxPathEnv, +): ResolvedSandboxPolicy { + const mode: SandboxMode = config.mode ?? 'workspace-write'; + const expand = (p: string): string => stripTrailingSlash(normalize(expandHome(p, env.homeDir))); + const allowWrite = (config.filesystem?.allowWrite ?? []).map(expand); + const writableRoots = + mode === 'read-only' + ? [env.tmpdir, ...allowWrite] + : [workspace.workDir, ...workspace.additionalDirs, env.tmpdir, ...allowWrite]; + return { + mode, + writableRoots: dedupe(writableRoots.map(expand)), + denyRead: dedupe((config.filesystem?.denyRead ?? []).map(expand)), + denyWrite: dedupe((config.filesystem?.denyWrite ?? []).map(expand)), + networkEnabled: config.network?.enabled ?? false, + }; +} + +function expandHome(p: string, homeDir: string): string { + if (p === '~') return homeDir; + if (p.startsWith('~/')) return `${homeDir}/${p.slice(2)}`; + return p; +} + +function stripTrailingSlash(p: string): string { + return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p; +} + +function dedupe(paths: readonly string[]): readonly string[] { + return [...new Set(paths)]; +} diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxService.ts b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts new file mode 100644 index 0000000000..acab39e6f2 --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts @@ -0,0 +1,179 @@ +/** + * `sandbox` domain (L3) — `ISandboxService` implementation. + * + * Resolves the `[sandbox]` config section through `config`, picks the OS + * sandbox backend for the host (`bwrap` on Linux, `seatbelt` on macOS; probed + * lazily on first `decide` through `os/interface` host-process spawns and + * cached afterwards), and folds the workspace roots from `workspaceContext` + * with host path facts (`os/interface` host environment) into a + * `ResolvedSandboxPolicy`. A sandboxed decision wraps the full shell argv + * (` -c 'cd && '`, POSIX only — Windows reports + * `unsupported-platform`) so the caller can exec it directly. Backend + * unavailable with `require = true` fails closed (`blocked`); otherwise it + * warns once through `log` and runs unsandboxed. Commands matching + * `excluded_commands` (per `&&`/`||`/`;`/`|`/newline segment, after stripping + * leading `VAR=value` assignments; entries match the segment text as a prefix + * followed by a space or end) bypass the sandbox and run through the normal + * permission chain. Bound at Session scope. + */ + +import { tmpdir } from 'node:os'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IConfigService } from '#/app/config/config'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +import { BwrapSandboxBackend } from './backends/bwrapBackend'; +import type { ISandboxBackend } from './backends/sandboxBackend'; +import { SeatbeltSandboxBackend } from './backends/seatbeltBackend'; +import { resolveSandboxConfig } from './configSection'; +import { ISandboxService } from './sandbox'; +import { resolveSandboxPolicy, type SandboxWorkspaceRoots } from './sandboxPolicy'; +import type { SandboxBackendId, SandboxDecision } from './sandboxTypes'; + +type BackendProbeResult = + | { readonly status: 'ok'; readonly backend: ISandboxBackend } + | { readonly status: 'backend-unavailable'; readonly backendId: SandboxBackendId } + | { readonly status: 'unsupported-platform' }; + +export class SandboxService implements ISandboxService { + declare readonly _serviceBrand: undefined; + + private backendResult: Promise | undefined; + private warnedUnavailable = false; + + constructor( + @IConfigService private readonly config: IConfigService, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IHostEnvironment private readonly env: IHostEnvironment, + @IHostProcessService private readonly hostProcess: IHostProcessService, + @ILogService private readonly log: ILogService, + private readonly createBackend: (osKind: string) => ISandboxBackend | undefined = pickBackend, + ) {} + + async decide(command: string, cwd: string): Promise { + const config = resolveSandboxConfig(this.config); + if (config?.enabled !== true) return { kind: 'unsandboxed', reason: 'disabled' }; + + const matched = matchExcludedCommand(command, config.excludedCommands ?? []); + if (matched !== undefined) return { kind: 'excluded', matched }; + + const probe = await this.probeBackend(); + if (probe.status !== 'ok') { + if (config.require === true) { + return { kind: 'blocked', reason: this.blockedReason(probe) }; + } + if (!this.warnedUnavailable) { + this.warnedUnavailable = true; + this.log.warn( + '[sandbox] sandbox.enabled = true but no usable sandbox backend; Bash commands run unsandboxed.', + { platform: this.env.osKind, status: probe.status }, + ); + } + return { kind: 'unsandboxed', reason: probe.status }; + } + + const policy = resolveSandboxPolicy(config, this.workspaceRoots(cwd), { + tmpdir: tmpdir(), + homeDir: this.env.homeDir, + }); + const shellArgv = [this.env.shellPath, '-c', `cd ${shellQuote(cwd)} && ${command}`]; + return { + kind: 'sandboxed', + argv: probe.backend.wrap(shellArgv, policy), + backendId: probe.backend.id, + }; + } + + private probeBackend(): Promise { + this.backendResult ??= this.doProbeBackend(); + return this.backendResult; + } + + private async doProbeBackend(): Promise { + const backend = this.createBackend(this.env.osKind); + if (backend === undefined) return { status: 'unsupported-platform' }; + const available = await backend.detect((argv) => this.spawnProbe(argv)); + return available + ? { status: 'ok', backend } + : { status: 'backend-unavailable', backendId: backend.id }; + } + + private async spawnProbe(argv: string[]): Promise { + const proc = await this.hostProcess.spawn(argv[0]!, argv.slice(1)); + try { + return await proc.wait(); + } finally { + proc.dispose(); + } + } + + private blockedReason(probe: Exclude): string { + if (probe.status === 'unsupported-platform') { + return ( + `Sandbox is required (sandbox.require = true) but no sandbox backend exists ` + + `for platform '${this.env.osKind}'.` + ); + } + return ( + `Sandbox is required (sandbox.require = true) but the '${probe.backendId}' backend ` + + `is not available on this host.` + ); + } + + private workspaceRoots(cwd: string): SandboxWorkspaceRoots { + const workDir = this.workspace.workDir; + const additionalDirs = + cwd === workDir ? this.workspace.additionalDirs : [cwd, ...this.workspace.additionalDirs]; + return { workDir, additionalDirs }; + } +} + +function pickBackend(osKind: string): ISandboxBackend | undefined { + if (osKind === 'Linux') return new BwrapSandboxBackend(); + if (osKind === 'macOS') return new SeatbeltSandboxBackend(); + return undefined; +} + +const SEGMENT_SPLIT_RE = /&&|\|\||[;|\n]/; +const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/; + +export function matchExcludedCommand( + command: string, + excludedCommands: readonly string[], +): string | undefined { + if (excludedCommands.length === 0) return undefined; + for (const rawSegment of command.split(SEGMENT_SPLIT_RE)) { + const segment = stripLeadingEnvAssignments(rawSegment.trim()); + if (segment === '') continue; + for (const entry of excludedCommands) { + const trimmed = entry.trim(); + if (trimmed === '') continue; + if (segment === trimmed || segment.startsWith(`${trimmed} `)) return entry; + } + } + return undefined; +} + +function stripLeadingEnvAssignments(segment: string): string { + const tokens = segment.split(/\s+/); + let i = 0; + while (i < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[i]!)) i += 1; + return tokens.slice(i).join(' '); +} + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", "'\\''")}'`; +} + +registerScopedService( + LifecycleScope.Session, + ISandboxService, + SandboxService, + InstantiationType.Eager, + 'sandbox', +); diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts b/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts new file mode 100644 index 0000000000..980d2e7936 --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts @@ -0,0 +1,55 @@ +/** + * `sandbox` domain (L3) — sandbox configuration and decision types. + * + * Pure type vocabulary for the OS-level command sandbox (bubblewrap on Linux, + * seatbelt on macOS): the `[sandbox]` config-section shape (`SandboxConfig`), + * the per-command verdict (`SandboxDecision`) produced by `ISandboxService`, + * and the backend-ready `ResolvedSandboxPolicy` (absolute, `~`-expanded paths) + * consumed by the sandbox backends. No IO, no DI. + */ + +export type SandboxMode = 'workspace-write' | 'read-only'; + +export interface SandboxFilesystemConfig { + readonly denyRead?: readonly string[]; + readonly allowWrite?: readonly string[]; + readonly denyWrite?: readonly string[]; +} + +export interface SandboxNetworkConfig { + readonly enabled?: boolean; + readonly allowedDomains?: readonly string[]; + readonly allowUnixSockets?: readonly string[]; +} + +export interface SandboxConfig { + readonly enabled?: boolean; + readonly mode?: SandboxMode; + readonly require?: boolean; + readonly autoAllowSandboxedBash?: boolean; + readonly excludedCommands?: readonly string[]; + readonly filesystem?: SandboxFilesystemConfig; + readonly network?: SandboxNetworkConfig; +} + +export type SandboxBackendId = 'bwrap' | 'seatbelt'; + +export type SandboxDecision = + | { readonly kind: 'sandboxed'; readonly argv: readonly string[]; readonly backendId: SandboxBackendId } + | { readonly kind: 'excluded'; readonly matched: string } + | { + readonly kind: 'unsandboxed'; + readonly reason: 'disabled' | 'backend-unavailable' | 'unsupported-platform'; + } + // `require = true` but no usable backend: fail-closed, the command must not run. + | { readonly kind: 'blocked'; readonly reason: string }; + +export interface ResolvedSandboxPolicy { + readonly mode: SandboxMode; + // Absolute paths with `~` expanded. `workspace-write`: cwd + additionalDirs + + // tmpdir + filesystem.allowWrite; `read-only`: tmpdir + filesystem.allowWrite. + readonly writableRoots: readonly string[]; + readonly denyRead: readonly string[]; + readonly denyWrite: readonly string[]; + readonly networkEnabled: boolean; +} diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 45c5f66bd5..946cb41d0c 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -18,6 +18,7 @@ import type { ContentPart, ToolCall } from '#/kosong/contract/message'; import type { Tool } from '#/kosong/contract/tool'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { SandboxDecision } from '#/session/sandbox/sandboxTypes'; import type { ToolInputDisplay } from '@moonshot-ai/protocol'; export type ExecutableToolOutput = string | ContentPart[]; @@ -82,6 +83,7 @@ export interface RunnableToolExecution { readonly stopBatchAfterThis?: boolean | undefined; readonly approvalRule: string; readonly matchesRule?: ((ruleArgs: string) => boolean) | undefined; + readonly sandbox?: SandboxDecision | undefined; readonly execute: (ctx: ExecutableToolContext) => Promise; } diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 47e133f418..6531fe138b 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IConfigService } from '#/app/config/config'; import { literalRulePattern, matchesGlobRuleSubject, @@ -27,6 +28,7 @@ import { import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; +import type { SandboxConfig, SandboxDecision } from '#/session/sandbox/sandboxTypes'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { stubPermissionModeService } from '../permissionMode/stubs'; @@ -62,6 +64,9 @@ describe('AgentPermissionPolicyService chain', () => { reg.defineInstance(ISessionWorkspaceContext, workspace); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IConfigService, { + get: (() => undefined) as IConfigService['get'], + }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -199,6 +204,9 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { reg.defineInstance(ISessionWorkspaceContext, workspace); reg.defineInstance(IHostEnvironment, kaosStub()); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IConfigService, { + get: (() => undefined) as IConfigService['get'], + }); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -327,6 +335,121 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { }); }); +describe('AgentPermissionPolicyService sandbox policies', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let mode: PermissionMode; + let sandboxConfig: SandboxConfig | undefined; + + beforeEach(() => { + disposables = new DisposableStore(); + mode = 'manual'; + sandboxConfig = undefined; + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); + reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub()); + reg.defineInstance(ISessionWorkspaceContext, workspaceStub('/workspace')); + reg.defineInstance(IHostEnvironment, kaosStub()); + reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.definePartialInstance(IConfigService, { + get: ((section: string) => + section === 'sandbox' ? sandboxConfig : undefined) as IConfigService['get'], + }); + reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); + }, + strict: true, + }); + }); + + afterEach(() => { + disposables.dispose(); + }); + + async function evaluate( + input: PolicyContextInput, + ): Promise { + const svc = ix.get(IAgentPermissionPolicyService); + return svc.evaluate(policyContext(input)); + } + + it('approves sandboxed Bash in manual mode when auto-allow is on', async () => { + sandboxConfig = { enabled: true }; + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'ls', timeout: 60 }, + sandbox: { kind: 'sandboxed', argv: ['bwrap', '--', 'ls'], backendId: 'bwrap' }, + })).resolves.toMatchObject({ + policyName: 'sandboxed-bash-approve', + result: { kind: 'approve' }, + }); + }); + + it('falls through to ask for unsandboxed Bash when the sandbox is enabled', async () => { + sandboxConfig = { enabled: true }; + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'ls', timeout: 60 }, + sandbox: { kind: 'unsandboxed', reason: 'backend-unavailable' }, + })).resolves.toMatchObject({ + policyName: 'fallback-ask', + result: { kind: 'ask' }, + }); + }); + + it('denies sandbox deny_read matches before auto-mode approval', async () => { + mode = 'auto'; + sandboxConfig = { enabled: true, filesystem: { denyRead: ['/workspace/secret/**'] } }; + await expect(evaluate({ + toolName: 'Read', + args: { path: '/workspace/secret/key.txt' }, + accesses: ToolAccesses.readFile('/workspace/secret/key.txt'), + })).resolves.toMatchObject({ + policyName: 'sandbox-fs-deny', + result: { kind: 'deny' }, + }); + }); + + it('denies sensitive files instead of asking when the sandbox is enabled', async () => { + sandboxConfig = { enabled: true }; + await expect(evaluate({ + toolName: 'Read', + args: { path: '/home/test/.ssh/id_rsa' }, + accesses: ToolAccesses.readFile('/home/test/.ssh/id_rsa'), + })).resolves.toMatchObject({ + policyName: 'sandbox-fs-deny', + result: { kind: 'deny' }, + }); + }); + + it('asks for file writes outside the writable roots when the sandbox is enabled', async () => { + sandboxConfig = { enabled: true }; + await expect(evaluate({ + toolName: 'Write', + args: { path: '/etc/sandbox-probe.conf', content: 'x' }, + accesses: ToolAccesses.writeFile('/etc/sandbox-probe.conf'), + })).resolves.toMatchObject({ + policyName: 'sandbox-outside-workspace-ask', + result: { kind: 'ask' }, + }); + }); + + it('keeps the pre-sandbox outcome for outside-workspace writes when disabled', async () => { + await expect(evaluate({ + toolName: 'Write', + args: { path: '/etc/sandbox-probe.conf', content: 'x' }, + accesses: ToolAccesses.writeFile('/etc/sandbox-probe.conf'), + })).resolves.toMatchObject({ + policyName: 'fallback-ask', + result: { kind: 'ask' }, + }); + }); +}); + interface MutablePermissionRulesStubOptions { readonly rules?: () => readonly PermissionRule[]; readonly sessionApprovalRulePatterns?: () => readonly string[]; @@ -354,6 +477,7 @@ interface PolicyContextInput { readonly toolName: string; readonly args: Record; readonly accesses?: ToolAccessList; + readonly sandbox?: SandboxDecision; } function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookContext { @@ -375,6 +499,7 @@ function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookCont subject === undefined ? undefined : (ruleArgs) => matchesRuleSubject(input.toolName, ruleArgs, subject), + sandbox: input.sandbox, execute: async () => ({ output: '' }), }, }; diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts new file mode 100644 index 0000000000..925f762ece --- /dev/null +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts @@ -0,0 +1,173 @@ +import { tmpdir } from 'node:os'; + +import type { ToolCall } from '#/kosong/contract/message'; +import { describe, expect, it } from 'vitest'; + +import type { IConfigService } from '#/app/config/config'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { SandboxFsDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/sandbox-fs-deny'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; +import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; + +import { stubWorkspaceContext } from '../../../session/workspaceContext/stub-workspace-context'; + +const signal = new AbortController().signal; + +function stubConfig(section: SandboxConfig | undefined): IConfigService { + return { + _serviceBrand: undefined, + get: (domain: string) => (domain === 'sandbox' ? section : undefined), + } as unknown as IConfigService; +} + +function stubEnv(): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: 'posix', + homeDir: '/home/test', + ready: Promise.resolve(), + }; +} + +function policy(section: SandboxConfig | undefined): SandboxFsDenyPermissionPolicyService { + return new SandboxFsDenyPermissionPolicyService( + stubConfig(section), + stubWorkspaceContext('/workspace/app', ['/workspace/extra']), + stubEnv(), + ); +} + +function policyContext(accesses: ToolAccessList): ResolvedToolExecutionHookContext { + const call: ToolCall = { + type: 'function', + id: 'call_tool', + name: 'Read', + arguments: '{}', + }; + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args: {}, + execution: { + approvalRule: 'Read', + accesses, + execute: async () => ({ output: '' }), + }, + }; +} + +describe('SandboxFsDenyPermissionPolicyService', () => { + it('denies reads under a deny_read subtree rule, including /foo-evil escape resistance', () => { + const p = policy({ enabled: true, filesystem: { denyRead: ['~/.ssh/**'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/home/test/.ssh/config'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/home/test/.ssh2/config'))), + ).toBeUndefined(); + }); + + it('denies searches under a deny_read rule', () => { + const p = policy({ enabled: true, filesystem: { denyRead: ['/home/test/.gnupg'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.searchTree('/home/test/.gnupg/keyring'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.searchTree('/home/test/other'))), + ).toBeUndefined(); + }); + + it('treats a deny_read entry without /** as an exact file (no suffix leakage)', () => { + const p = policy({ enabled: true, filesystem: { denyRead: ['/home/test/secrets.txt'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/home/test/secrets.txt'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/home/test/secrets.txt.bak'))), + ).toBeUndefined(); + }); + + it('denies readwrite accesses against deny_read (readwrite reads too)', () => { + const p = policy({ enabled: true, filesystem: { denyRead: ['/home/test/secrets.txt'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.readWriteFile('/home/test/secrets.txt'))), + ).toMatchObject({ kind: 'deny' }); + }); + + it('denies writes under a deny_write rule', () => { + const p = policy({ enabled: true, filesystem: { denyWrite: ['/workspace/app/.git/**'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/.git/config'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/.gitx/config'))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/src/a.ts'))), + ).toBeUndefined(); + }); + + it('denies writes outside the writable roots in read-only mode', () => { + const p = policy({ enabled: true, mode: 'read-only' }); + + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/src/a.ts'))), + ).toMatchObject({ kind: 'deny', reason: { sandbox_mode: 'read-only' } }); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile(`${tmpdir()}/scratch.txt`))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/workspace/app/src/a.ts'))), + ).toBeUndefined(); + }); + + it('allows writes inside the workspace in workspace-write mode', () => { + const p = policy({ enabled: true }); + + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/src/a.ts'))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/extra/b.ts'))), + ).toBeUndefined(); + }); + + it('denies sensitive files for any operation (ask upgraded to deny)', () => { + const p = policy({ enabled: true }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/home/test/.ssh/id_rsa'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/app/.env'))), + ).toMatchObject({ kind: 'deny' }); + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/workspace/app/.env.example'))), + ).toBeUndefined(); + }); + + it('defers when the sandbox is disabled or there are no file accesses', () => { + expect( + policy({ enabled: false }).evaluate( + policyContext(ToolAccesses.readFile('/home/test/.ssh/id_rsa')), + ), + ).toBeUndefined(); + expect( + policy(undefined).evaluate(policyContext(ToolAccesses.readFile('/home/test/.ssh/id_rsa'))), + ).toBeUndefined(); + expect(policy({ enabled: true }).evaluate(policyContext(ToolAccesses.none()))).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.test.ts new file mode 100644 index 0000000000..cc6c83845a --- /dev/null +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.test.ts @@ -0,0 +1,118 @@ +import { tmpdir } from 'node:os'; + +import type { ToolCall } from '#/kosong/contract/message'; +import { describe, expect, it } from 'vitest'; + +import type { IConfigService } from '#/app/config/config'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { SandboxOutsideWorkspaceAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sandbox-outside-workspace-ask'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; +import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; + +import { stubWorkspaceContext } from '../../../session/workspaceContext/stub-workspace-context'; + +const signal = new AbortController().signal; + +function stubConfig(section: SandboxConfig | undefined): IConfigService { + return { + _serviceBrand: undefined, + get: (domain: string) => (domain === 'sandbox' ? section : undefined), + } as unknown as IConfigService; +} + +function stubEnv(): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: 'posix', + homeDir: '/home/test', + ready: Promise.resolve(), + }; +} + +function policy( + section: SandboxConfig | undefined, +): SandboxOutsideWorkspaceAskPermissionPolicyService { + return new SandboxOutsideWorkspaceAskPermissionPolicyService( + stubConfig(section), + stubWorkspaceContext('/workspace/app', ['/workspace/extra']), + stubEnv(), + ); +} + +function policyContext(accesses: ToolAccessList): ResolvedToolExecutionHookContext { + const call: ToolCall = { + type: 'function', + id: 'call_tool', + name: 'Read', + arguments: '{}', + }; + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args: {}, + execution: { + approvalRule: 'Read', + accesses, + execute: async () => ({ output: '' }), + }, + }; +} + +describe('SandboxOutsideWorkspaceAskPermissionPolicyService', () => { + it('asks for reads, writes, and searches outside the writable roots', () => { + const p = policy({ enabled: true }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/etc/hosts'))), + ).toEqual({ kind: 'ask' }); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/etc/sandbox-probe.conf'))), + ).toEqual({ kind: 'ask' }); + expect( + p.evaluate(policyContext(ToolAccesses.searchTree('/var/log'))), + ).toEqual({ kind: 'ask' }); + }); + + it('defers for paths inside the workspace, additional dirs, tmpdir, and allow_write roots', () => { + const p = policy({ enabled: true, filesystem: { allowWrite: ['/data/out'] } }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/workspace/app/src/a.ts'))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/workspace/extra/b.ts'))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile(`${tmpdir()}/scratch.txt`))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext(ToolAccesses.writeFile('/data/out/result.txt'))), + ).toBeUndefined(); + }); + + it('does not leak on shared-prefix paths outside the roots', () => { + const p = policy({ enabled: true }); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile('/workspace/app-evil/secret.txt'))), + ).toEqual({ kind: 'ask' }); + }); + + it('defers when the sandbox is disabled or there are no file accesses', () => { + expect( + policy({ enabled: false }).evaluate(policyContext(ToolAccesses.readFile('/etc/hosts'))), + ).toBeUndefined(); + expect( + policy(undefined).evaluate(policyContext(ToolAccesses.readFile('/etc/hosts'))), + ).toBeUndefined(); + expect(policy({ enabled: true }).evaluate(policyContext(ToolAccesses.none()))).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandboxed-bash-approve.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandboxed-bash-approve.test.ts new file mode 100644 index 0000000000..06b66e478c --- /dev/null +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandboxed-bash-approve.test.ts @@ -0,0 +1,82 @@ +import type { ToolCall } from '#/kosong/contract/message'; +import { describe, expect, it } from 'vitest'; + +import type { IConfigService } from '#/app/config/config'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { SandboxedBashApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/sandboxed-bash-approve'; +import type { SandboxConfig, SandboxDecision } from '#/session/sandbox/sandboxTypes'; + +const signal = new AbortController().signal; + +const SANDBOXED: SandboxDecision = { + kind: 'sandboxed', + argv: ['bwrap', '--', '/bin/bash', '-c', 'ls'], + backendId: 'bwrap', +}; + +function stubConfig(section: SandboxConfig | undefined): IConfigService { + return { + _serviceBrand: undefined, + get: (domain: string) => (domain === 'sandbox' ? section : undefined), + } as unknown as IConfigService; +} + +function policyContext(sandbox?: SandboxDecision): ResolvedToolExecutionHookContext { + const call: ToolCall = { + type: 'function', + id: 'call_bash', + name: 'Bash', + arguments: JSON.stringify({ command: 'ls' }), + }; + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args: { command: 'ls' }, + execution: { + approvalRule: 'Bash', + sandbox, + execute: async () => ({ output: '' }), + }, + }; +} + +describe('SandboxedBashApprovePermissionPolicyService', () => { + function policy(section: SandboxConfig | undefined): SandboxedBashApprovePermissionPolicyService { + return new SandboxedBashApprovePermissionPolicyService(stubConfig(section)); + } + + it('approves a sandboxed execution when enabled (auto-allow defaults to true)', () => { + expect(policy({ enabled: true }).evaluate(policyContext(SANDBOXED))).toEqual({ + kind: 'approve', + }); + expect( + policy({ enabled: true, autoAllowSandboxedBash: true }).evaluate(policyContext(SANDBOXED)), + ).toEqual({ kind: 'approve' }); + }); + + it('defers when autoAllowSandboxedBash is false', () => { + expect( + policy({ enabled: true, autoAllowSandboxedBash: false }).evaluate(policyContext(SANDBOXED)), + ).toBeUndefined(); + }); + + it('defers when the execution is not sandboxed', () => { + expect( + policy({ enabled: true }).evaluate( + policyContext({ kind: 'unsandboxed', reason: 'backend-unavailable' }), + ), + ).toBeUndefined(); + expect( + policy({ enabled: true }).evaluate(policyContext({ kind: 'excluded', matched: 'docker' })), + ).toBeUndefined(); + expect(policy({ enabled: true }).evaluate(policyContext())).toBeUndefined(); + }); + + it('defers when the sandbox is disabled or unconfigured', () => { + expect(policy({ enabled: false }).evaluate(policyContext(SANDBOXED))).toBeUndefined(); + expect(policy({}).evaluate(policyContext(SANDBOXED))).toBeUndefined(); + expect(policy(undefined).evaluate(policyContext(SANDBOXED))).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index a4ca4eea3e..02c0652d20 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -34,6 +34,8 @@ import { ProcessTask } from '#/os/backends/node-local/tools/process-task'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { type ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import type { ISandboxService } from '#/session/sandbox/sandbox'; +import type { SandboxDecision } from '#/session/sandbox/sandboxTypes'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; import { type BashInput, BashInputSchema, BashTool } from '#/os/backends/node-local/tools/bash'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; @@ -712,6 +714,15 @@ function stubConfig(values: Record = {}): IConfigService { } as unknown as IConfigService; } +function stubSandbox( + decision: SandboxDecision = { kind: 'unsandboxed', reason: 'disabled' }, +): ISandboxService { + return { + _serviceBrand: undefined, + decide: vi.fn(async () => decision), + } as unknown as ISandboxService; +} + function bashTool( runner: ISessionProcessRunner, env: IHostEnvironment = createTestEnv(), @@ -719,8 +730,9 @@ function bashTool( background: IAgentTaskService = createFakeTaskService().service, toolPolicy: IAgentToolPolicyService = stubToolPolicy(), config: IConfigService = stubConfig(), + sandbox: ISandboxService = stubSandbox(), ): BashTool { - return new BashTool(runner, env, ctx, background, toolPolicy, config); + return new BashTool(runner, env, ctx, background, toolPolicy, config, sandbox); } @@ -850,6 +862,81 @@ describe('BashTool', () => { expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/var/app' && pwd"]); }); + it('execs the wrapped argv and exposes the decision when the command is sandboxed', async () => { + const proc = processWithOutput({ stdout: 'ok\n' }); + const { runner, exec } = createTestRunner(proc); + const decision: SandboxDecision = { + kind: 'sandboxed', + argv: ['bwrap', '--die-with-parent', '--', '/bin/bash', '-c', "cd '/workspace' && printf ok"], + backendId: 'bwrap', + }; + const sandbox = stubSandbox(decision); + const tool = bashTool(runner, posixEnv, createTestCtx(), undefined, undefined, undefined, sandbox); + + const resolved = await tool.resolveExecution({ command: 'printf ok', timeout: 60 }); + expect(resolved.isError).not.toBe(true); + if (resolved.isError === true) return; + expect(resolved.sandbox).toEqual(decision); + expect(sandbox.decide).toHaveBeenCalledWith('printf ok', '/workspace'); + + const result = await resolved.execute(context({ command: 'printf ok', timeout: 60 })); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec.mock.calls[0]?.[0]).toEqual(decision.argv); + expect(result).toMatchObject({ output: 'ok\n', isError: false }); + }); + + it('returns the blocked reason without spawning when the sandbox blocks the command', async () => { + const { runner, exec } = createTestRunner(processWithOutput()); + const tool = bashTool( + runner, + posixEnv, + createTestCtx(), + undefined, + undefined, + undefined, + stubSandbox({ kind: 'blocked', reason: 'Sandbox is required but unavailable.' }), + ); + + const resolved = await tool.resolveExecution({ command: 'ls', timeout: 60 }); + expect(resolved).toMatchObject({ + isError: true, + output: 'Sandbox is required but unavailable.', + }); + expect(exec).not.toHaveBeenCalled(); + }); + + it('appends the sandbox hint when a sandboxed command output shows denial signatures', async () => { + const { runner } = createTestRunner( + processWithOutput({ + stderr: 'cat: /home/test/.ssh/id_rsa: Operation not permitted\n', + exitCode: 1, + }), + ); + const tool = bashTool( + runner, + posixEnv, + createTestCtx(), + undefined, + undefined, + undefined, + stubSandbox({ kind: 'sandboxed', argv: ['bwrap', '--', 'true'], backendId: 'bwrap' }), + ); + + const result = await executeTool(tool, context({ command: 'cat ~/.ssh/id_rsa', timeout: 60 })); + expect(result.isError).toBe(true); + expect(result.output).toContain('sandbox.excluded_commands'); + }); + + it('does not append the sandbox hint when the command ran unsandboxed', async () => { + const { runner } = createTestRunner( + processWithOutput({ stderr: 'x: Operation not permitted\n', exitCode: 1 }), + ); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'x', timeout: 60 })); + expect(result.output).not.toContain('sandbox.excluded_commands'); + }); + it('uses Git Bash semantics on Windows', async () => { const proc = processWithOutput({ stdout: 'ok\n' }); const { runner, exec } = createTestRunner(proc); diff --git a/packages/agent-core-v2/test/session/sandbox/bwrapBackend.test.ts b/packages/agent-core-v2/test/session/sandbox/bwrapBackend.test.ts new file mode 100644 index 0000000000..9c23b16f1c --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/bwrapBackend.test.ts @@ -0,0 +1,154 @@ +/** + * BwrapSandboxBackend tests for the v2 sandbox domain. + * + * Covers the availability probe (`detect` exit-code mapping, spawn failures) + * and the generated bwrap argv: writable binds, `denyRead` masking per path + * kind (dir → `--tmpfs`, file → `--ro-bind /dev/null`, missing → skipped), + * `denyWrite` read-only overrides limited to the writable roots, network + * unsharing, and bind ordering (all masks after the writable binds). Path + * classification is injected, so no real filesystem is touched. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { BWRAP_DETECT_ARGV, BwrapSandboxBackend } from '#/session/sandbox/backends/bwrapBackend'; +import type { PathKind } from '#/session/sandbox/backends/sandboxBackend'; +import type { ResolvedSandboxPolicy } from '#/session/sandbox/sandboxTypes'; + +function policy(overrides: Partial = {}): ResolvedSandboxPolicy { + return { + mode: 'workspace-write', + writableRoots: ['/workspace/app', '/tmp'], + denyRead: [], + denyWrite: [], + networkEnabled: false, + ...overrides, + }; +} + +function pathKindOf(table: Record): (p: string) => PathKind { + return (p) => table[p] ?? 'missing'; +} + +describe('BwrapSandboxBackend.detect', () => { + it('is available when the probe exits 0', async () => { + const spawn = vi.fn(async () => 0); + await expect(new BwrapSandboxBackend().detect(spawn)).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith([...BWRAP_DETECT_ARGV]); + }); + + it('is unavailable on non-zero exit or spawn failure', async () => { + await expect(new BwrapSandboxBackend().detect(async () => 1)).resolves.toBe(false); + await expect( + new BwrapSandboxBackend().detect(async () => { + throw new Error('ENOENT'); + }), + ).resolves.toBe(false); + }); +}); + +describe('BwrapSandboxBackend.wrap', () => { + it('builds the base argv with writable binds, network unshare, and the command after --', () => { + const backend = new BwrapSandboxBackend(() => 'dir'); + const argv = backend.wrap(['/bin/bash', '-c', 'echo ok'], policy()); + + expect(argv).toEqual([ + 'bwrap', + '--die-with-parent', + '--ro-bind', '/', '/', + '--dev', '/dev', + '--proc', '/proc', + '--tmpfs', '/tmp', + '--bind', '/workspace/app', '/workspace/app', + '--bind', '/tmp', '/tmp', + '--unshare-net', + '--', + '/bin/bash', '-c', 'echo ok', + ]); + }); + + it('keeps the network shared when networkEnabled is true', () => { + const backend = new BwrapSandboxBackend(() => 'dir'); + const argv = backend.wrap(['true'], policy({ networkEnabled: true })); + + expect(argv).not.toContain('--unshare-net'); + }); + + it('skips writable roots that do not exist', () => { + const backend = new BwrapSandboxBackend(pathKindOf({ '/workspace/app': 'dir' })); + const argv = backend.wrap(['true'], policy()); + + const bindPairs: string[][] = []; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--bind') bindPairs.push([argv[i + 1]!, argv[i + 2]!]); + } + expect(bindPairs).toEqual([['/workspace/app', '/workspace/app']]); + }); + + it('masks denyRead dirs with --tmpfs and files with /dev/null, skipping missing paths', () => { + const backend = new BwrapSandboxBackend( + pathKindOf({ + '/workspace/app': 'dir', + '/tmp': 'dir', + '/home/test/.ssh': 'dir', + '/home/test/.aws/credentials': 'file', + }), + ); + const argv = backend.wrap( + ['true'], + policy({ + denyRead: ['/home/test/.ssh', '/home/test/.aws/credentials', '/does/not/exist'], + }), + ); + + expect(argv).toEqual(expect.arrayContaining(['--tmpfs', '/home/test/.ssh'])); + expect(argv).toEqual( + expect.arrayContaining(['--ro-bind', '/dev/null', '/home/test/.aws/credentials']), + ); + expect(argv).not.toContain('/does/not/exist'); + }); + + it('applies denyWrite as a read-only re-bind after the writable binds, only within writable roots', () => { + const backend = new BwrapSandboxBackend( + pathKindOf({ + '/workspace/app': 'dir', + '/tmp': 'dir', + '/workspace/app/.git': 'dir', + }), + ); + const argv = backend.wrap( + ['true'], + policy({ + denyWrite: [ + '/workspace/app/.git', // inside a writable root → overridden read-only + '/etc', // outside writable roots → already read-only, skipped + '/workspace/app/missing', // missing → skipped + ], + }), + ); + + expect(argv).toEqual(expect.arrayContaining(['--ro-bind', '/workspace/app/.git', '/workspace/app/.git'])); + expect(argv).not.toContain('/etc'); + expect(argv).not.toContain('/workspace/app/missing'); + expect(argv.indexOf('/workspace/app/.git')).toBeGreaterThan(argv.lastIndexOf('--bind')); + }); + + it('orders every mask after all writable binds', () => { + const backend = new BwrapSandboxBackend( + pathKindOf({ + '/workspace/app': 'dir', + '/tmp': 'dir', + '/workspace/app/.ssh': 'dir', + '/workspace/app/.git': 'dir', + }), + ); + const argv = backend.wrap( + ['true'], + policy({ denyRead: ['/workspace/app/.ssh'], denyWrite: ['/workspace/app/.git'] }), + ); + + const lastBind = argv.lastIndexOf('--bind'); + expect(argv.lastIndexOf('--tmpfs')).toBeGreaterThan(lastBind); + expect(argv.indexOf('/workspace/app/.git')).toBeGreaterThan(lastBind); + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/bwrapIntegration.test.ts b/packages/agent-core-v2/test/session/sandbox/bwrapIntegration.test.ts new file mode 100644 index 0000000000..5a29327061 --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/bwrapIntegration.test.ts @@ -0,0 +1,164 @@ +/** + * BwrapSandboxBackend integration tests for the v2 sandbox domain. + * + * Runs the real `bwrap` binary against wrapped argv produced by + * `BwrapSandboxBackend.wrap` in temporary directories under the host tmpdir, + * verifying the end-to-end filesystem and network semantics: workspace writes + * succeed, `$HOME` writes fail, `denyRead` masks files with `/dev/null`, + * `denyWrite` re-binds subtrees read-only, and `--unshare-net` kills the + * network. Skipped entirely when `bwrap` (or `curl` for the network case) is + * not on PATH. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BwrapSandboxBackend } from '#/session/sandbox/backends/bwrapBackend'; +import type { ResolvedSandboxPolicy } from '#/session/sandbox/sandboxTypes'; + +function onPath(binary: string): boolean { + return spawnSync('which', [binary], { stdio: 'ignore' }).status === 0; +} + +const hasBwrap = onPath('bwrap'); +const hasCurl = onPath('curl'); + +interface RunResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function run(argv: readonly string[]): RunResult { + const result = spawnSync(argv[0]!, [...argv.slice(1)], { + encoding: 'utf8', + timeout: 30_000, + }); + return { + status: result.status ?? -1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +function policy(overrides: Partial = {}): ResolvedSandboxPolicy { + return { + mode: 'workspace-write', + // Mirrors `resolveSandboxPolicy`: tmpdir is always a writable root, so the + // host /tmp is bind-mounted back over the scratch `--tmpfs /tmp`. + writableRoots: [tmpdir()], + denyRead: [], + denyWrite: [], + networkEnabled: false, + ...overrides, + }; +} + +describe.skipIf(!hasBwrap)('BwrapSandboxBackend integration', () => { + let tempDirs: string[] = []; + let homeProbes: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + for (const probe of homeProbes.splice(0)) rmSync(probe, { force: true }); + }); + + function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'kimi-sandbox-it-')); + tempDirs.push(dir); + return dir; + } + + function makeHomeProbe(): string { + const probe = join( + process.env['HOME'] ?? tmpdir(), + `.kimi-sandbox-it-${String(process.pid)}-${String(homeProbes.length)}`, + ); + homeProbes.push(probe); + return probe; + } + + function sh(script: string, p: ResolvedSandboxPolicy): RunResult { + const argv = new BwrapSandboxBackend().wrap(['/bin/sh', '-c', script], p); + return run(argv); + } + + it('detect() reports the backend as available', async () => { + const probe = async (argv: string[]): Promise => run(argv).status; + await expect(new BwrapSandboxBackend().detect(probe)).resolves.toBe(true); + }); + + it('allows writes inside the writable root but denies writes to $HOME', () => { + const ws = makeTempDir(); + const homeProbe = makeHomeProbe(); + const result = sh(`touch '${ws}/ok.txt' && echo ws-ok; touch '${homeProbe}'`, policy({ + writableRoots: [ws, tmpdir()], + })); + + expect(result.stdout).toContain('ws-ok'); + expect(existsSync(join(ws, 'ok.txt'))).toBe(true); + expect(existsSync(homeProbe)).toBe(false); + }); + + it('masks a denyRead file with /dev/null (contents unreadable)', () => { + const ws = makeTempDir(); + const secretDir = makeTempDir(); + const secretFile = join(secretDir, 'fake_id_rsa'); + writeFileSync(secretFile, 'SECRET-KEY-MATERIAL'); + + const control = sh(`cat '${secretFile}'`, policy({ writableRoots: [ws, tmpdir()] })); + expect(control.stdout).toBe('SECRET-KEY-MATERIAL'); + + // Depending on the bind flags the mask surfaces as an empty read or as + // "Permission denied" — either way the contents must not leak. + const masked = sh(`cat '${secretFile}' 2>&1; echo rc=$?`, policy({ + writableRoots: [ws, tmpdir()], + denyRead: [secretFile], + })); + expect(masked.stdout).not.toContain('SECRET-KEY-MATERIAL'); + expect(masked.stdout).toMatch(/Permission denied|rc=0/); + }); + + it('masks a denyRead directory with a tmpfs (contents disappear)', () => { + const ws = makeTempDir(); + const secretDir = makeTempDir(); + writeFileSync(join(secretDir, 'key'), 'SECRET'); + + const masked = sh(`ls '${secretDir}'`, policy({ + writableRoots: [ws, tmpdir()], + denyRead: [secretDir], + })); + expect(masked.status).toBe(0); + expect(masked.stdout.trim()).toBe(''); + }); + + it('re-binds a denyWrite subtree read-only inside a writable root', () => { + const ws = makeTempDir(); + const locked = join(ws, 'locked'); + mkdirSync(locked); + + const result = sh( + `touch '${ws}/ok.txt' && echo ws-ok; touch '${locked}/nope.txt' && echo locked-write-ok`, + policy({ writableRoots: [ws, tmpdir()], denyWrite: [locked] }), + ); + + expect(result.stdout).toContain('ws-ok'); + expect(result.stdout).not.toContain('locked-write-ok'); + expect(existsSync(join(ws, 'ok.txt'))).toBe(true); + expect(existsSync(join(locked, 'nope.txt'))).toBe(false); + }); + + it.skipIf(!hasCurl)('blocks the network when networkEnabled is false', () => { + const ws = makeTempDir(); + const result = sh( + 'curl -sS --max-time 3 https://example.com -o /dev/null', + policy({ writableRoots: [ws, tmpdir()], networkEnabled: false }), + ); + + expect(result.status).not.toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/configSection.test.ts b/packages/agent-core-v2/test/session/sandbox/configSection.test.ts new file mode 100644 index 0000000000..53f4bdaa4d --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/configSection.test.ts @@ -0,0 +1,108 @@ +/** + * sandbox configSection tests for the v2 sandbox domain. + * + * Covers the `[sandbox]` TOML transforms (snake_case ↔ camelCase, including + * the nested `[sandbox.filesystem]` / `[sandbox.network]` tables), the zod + * schema, and `resolveSandboxConfig` (section read + the one-time + * `allowed_domains` Phase 3 warning). + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { IConfigService } from '#/app/config/config'; +import { + SANDBOX_SECTION, + SandboxConfigSchema, + resolveSandboxConfig, + sandboxFromToml, + sandboxToToml, +} from '#/session/sandbox/configSection'; +import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; + +const TOML_SECTION = { + enabled: true, + mode: 'read-only', + require: true, + auto_allow_sandboxed_bash: true, + excluded_commands: ['docker', 'git push'], + filesystem: { + deny_read: ['~/.ssh/**'], + allow_write: ['/data/out'], + deny_write: ['**/.git/**'], + }, + network: { + enabled: false, + allowed_domains: ['example.com'], + allow_unix_sockets: ['/var/run/ssh-agent.sock'], + }, +}; + +const CAMEL_SECTION = { + enabled: true, + mode: 'read-only', + require: true, + autoAllowSandboxedBash: true, + excludedCommands: ['docker', 'git push'], + filesystem: { + denyRead: ['~/.ssh/**'], + allowWrite: ['/data/out'], + denyWrite: ['**/.git/**'], + }, + network: { + enabled: false, + allowedDomains: ['example.com'], + allowUnixSockets: ['/var/run/ssh-agent.sock'], + }, +}; + +describe('sandbox config section', () => { + it('fromToml converts snake_case to camelCase, including nested tables', () => { + expect(sandboxFromToml(TOML_SECTION)).toEqual(CAMEL_SECTION); + }); + + it('fromToml passes non-objects through', () => { + expect(sandboxFromToml('nope')).toBe('nope'); + }); + + it('toToml converts back to snake_case and round-trips', () => { + expect(sandboxToToml(CAMEL_SECTION, undefined)).toEqual(TOML_SECTION); + expect(sandboxFromToml(sandboxToToml(CAMEL_SECTION, undefined))).toEqual(CAMEL_SECTION); + }); + + it('schema accepts an empty section and rejects bad values', () => { + expect(SandboxConfigSchema.safeParse({}).success).toBe(true); + expect(SandboxConfigSchema.safeParse(CAMEL_SECTION).success).toBe(true); + expect(SandboxConfigSchema.safeParse({ mode: 'yolo' }).success).toBe(false); + expect(SandboxConfigSchema.safeParse({ enabled: 'yes' }).success).toBe(false); + expect(SandboxConfigSchema.safeParse({ excludedCommands: 'docker' }).success).toBe(false); + }); +}); + +describe('resolveSandboxConfig', () => { + function stubConfig(section: unknown): IConfigService { + return { + _serviceBrand: undefined, + get: (domain: string) => (domain === SANDBOX_SECTION ? section : undefined), + } as unknown as IConfigService; + } + + it('returns the sandbox section and undefined when absent', () => { + const section: SandboxConfig = { enabled: true }; + expect(resolveSandboxConfig(stubConfig(section))).toEqual(section); + expect(resolveSandboxConfig(stubConfig(undefined))).toBeUndefined(); + }); + + it('warns once when network.allowedDomains is set (Phase 3 not implemented)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const withDomains = stubConfig({ enabled: true, network: { allowedDomains: ['a.com'] } }); + resolveSandboxConfig(withDomains); + resolveSandboxConfig(withDomains); + resolveSandboxConfig(stubConfig({ enabled: true })); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('allowed_domains'); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts new file mode 100644 index 0000000000..81d0699c4e --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts @@ -0,0 +1,85 @@ +/** + * sandboxPolicy tests for the v2 sandbox domain. + * + * Exercises the pure `resolveSandboxPolicy`: writable-root sets per mode, + * `~` expansion against the host home directory, normalization, and dedupe. + */ + +import { describe, expect, it } from 'vitest'; + +import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; + +const ENV = { tmpdir: '/tmp', homeDir: '/home/test' } as const; +const WORKSPACE = { workDir: '/workspace/app', additionalDirs: ['/workspace/extra'] } as const; + +describe('resolveSandboxPolicy', () => { + it('defaults to workspace-write with workspace + additionalDirs + tmpdir writable', () => { + const policy = resolveSandboxPolicy({}, WORKSPACE, ENV); + + expect(policy.mode).toBe('workspace-write'); + expect(policy.writableRoots).toEqual(['/workspace/app', '/workspace/extra', '/tmp']); + expect(policy.denyRead).toEqual([]); + expect(policy.denyWrite).toEqual([]); + expect(policy.networkEnabled).toBe(false); + }); + + it('read-only mode keeps only tmpdir + filesystem.allowWrite writable', () => { + const config: SandboxConfig = { + mode: 'read-only', + filesystem: { allowWrite: ['/data/out'] }, + }; + const policy = resolveSandboxPolicy(config, WORKSPACE, ENV); + + expect(policy.mode).toBe('read-only'); + expect(policy.writableRoots).toEqual(['/tmp', '/data/out']); + }); + + it('appends filesystem.allowWrite in workspace-write mode', () => { + const config: SandboxConfig = { filesystem: { allowWrite: ['/data/out', '~/.cache/tool'] } }; + const policy = resolveSandboxPolicy(config, WORKSPACE, ENV); + + expect(policy.writableRoots).toEqual([ + '/workspace/app', + '/workspace/extra', + '/tmp', + '/data/out', + '/home/test/.cache/tool', + ]); + }); + + it('expands ~ in denyRead / denyWrite / allowWrite against homeDir', () => { + const config: SandboxConfig = { + filesystem: { + denyRead: ['~/.ssh', '~/secrets.txt', '/etc/shadow'], + denyWrite: ['~/.git', '~'], + allowWrite: ['~'], + }, + }; + const policy = resolveSandboxPolicy(config, WORKSPACE, ENV); + + expect(policy.denyRead).toEqual(['/home/test/.ssh', '/home/test/secrets.txt', '/etc/shadow']); + expect(policy.denyWrite).toEqual(['/home/test/.git', '/home/test']); + expect(policy.writableRoots).toContain('/home/test'); + }); + + it('normalizes and dedupes roots', () => { + const config: SandboxConfig = { + filesystem: { allowWrite: ['/workspace/app/', '/data//out'] }, + }; + const policy = resolveSandboxPolicy( + config, + { workDir: '/workspace/app', additionalDirs: ['/workspace/app', '/workspace/extra'] }, + ENV, + ); + + expect(policy.writableRoots).toEqual(['/workspace/app', '/workspace/extra', '/tmp', '/data/out']); + }); + + it('reads networkEnabled from network.enabled', () => { + expect(resolveSandboxPolicy({ network: { enabled: true } }, WORKSPACE, ENV).networkEnabled).toBe( + true, + ); + expect(resolveSandboxPolicy({ network: {} }, WORKSPACE, ENV).networkEnabled).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts b/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts new file mode 100644 index 0000000000..2bb251e348 --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts @@ -0,0 +1,270 @@ +/** + * SandboxService tests for the v2 sandbox domain. + * + * Drives `decide` through every branch — disabled, excluded command, sandboxed + * (Linux bwrap / macOS seatbelt), fail-closed blocked, fail-open unsandboxed, + * unsupported platform — with stubbed `IConfigService`, + * `ISessionWorkspaceContext`, `IHostEnvironment`, `IHostProcessService` (the + * backend probe), and `ILogService`. Also covers the probe caching and the + * `matchExcludedCommand` segment/prefix matcher. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { ILogService } from '#/_base/log/log'; +import type { IConfigService } from '#/app/config/config'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import { BwrapSandboxBackend } from '#/session/sandbox/backends/bwrapBackend'; +import type { ISandboxBackend } from '#/session/sandbox/backends/sandboxBackend'; +import { SeatbeltSandboxBackend } from '#/session/sandbox/backends/seatbeltBackend'; +import { matchExcludedCommand, SandboxService } from '#/session/sandbox/sandboxService'; +import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; +import { stubWorkspaceContext } from '../workspaceContext/stub-workspace-context'; + +// Backends with a stubbed path classifier so wrap never touches the real fs. +function testBackend(osKind: string): ISandboxBackend | undefined { + if (osKind === 'Linux') return new BwrapSandboxBackend(() => 'dir'); + if (osKind === 'macOS') return new SeatbeltSandboxBackend(); + return undefined; +} + +function stubConfig(section: SandboxConfig | undefined): IConfigService { + return { + _serviceBrand: undefined, + get: (domain: string) => (domain === 'sandbox' ? section : undefined), + } as unknown as IConfigService; +} + +function stubEnv(osKind: string): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind, + osArch: 'arm64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: 'posix', + homeDir: '/home/test', + ready: Promise.resolve(), + }; +} + +function stubHostProcess(exitCode: number): { + readonly hostProcess: IHostProcessService; + readonly spawn: ReturnType; +} { + const spawn = vi.fn(async (): Promise => { + return { + _serviceBrand: undefined, + pid: 1, + exitCode, + stdin: {} as IHostProcess['stdin'], + stdout: {} as IHostProcess['stdout'], + stderr: {} as IHostProcess['stderr'], + wait: async () => exitCode, + kill: async () => {}, + dispose: () => {}, + }; + }); + return { + hostProcess: { _serviceBrand: undefined, spawn } as unknown as IHostProcessService, + spawn, + }; +} + +function stubLog(): { readonly log: ILogService; readonly warn: ReturnType } { + const warn = vi.fn(); + return { log: { _serviceBrand: undefined, warn } as unknown as ILogService, warn }; +} + +function service(options: { + readonly config?: SandboxConfig; + readonly osKind?: string; + readonly probeExitCode?: number; + readonly workDir?: string; + readonly additionalDirs?: readonly string[]; +}): { + readonly sandbox: SandboxService; + readonly spawn: ReturnType; + readonly warn: ReturnType; +} { + const { hostProcess, spawn } = stubHostProcess(options.probeExitCode ?? 0); + const { log, warn } = stubLog(); + const sandbox = new SandboxService( + stubConfig(options.config), + stubWorkspaceContext(options.workDir ?? '/workspace/app', options.additionalDirs ?? []), + stubEnv(options.osKind ?? 'Linux'), + hostProcess, + log, + testBackend, + ); + return { sandbox, spawn, warn }; +} + +describe('SandboxService.decide', () => { + it('runs unsandboxed when the section is missing or enabled is not true', async () => { + const { sandbox } = service({ config: undefined }); + await expect(sandbox.decide('ls', '/workspace/app')).resolves.toEqual({ + kind: 'unsandboxed', + reason: 'disabled', + }); + + const off = service({ config: { enabled: false } }); + await expect(off.sandbox.decide('ls', '/workspace/app')).resolves.toEqual({ + kind: 'unsandboxed', + reason: 'disabled', + }); + }); + + it('reports excluded commands without probing the backend', async () => { + const { sandbox, spawn } = service({ + config: { enabled: true, excludedCommands: ['docker'] }, + }); + + await expect(sandbox.decide('docker ps', '/workspace/app')).resolves.toEqual({ + kind: 'excluded', + matched: 'docker', + }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('wraps the shell argv with bwrap on Linux when the probe succeeds', async () => { + const { sandbox, spawn } = service({ + config: { enabled: true, filesystem: { denyRead: ['/home/test/.ssh'] } }, + additionalDirs: ['/workspace/extra'], + }); + + const decision = await sandbox.decide('echo ok', '/workspace/app'); + expect(decision.kind).toBe('sandboxed'); + if (decision.kind !== 'sandboxed') return; + expect(decision.backendId).toBe('bwrap'); + expect(decision.argv[0]).toBe('bwrap'); + expect(decision.argv).toContain('--unshare-net'); + expect(decision.argv).toEqual( + expect.arrayContaining(['--bind', '/workspace/app', '/workspace/app']), + ); + expect(decision.argv).toEqual( + expect.arrayContaining(['--bind', '/workspace/extra', '/workspace/extra']), + ); + const separator = decision.argv.indexOf('--'); + expect(decision.argv.slice(separator + 1)).toEqual([ + '/bin/bash', + '-c', + "cd '/workspace/app' && echo ok", + ]); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0]?.[0]).toBe('bwrap'); + }); + + it('includes the command cwd in the writable roots when it differs from the workDir', async () => { + const { sandbox } = service({ config: { enabled: true } }); + + const decision = await sandbox.decide('pwd', '/tmp/scratch'); + expect(decision.kind).toBe('sandboxed'); + if (decision.kind !== 'sandboxed') return; + expect(decision.argv).toEqual(expect.arrayContaining(['--bind', '/tmp/scratch', '/tmp/scratch'])); + expect(decision.argv.slice(decision.argv.indexOf('--') + 1)).toEqual([ + '/bin/bash', + '-c', + "cd '/tmp/scratch' && pwd", + ]); + }); + + it('wraps with seatbelt on macOS', async () => { + const { sandbox } = service({ config: { enabled: true }, osKind: 'macOS' }); + + const decision = await sandbox.decide('echo ok', '/workspace/app'); + expect(decision.kind).toBe('sandboxed'); + if (decision.kind !== 'sandboxed') return; + expect(decision.backendId).toBe('seatbelt'); + expect(decision.argv[0]).toBe('sandbox-exec'); + }); + + it('caches the backend probe across decisions', async () => { + const { sandbox, spawn } = service({ config: { enabled: true } }); + + await sandbox.decide('one', '/workspace/app'); + await sandbox.decide('two', '/workspace/app'); + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it('fails closed when require is true and the backend is unavailable', async () => { + const { sandbox } = service({ config: { enabled: true, require: true }, probeExitCode: 1 }); + + const decision = await sandbox.decide('ls', '/workspace/app'); + expect(decision.kind).toBe('blocked'); + if (decision.kind !== 'blocked') return; + expect(decision.reason).toContain('require'); + expect(decision.reason).toContain('bwrap'); + }); + + it('fails open with a one-time warning when require is not set', async () => { + const { sandbox, warn } = service({ config: { enabled: true }, probeExitCode: 1 }); + + await expect(sandbox.decide('ls', '/workspace/app')).resolves.toEqual({ + kind: 'unsandboxed', + reason: 'backend-unavailable', + }); + await sandbox.decide('ls', '/workspace/app'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('reports unsupported-platform on Windows, blocked only when required', async () => { + const open = service({ config: { enabled: true }, osKind: 'Windows' }); + await expect(open.sandbox.decide('ls', 'C:\\repo')).resolves.toEqual({ + kind: 'unsandboxed', + reason: 'unsupported-platform', + }); + + const closed = service({ config: { enabled: true, require: true }, osKind: 'Windows' }); + const decision = await closed.sandbox.decide('ls', 'C:\\repo'); + expect(decision.kind).toBe('blocked'); + if (decision.kind !== 'blocked') return; + expect(decision.reason).toContain('Windows'); + }); + + it('treats a failing probe spawn as backend-unavailable', async () => { + const { sandbox } = service({ config: { enabled: true }, probeExitCode: 1 }); + await expect(sandbox.decide('ls', '/workspace/app')).resolves.toEqual({ + kind: 'unsandboxed', + reason: 'backend-unavailable', + }); + }); +}); + +describe('matchExcludedCommand', () => { + it('matches the first token of a segment', () => { + expect(matchExcludedCommand('docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('podman ps', ['docker'])).toBeUndefined(); + }); + + it('splits on &&, ||, ;, | and newlines', () => { + expect(matchExcludedCommand('ls && docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('ls || docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('ls; docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('ls | docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('ls\ndocker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('ls && echo hi', ['docker'])).toBeUndefined(); + }); + + it('strips leading env assignments before matching', () => { + expect(matchExcludedCommand('FOO=bar BAZ=1 docker ps', ['docker'])).toBe('docker'); + expect(matchExcludedCommand('FOO=bar echo ok', ['docker'])).toBeUndefined(); + }); + + it('matches multi-word entries as a prefix with a word boundary', () => { + expect(matchExcludedCommand('git push origin main', ['git push'])).toBe('git push'); + expect(matchExcludedCommand('git push', ['git push'])).toBe('git push'); + expect(matchExcludedCommand('git pushforce', ['git push'])).toBeUndefined(); + expect(matchExcludedCommand('git pul', ['git push'])).toBeUndefined(); + }); + + it('requires a word boundary for single-word entries', () => { + expect(matchExcludedCommand('docker-compose up', ['docker'])).toBeUndefined(); + }); + + it('returns undefined for an empty exclusion list', () => { + expect(matchExcludedCommand('docker ps', [])).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/seatbeltBackend.test.ts b/packages/agent-core-v2/test/session/sandbox/seatbeltBackend.test.ts new file mode 100644 index 0000000000..8973d8cfe2 --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/seatbeltBackend.test.ts @@ -0,0 +1,105 @@ +/** + * SeatbeltSandboxBackend tests for the v2 sandbox domain. + * + * Covers the availability probe and the generated SBPL profile: `(deny + * default)` baseline with a conservative allow set, explicit `deny file-read*` + * masks (which beat the broad read allow), writable-root `allow file-write*`, + * `deny file-write*` overrides, the network gate, and SBPL string escaping. + * No macOS host is required — the profile is asserted as text. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + SEATBELT_DETECT_ARGV, + SeatbeltSandboxBackend, + buildSeatbeltProfile, +} from '#/session/sandbox/backends/seatbeltBackend'; +import type { ResolvedSandboxPolicy } from '#/session/sandbox/sandboxTypes'; + +function policy(overrides: Partial = {}): ResolvedSandboxPolicy { + return { + mode: 'workspace-write', + writableRoots: ['/workspace/app', '/tmp'], + denyRead: [], + denyWrite: [], + networkEnabled: false, + ...overrides, + }; +} + +describe('SeatbeltSandboxBackend.detect', () => { + it('is available when the probe exits 0', async () => { + const spawn = vi.fn(async () => 0); + await expect(new SeatbeltSandboxBackend().detect(spawn)).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith([...SEATBELT_DETECT_ARGV]); + }); + + it('is unavailable on non-zero exit or spawn failure', async () => { + await expect(new SeatbeltSandboxBackend().detect(async () => 1)).resolves.toBe(false); + await expect( + new SeatbeltSandboxBackend().detect(async () => { + throw new Error('ENOENT'); + }), + ).resolves.toBe(false); + }); +}); + +describe('SeatbeltSandboxBackend.wrap', () => { + it('prefixes the argv with sandbox-exec -p ', () => { + const argv = new SeatbeltSandboxBackend().wrap(['/bin/zsh', '-c', 'echo ok'], policy()); + + expect(argv[0]).toBe('sandbox-exec'); + expect(argv[1]).toBe('-p'); + expect(argv.slice(3)).toEqual(['/bin/zsh', '-c', 'echo ok']); + }); +}); + +describe('buildSeatbeltProfile', () => { + it('denies by default with a conservative process/read baseline and writable roots', () => { + const profile = buildSeatbeltProfile(policy()); + + expect(profile).toMatchInlineSnapshot(` + "(version 1) + (deny default) + (allow process-exec) + (allow process-fork) + (allow signal (target self)) + (allow sysctl-read) + (allow mach-lookup) + (allow ipc-posix-shm) + (allow file-read*) + (allow file-write* (subpath "/workspace/app")) + (allow file-write* (subpath "/tmp"))" + `); + }); + + it('emits deny rules for denyRead / denyWrite (deny beats allow in seatbelt)', () => { + const profile = buildSeatbeltProfile( + policy({ + denyRead: ['/Users/test/.ssh'], + denyWrite: ['/workspace/app/.git'], + }), + ); + + expect(profile).toContain('(deny file-read* (subpath "/Users/test/.ssh"))'); + expect(profile).toContain('(deny file-write* (subpath "/workspace/app/.git"))'); + expect(profile.indexOf('(deny file-read*')).toBeGreaterThan(profile.indexOf('(allow file-read*)')); + expect(profile.indexOf('(deny file-write*')).toBeGreaterThan( + profile.indexOf('(allow file-write*'), + ); + }); + + it('allows the network only when networkEnabled is true', () => { + expect(buildSeatbeltProfile(policy())).not.toContain('network'); + expect(buildSeatbeltProfile(policy({ networkEnabled: true }))).toContain('(allow network*)'); + }); + + it('escapes backslashes and double quotes in paths', () => { + const profile = buildSeatbeltProfile( + policy({ writableRoots: ['/odd/path\\with"quote'], denyRead: [], denyWrite: [] }), + ); + + expect(profile).toContain('(allow file-write* (subpath "/odd/path\\\\with\\"quote"))'); + }); +}); From b21394f2a270ac315bb3ce96f67b114e56b2a162 Mon Sep 17 00:00:00 2001 From: xuwenhao Date: Thu, 23 Jul 2026 18:03:27 +0800 Subject: [PATCH 2/3] fix(agent-core-v2): default sensitive deny_read and stop promoting arbitrary Bash cwd to writable roots --- docs/en/configuration/sandbox.md | 15 +++++- docs/zh/configuration/sandbox.md | 15 +++++- .../policies/sandbox-fs-deny.ts | 15 ++---- .../src/session/sandbox/sandboxPolicy.ts | 46 +++++++++++++++---- .../src/session/sandbox/sandboxService.ts | 12 ++--- .../session/sandbox/sandboxPolicy.test.ts | 41 +++++++++++++++-- .../session/sandbox/sandboxService.test.ts | 14 ++++-- 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/docs/en/configuration/sandbox.md b/docs/en/configuration/sandbox.md index 0e98517777..611f979e43 100644 --- a/docs/en/configuration/sandbox.md +++ b/docs/en/configuration/sandbox.md @@ -37,7 +37,7 @@ auto_allow_sandboxed_bash = true # Sandboxed Bash calls skip the approval excluded_commands = [] # Command prefixes that bypass the sandbox. [sandbox.filesystem] -deny_read = [] # Paths masked so the sandboxed process cannot read them. +deny_read = [] # Extra paths to mask (appended to the built-in sensitive list). allow_write = [] # Extra writable roots on top of the mode defaults. deny_write = [] # Paths inside writable roots re-protected as read-only. @@ -62,7 +62,18 @@ allow_unix_sockets = [] # Reserved (e.g. an ssh-agent socket); in - `deny_read` beats the writable roots, `deny_write` beats `allow_write` / the mode defaults — deny always wins. - On Linux, `deny_read` / `deny_write` entries that do not exist are skipped (bubblewrap cannot mount over a non-existent path). -Sensitive files (`~/.ssh/id_rsa`, `.env`, cloud credentials, …) are always denied by the permission layer while the sandbox is enabled, even without listing them in `deny_read` — see below. +### Built-in `deny_read` list + +The sandbox always masks a built-in set of credential locations — it cannot be turned off, and `filesystem.deny_read` appends to it: + +``` +~/.ssh ~/.aws ~/.gnupg ~/.azure ~/.config/gcloud +~/.kube ~/.docker ~/.netrc ~/.git-credentials ~/.config/gh +``` + +In addition, a literal `.env` directly under each writable root (the workspace, its additional directories, the temp directory, and each `allow_write` entry) is masked. A `.env` in a **nested subdirectory** is not covered by this built-in rule — add the concrete directory to `filesystem.deny_read` if you need it masked. + +Sensitive files beyond these paths (`credentials` files, SSH key variants, …) are also hard-denied for the file tools by the permission layer while the sandbox is enabled — see below. ### `network` diff --git a/docs/zh/configuration/sandbox.md b/docs/zh/configuration/sandbox.md index 1d26a5a003..e274f1f007 100644 --- a/docs/zh/configuration/sandbox.md +++ b/docs/zh/configuration/sandbox.md @@ -37,7 +37,7 @@ auto_allow_sandboxed_bash = true # 沙箱内执行的 Bash 免审批。 excluded_commands = [] # 命中前缀的命令段不进沙箱。 [sandbox.filesystem] -deny_read = [] # 遮蔽后沙箱内进程读不到的路径。 +deny_read = [] # 追加的遮蔽路径(在内置敏感清单之上追加)。 allow_write = [] # 在模式默认之外额外可写的根目录。 deny_write = [] # 在可写集合内再排除、恢复只读的路径。 @@ -62,7 +62,18 @@ allow_unix_sockets = [] # 预留字段(如 ssh-agent socket) - `deny_read` 优先于可写根,`deny_write` 优先于 `allow_write` 和模式默认——deny 永远赢。 - Linux 下 `deny_read` / `deny_write` 里不存在的路径会被跳过(bubblewrap 无法在不存在的路径上挂载)。 -敏感文件(`~/.ssh/id_rsa`、`.env`、云厂商凭证等)在沙箱启用时一律被权限层硬拒绝,无需写进 `deny_read`——见下文。 +### 内置 `deny_read` 清单 + +沙箱始终遮蔽一组内置的凭证位置——不可关闭,`filesystem.deny_read` 是在它之上追加: + +``` +~/.ssh ~/.aws ~/.gnupg ~/.azure ~/.config/gcloud +~/.kube ~/.docker ~/.netrc ~/.git-credentials ~/.config/gh +``` + +另外,每个可写根(工作区、additionalDirs、临时目录、每个 `allow_write` 条目)**直下**的 `.env` 也会被遮蔽。**子目录里**的 `.env` 不在内置规则覆盖范围——需要的话把具体目录写进 `filesystem.deny_read`。 + +这些路径之外的敏感文件(`credentials`、SSH 私钥变体等)在沙箱启用时也会被权限层对文件工具硬拒绝——见下文。 ### `network` diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts index 7198f4d544..16a46a4787 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts @@ -36,7 +36,7 @@ export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { if (accesses.length === 0) return undefined; const policy = this.resolvedPolicy(config); for (const access of accesses) { - const denied = this.denyAccess(access, config, policy); + const denied = this.denyAccess(access, policy); if (denied !== undefined) return denied; } return undefined; @@ -44,7 +44,6 @@ export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { private denyAccess( access: ToolFileAccess, - config: SandboxConfig, policy: ResolvedSandboxPolicy, ): PermissionPolicyResult | undefined { if (isSensitiveFile(access.path)) { @@ -61,26 +60,22 @@ export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { access.operation === 'search' || access.operation === 'readwrite'; if (reads) { - const rule = (config.filesystem?.denyRead ?? []).find((entry) => - matchesPathRule(access.path, entry, homeDir), - ); + const rule = policy.denyRead.find((entry) => matchesPathRule(access.path, entry, homeDir)); if (rule !== undefined) { return { kind: 'deny', - message: `Read access to "${access.path}" is denied by sandbox rule filesystem.deny_read ("${rule}").`, + message: `Read access to "${access.path}" is denied by the sandbox deny-read policy ("${rule}").`, reason: { matched_rule: rule }, }; } } const writes = access.operation === 'write' || access.operation === 'readwrite'; if (writes) { - const rule = (config.filesystem?.denyWrite ?? []).find((entry) => - matchesPathRule(access.path, entry, homeDir), - ); + const rule = policy.denyWrite.find((entry) => matchesPathRule(access.path, entry, homeDir)); if (rule !== undefined) { return { kind: 'deny', - message: `Write access to "${access.path}" is denied by sandbox rule filesystem.deny_write ("${rule}").`, + message: `Write access to "${access.path}" is denied by the sandbox deny-write policy ("${rule}").`, reason: { matched_rule: rule }, }; } diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts index c8a4b7c637..bc8dacddd8 100644 --- a/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts +++ b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts @@ -6,8 +6,11 @@ * set for the mode (`workspace-write` = workspace + additionalDirs + tmpdir + * `filesystem.allowWrite`; `read-only` = tmpdir + `filesystem.allowWrite`), * and the deny lists, all as normalized absolute paths with `~` expanded - * against the host home directory. `denyRead` defaults to empty — sensitive - * files stay guarded by the `isSensitiveFile` permission policy (Phase 2). + * against the host home directory and a trailing `/**` stripped (backends + * mask literal paths; subtree semantics live in the matchers). `denyRead` + * always starts from `DEFAULT_DENY_READ` (well-known credential locations, + * plus a literal `.env` under every writable root) — the built-in list cannot + * be turned off; `filesystem.denyRead` appends to it. */ import { normalize } from 'pathe'; @@ -24,22 +27,45 @@ export interface SandboxPathEnv { readonly homeDir: string; } +export const DEFAULT_DENY_READ: readonly string[] = [ + '~/.ssh', + '~/.aws', + '~/.gnupg', + '~/.azure', + '~/.config/gcloud', + '~/.kube', + '~/.docker', + '~/.netrc', + '~/.git-credentials', + '~/.config/gh', +]; + +const SUBTREE_SUFFIX = '/**'; + export function resolveSandboxPolicy( config: SandboxConfig, workspace: SandboxWorkspaceRoots, env: SandboxPathEnv, ): ResolvedSandboxPolicy { const mode: SandboxMode = config.mode ?? 'workspace-write'; - const expand = (p: string): string => stripTrailingSlash(normalize(expandHome(p, env.homeDir))); + const expand = (p: string): string => + stripTrailingSlash(normalize(expandHome(stripSubtreeSuffix(p), env.homeDir))); const allowWrite = (config.filesystem?.allowWrite ?? []).map(expand); - const writableRoots = - mode === 'read-only' + const writableRoots = dedupe( + (mode === 'read-only' ? [env.tmpdir, ...allowWrite] - : [workspace.workDir, ...workspace.additionalDirs, env.tmpdir, ...allowWrite]; + : [workspace.workDir, ...workspace.additionalDirs, env.tmpdir, ...allowWrite] + ).map(expand), + ); + const denyRead = dedupe([ + ...DEFAULT_DENY_READ.map(expand), + ...writableRoots.map((root) => `${root}/.env`), + ...(config.filesystem?.denyRead ?? []).map(expand), + ]); return { mode, - writableRoots: dedupe(writableRoots.map(expand)), - denyRead: dedupe((config.filesystem?.denyRead ?? []).map(expand)), + writableRoots, + denyRead, denyWrite: dedupe((config.filesystem?.denyWrite ?? []).map(expand)), networkEnabled: config.network?.enabled ?? false, }; @@ -51,6 +77,10 @@ function expandHome(p: string, homeDir: string): string { return p; } +function stripSubtreeSuffix(p: string): string { + return p.endsWith(SUBTREE_SUFFIX) ? p.slice(0, -SUBTREE_SUFFIX.length) : p; +} + function stripTrailingSlash(p: string): string { return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p; } diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxService.ts b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts index acab39e6f2..4f6ff2243c 100644 --- a/packages/agent-core-v2/src/session/sandbox/sandboxService.ts +++ b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts @@ -77,7 +77,7 @@ export class SandboxService implements ISandboxService { return { kind: 'unsandboxed', reason: probe.status }; } - const policy = resolveSandboxPolicy(config, this.workspaceRoots(cwd), { + const policy = resolveSandboxPolicy(config, this.workspaceRoots(), { tmpdir: tmpdir(), homeDir: this.env.homeDir, }); @@ -125,11 +125,11 @@ export class SandboxService implements ISandboxService { ); } - private workspaceRoots(cwd: string): SandboxWorkspaceRoots { - const workDir = this.workspace.workDir; - const additionalDirs = - cwd === workDir ? this.workspace.additionalDirs : [cwd, ...this.workspace.additionalDirs]; - return { workDir, additionalDirs }; + private workspaceRoots(): SandboxWorkspaceRoots { + // Writable roots come only from the session workspace — the command's cwd + // is never promoted: a cwd inside the workspace is covered by workDir, + // and a cwd outside it stays read-only (cd in, read, but writes EROFS). + return { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }; } } diff --git a/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts index 81d0699c4e..581e5994cc 100644 --- a/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts +++ b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts @@ -2,28 +2,43 @@ * sandboxPolicy tests for the v2 sandbox domain. * * Exercises the pure `resolveSandboxPolicy`: writable-root sets per mode, - * `~` expansion against the host home directory, normalization, and dedupe. + * `~` expansion against the host home directory, normalization and dedupe, + * the always-on `DEFAULT_DENY_READ` list, and the literal `.env` masks under + * every writable root. */ import { describe, expect, it } from 'vitest'; -import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import { DEFAULT_DENY_READ, resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; const ENV = { tmpdir: '/tmp', homeDir: '/home/test' } as const; const WORKSPACE = { workDir: '/workspace/app', additionalDirs: ['/workspace/extra'] } as const; +const EXPANDED_DEFAULTS = DEFAULT_DENY_READ.map((p) => `/home/test/${p.slice(2)}`); + +function expectedDenyRead(writableRoots: readonly string[], userRules: readonly string[] = []) { + return [...EXPANDED_DEFAULTS, ...writableRoots.map((root) => `${root}/.env`), ...userRules]; +} + describe('resolveSandboxPolicy', () => { it('defaults to workspace-write with workspace + additionalDirs + tmpdir writable', () => { const policy = resolveSandboxPolicy({}, WORKSPACE, ENV); expect(policy.mode).toBe('workspace-write'); expect(policy.writableRoots).toEqual(['/workspace/app', '/workspace/extra', '/tmp']); - expect(policy.denyRead).toEqual([]); expect(policy.denyWrite).toEqual([]); expect(policy.networkEnabled).toBe(false); }); + it('always includes the built-in deny_read list and a literal .env under each writable root', () => { + const policy = resolveSandboxPolicy({}, WORKSPACE, ENV); + + expect(policy.denyRead).toEqual( + expectedDenyRead(['/workspace/app', '/workspace/extra', '/tmp']), + ); + }); + it('read-only mode keeps only tmpdir + filesystem.allowWrite writable', () => { const config: SandboxConfig = { mode: 'read-only', @@ -33,6 +48,7 @@ describe('resolveSandboxPolicy', () => { expect(policy.mode).toBe('read-only'); expect(policy.writableRoots).toEqual(['/tmp', '/data/out']); + expect(policy.denyRead).toEqual(expectedDenyRead(['/tmp', '/data/out'])); }); it('appends filesystem.allowWrite in workspace-write mode', () => { @@ -51,18 +67,32 @@ describe('resolveSandboxPolicy', () => { it('expands ~ in denyRead / denyWrite / allowWrite against homeDir', () => { const config: SandboxConfig = { filesystem: { - denyRead: ['~/.ssh', '~/secrets.txt', '/etc/shadow'], + denyRead: ['~/.gnupg', '~/secrets.txt', '/etc/shadow'], denyWrite: ['~/.git', '~'], allowWrite: ['~'], }, }; const policy = resolveSandboxPolicy(config, WORKSPACE, ENV); - expect(policy.denyRead).toEqual(['/home/test/.ssh', '/home/test/secrets.txt', '/etc/shadow']); + // ~/.gnupg is already in the built-in defaults — merged without duplicates. + expect(policy.denyRead).toEqual( + expectedDenyRead(['/workspace/app', '/workspace/extra', '/tmp', '/home/test'], [ + '/home/test/secrets.txt', + '/etc/shadow', + ]), + ); expect(policy.denyWrite).toEqual(['/home/test/.git', '/home/test']); expect(policy.writableRoots).toContain('/home/test'); }); + it('strips a trailing /** from rule entries', () => { + const config: SandboxConfig = { filesystem: { denyRead: ['/data/secret/**'] } }; + const policy = resolveSandboxPolicy(config, WORKSPACE, ENV); + + expect(policy.denyRead).toContain('/data/secret'); + expect(policy.denyRead).not.toContain('/data/secret/**'); + }); + it('normalizes and dedupes roots', () => { const config: SandboxConfig = { filesystem: { allowWrite: ['/workspace/app/', '/data//out'] }, @@ -74,6 +104,7 @@ describe('resolveSandboxPolicy', () => { ); expect(policy.writableRoots).toEqual(['/workspace/app', '/workspace/extra', '/tmp', '/data/out']); + expect(policy.denyRead.filter((p) => p === '/workspace/app/.env')).toHaveLength(1); }); it('reads networkEnabled from network.enabled', () => { diff --git a/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts b/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts index 2bb251e348..9904eb350e 100644 --- a/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts +++ b/packages/agent-core-v2/test/session/sandbox/sandboxService.test.ts @@ -157,17 +157,23 @@ describe('SandboxService.decide', () => { expect(spawn.mock.calls[0]?.[0]).toBe('bwrap'); }); - it('includes the command cwd in the writable roots when it differs from the workDir', async () => { + it('does not promote an outside-workspace cwd into the writable roots', async () => { const { sandbox } = service({ config: { enabled: true } }); - const decision = await sandbox.decide('pwd', '/tmp/scratch'); + const decision = await sandbox.decide('pwd', '/home/test/other-project'); expect(decision.kind).toBe('sandboxed'); if (decision.kind !== 'sandboxed') return; - expect(decision.argv).toEqual(expect.arrayContaining(['--bind', '/tmp/scratch', '/tmp/scratch'])); + // The shell still cds into the requested cwd, but it stays read-only. + expect(decision.argv).not.toEqual( + expect.arrayContaining(['--bind', '/home/test/other-project', '/home/test/other-project']), + ); + expect(decision.argv).toEqual( + expect.arrayContaining(['--bind', '/workspace/app', '/workspace/app']), + ); expect(decision.argv.slice(decision.argv.indexOf('--') + 1)).toEqual([ '/bin/bash', '-c', - "cd '/tmp/scratch' && pwd", + "cd '/home/test/other-project' && pwd", ]); }); From 6b52936d3fd702a7f256c0fe6981482bf7b96c4d Mon Sep 17 00:00:00 2001 From: xuwenhao Date: Thu, 23 Jul 2026 20:10:02 +0800 Subject: [PATCH 3/3] fix(agent-core-v2): scrub env, mask host sockets, and close recursive-search deny_read gaps in sandbox --- docs/en/configuration/sandbox.md | 8 +++ docs/zh/configuration/sandbox.md | 8 +++ .../policies/sandbox-fs-deny.ts | 26 +++++-- .../policies/sandbox-outside-workspace-ask.ts | 6 +- packages/agent-core-v2/src/index.ts | 1 + .../src/os/backends/node-local/tools/bash.ts | 10 ++- .../session/sandbox/backends/bwrapBackend.ts | 17 ++--- .../sandbox/backends/sandboxBackend.ts | 14 +++- .../src/session/sandbox/envScrub.ts | 38 ++++++++++ .../src/session/sandbox/sandboxPolicy.ts | 72 +++++++++++++++++-- .../src/session/sandbox/sandboxService.ts | 22 +++--- .../src/session/sandbox/sandboxTypes.ts | 9 +-- .../policies/sandbox-fs-deny.test.ts | 62 +++++++++++++++- .../os/backends/node-local/tools/bash.test.ts | 32 +++++++++ .../test/session/sandbox/envScrub.test.ts | 55 ++++++++++++++ .../session/sandbox/sandboxPolicy.test.ts | 61 +++++++++++++++- 16 files changed, 395 insertions(+), 46 deletions(-) create mode 100644 packages/agent-core-v2/src/session/sandbox/envScrub.ts create mode 100644 packages/agent-core-v2/test/session/sandbox/envScrub.test.ts diff --git a/docs/en/configuration/sandbox.md b/docs/en/configuration/sandbox.md index 611f979e43..3bad19f2fd 100644 --- a/docs/en/configuration/sandbox.md +++ b/docs/en/configuration/sandbox.md @@ -73,8 +73,14 @@ The sandbox always masks a built-in set of credential locations — it cannot be In addition, a literal `.env` directly under each writable root (the workspace, its additional directories, the temp directory, and each `allow_write` entry) is masked. A `.env` in a **nested subdirectory** is not covered by this built-in rule — add the concrete directory to `filesystem.deny_read` if you need it masked. +Host daemon sockets are masked as well, since unix-domain sockets bypass network isolation: `/var/run/docker.sock`, `/run/docker.sock`, `/var/run/containerd.sock`, `/run/containerd/containerd.sock`, `/run/crio/crio.sock`, `/run/podman/podman.sock`, plus `$XDG_RUNTIME_DIR/bus`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, and `$XDG_RUNTIME_DIR/gnupg` (when `XDG_RUNTIME_DIR` is set or resolvable as `/run/user/`). + Sensitive files beyond these paths (`credentials` files, SSH key variants, …) are also hard-denied for the file tools by the permission layer while the sandbox is enabled — see below. +### Environment variable scrubbing + +Sandboxed commands do not inherit secret-bearing environment variables: names ending in `_API_KEY`, `_KEY`, `_TOKEN`, `_SECRET`, `_PASSWORD`, or `_CREDENTIALS` (case-insensitive), plus `SSH_AUTH_SOCK`, `SSH_AGENT_PID`, `GPG_AGENT_INFO`, and `XAUTHORITY`, are blanked out in the command's environment. This is not configurable; put non-secret values in ordinary variables if a sandboxed command needs them. + ### `network` With `enabled = false` (default) the sandboxed command runs in a private network namespace with no connectivity (Linux) or with all network operations denied (macOS). @@ -105,6 +111,7 @@ While `sandbox.enabled = true`, three permission policies take effect (in additi 1. **Sandboxed Bash skip-approval** — a `Bash` call that will run inside the sandbox is approved without prompting (disable with `auto_allow_sandboxed_bash = false`). User `deny` rules and the checks below still take precedence. 2. **Hard deny** — evaluated before mode-based auto-approval (`--auto` / `--yolo`): - reads/searches matching `filesystem.deny_read`; + - **recursive** reads/searches (e.g. `Grep` over a directory tree) whose root contains a `deny_read` *directory* — the whole access is denied with a hint to narrow the search root (file entries such as the built-in `.env` masks never trigger this); - writes matching `filesystem.deny_write`; - in `read-only` mode, writes outside the writable roots; - any access to a **sensitive file** (env files, SSH keys, cloud credentials — the same patterns the file tools already guard), which is upgraded from "ask" to a hard "deny" while the sandbox is enabled. @@ -114,6 +121,7 @@ While `sandbox.enabled = true`, three permission policies take effect (in additi - Only `Bash` is wrapped by the OS sandbox. External hooks run unsandboxed (same as claude-code), and the `Grep` tool's `rg` subprocess is not sandboxed separately — its search roots are already guarded by the workspace path checks plus the policies above. - On Linux, `deny_read` masks only paths that exist when the command starts (bubblewrap cannot mount over a non-existent path). +- The sandbox masks the known host socket paths listed above but cannot block creating new `AF_UNIX` sockets (no seccomp filter); it also cannot stop a sandboxed process from talking to daemons over unnamed or relocated sockets. - `network.allowed_domains` and `network.allow_unix_sockets` are reserved schema fields with no behavior yet (Phase 3). - Windows is not supported; commands run unsandboxed there. diff --git a/docs/zh/configuration/sandbox.md b/docs/zh/configuration/sandbox.md index e274f1f007..318aa56c97 100644 --- a/docs/zh/configuration/sandbox.md +++ b/docs/zh/configuration/sandbox.md @@ -73,8 +73,14 @@ allow_unix_sockets = [] # 预留字段(如 ssh-agent socket) 另外,每个可写根(工作区、additionalDirs、临时目录、每个 `allow_write` 条目)**直下**的 `.env` 也会被遮蔽。**子目录里**的 `.env` 不在内置规则覆盖范围——需要的话把具体目录写进 `filesystem.deny_read`。 +宿主 daemon 的 unix socket 也在内置遮蔽之列(unix socket 不受网络隔离约束):`/var/run/docker.sock`、`/run/docker.sock`、`/var/run/containerd.sock`、`/run/containerd/containerd.sock`、`/run/crio/crio.sock`、`/run/podman/podman.sock`,以及 `$XDG_RUNTIME_DIR/bus`、`$XDG_RUNTIME_DIR/docker.sock`、`$XDG_RUNTIME_DIR/podman/podman.sock`、`$XDG_RUNTIME_DIR/gnupg`(`XDG_RUNTIME_DIR` 已设置或可解析为 `/run/user/` 时)。 + 这些路径之外的敏感文件(`credentials`、SSH 私钥变体等)在沙箱启用时也会被权限层对文件工具硬拒绝——见下文。 +### 环境变量擦除 + +沙箱命令不会继承密钥类环境变量:以 `_API_KEY`、`_KEY`、`_TOKEN`、`_SECRET`、`_PASSWORD`、`_CREDENTIALS` 结尾(大小写不敏感)的变量,以及 `SSH_AUTH_SOCK`、`SSH_AGENT_PID`、`GPG_AGENT_INFO`、`XAUTHORITY`,在沙箱命令的环境里会被置空。该行为不可配置;沙箱命令需要的非密钥值请放在普通变量里。 + ### `network` `enabled = false`(默认)时,沙箱内命令运行在独立网络命名空间、无任何连接(Linux),或所有网络操作被拒绝(macOS)。 @@ -105,6 +111,7 @@ allow_unix_sockets = [] # 预留字段(如 ssh-agent socket) 1. **沙箱 Bash 免审批**——将在沙箱内运行的 `Bash` 调用直接批准,不再询问(可用 `auto_allow_sandboxed_bash = false` 关闭)。用户配置的 `deny` 规则和下面的检查仍然优先。 2. **硬拒绝**——在模式自动批准(`--auto` / `--yolo`)之前判定: - 读/搜索命中 `filesystem.deny_read`; + - **递归**读/搜索(如对目录树的 `Grep`)的根目录包含某个 `deny_read` **目录**时,整个访问被拒绝,并提示缩小搜索根(文件类条目如内置 `.env` 遮蔽不会触发该判定); - 写命中 `filesystem.deny_write`; - `read-only` 模式下写出可写根之外; - 任意 operation 命中**敏感文件**(env 文件、SSH 私钥、云凭证——与文件工具既有守护相同的模式),沙箱启用期间从「询问」升级为硬「拒绝」。 @@ -114,6 +121,7 @@ allow_unix_sockets = [] # 预留字段(如 ssh-agent socket) - 只有 `Bash` 走 OS 沙箱。外部 hooks 不在沙箱内运行(与 claude-code 一致);`Grep` 工具的 `rg` 子进程不单独过沙箱——其搜索根已被工作区路径检查和上述策略覆盖。 - Linux 下 `deny_read` 只遮蔽命令启动时已存在的路径(bubblewrap 无法在不存在的路径上挂载)。 +- 沙箱只遮蔽上文列出的已知 socket 路径,无法拦截新建 `AF_UNIX` socket(无 seccomp 过滤),也管不到改名或迁移过的 daemon socket。 - `network.allowed_domains` 与 `network.allow_unix_sockets` 是预留 schema 字段,暂无行为(Phase 3)。 - Windows 不支持,命令按非沙箱运行。 diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts index 16a46a4787..483a933972 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-fs-deny.ts @@ -1,5 +1,3 @@ -import { tmpdir } from 'node:os'; - import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { IConfigService, type IConfigService as ConfigService } from '#/app/config/config'; import { IHostEnvironment, type IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; @@ -7,15 +5,16 @@ import type { PermissionPolicy, PermissionPolicyResult, } from '#/agent/permissionPolicy/types'; +import { defaultPathKind } from '#/session/sandbox/backends/sandboxBackend'; import { resolveSandboxConfig } from '#/session/sandbox/configSection'; import { isWithinAnyRoot, matchesPathRule } from '#/session/sandbox/pathRules'; -import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import { hostSandboxPathEnv, resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; import type { ResolvedSandboxPolicy, SandboxConfig } from '#/session/sandbox/sandboxTypes'; import { ISessionWorkspaceContext, type ISessionWorkspaceContext as WorkspaceContext, } from '#/session/workspaceContext/workspaceContext'; -import { isSensitiveFile } from '#/tool/path-access'; +import { isSensitiveFile, isWithinDirectory } from '#/tool/path-access'; import type { ToolFileAccess } from '#/tool/toolContract'; import { fileAccesses } from './path-utils'; @@ -68,6 +67,23 @@ export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { reason: { matched_rule: rule }, }; } + if (access.recursive === true) { + const contained = policy.denyRead.find( + (entry) => + defaultPathKind(entry) === 'dir' && + isWithinDirectory(entry, access.path) && + !isWithinDirectory(access.path, entry), + ); + if (contained !== undefined) { + return { + kind: 'deny', + message: + `Recursive access to "${access.path}" is denied: its subtree contains the ` + + `sandbox deny-read path "${contained}". Narrow the search or read root.`, + reason: { matched_rule: contained }, + }; + } + } } const writes = access.operation === 'write' || access.operation === 'readwrite'; if (writes) { @@ -96,7 +112,7 @@ export class SandboxFsDenyPermissionPolicyService implements PermissionPolicy { return resolveSandboxPolicy( config, { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }, - { tmpdir: tmpdir(), homeDir: this.env.homeDir }, + hostSandboxPathEnv(this.env.homeDir), ); } } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts index 6f3965e07f..3bc5828a59 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sandbox-outside-workspace-ask.ts @@ -1,5 +1,3 @@ -import { tmpdir } from 'node:os'; - import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { IConfigService, type IConfigService as ConfigService } from '#/app/config/config'; import { IHostEnvironment, type IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment'; @@ -9,7 +7,7 @@ import type { } from '#/agent/permissionPolicy/types'; import { resolveSandboxConfig } from '#/session/sandbox/configSection'; import { isWithinAnyRoot } from '#/session/sandbox/pathRules'; -import { resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import { hostSandboxPathEnv, resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; import { ISessionWorkspaceContext, type ISessionWorkspaceContext as WorkspaceContext, @@ -32,7 +30,7 @@ export class SandboxOutsideWorkspaceAskPermissionPolicyService implements Permis const policy = resolveSandboxPolicy( config, { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }, - { tmpdir: tmpdir(), homeDir: this.env.homeDir }, + hostSandboxPathEnv(this.env.homeDir), ); const access = fileAccesses(context).find( (fileAccess) => !isWithinAnyRoot(fileAccess.path, policy.writableRoots), diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index eb7206967f..baabe05679 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -315,6 +315,7 @@ export { export * from '#/session/sandbox/sandboxTypes'; export * from '#/session/sandbox/sandboxPolicy'; export * from '#/session/sandbox/pathRules'; +export * from '#/session/sandbox/envScrub'; export * from '#/session/sandbox/backends/sandboxBackend'; export * from '#/session/sandbox/backends/bwrapBackend'; export * from '#/session/sandbox/backends/seatbeltBackend'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts index 243994a4ae..b024758bfb 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts @@ -12,7 +12,9 @@ * - `tasks` — `IAgentTaskService`, owns foreground/detached task * lifecycle (timeouts, detach, user interrupt) * - `sandbox` — `ISandboxService`, decides per command whether the shell - * argv is wrapped in the OS sandbox (bwrap/seatbelt) + * argv is wrapped in the OS sandbox (bwrap/seatbelt); + * sandboxed runs also scrub sensitive env vars (API keys, + * tokens, agent sockets) to empty in the exec overlay * * Execution goes through `ISessionProcessRunner`, never directly via * `node:child_process`. @@ -41,6 +43,7 @@ import { resolveAgentTaskConfig } from '#/agent/task/configSection'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { ISandboxService } from '#/session/sandbox/sandbox'; +import { sensitiveEnvNames } from '#/session/sandbox/envScrub'; import type { SandboxDecision } from '#/session/sandbox/sandboxTypes'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner'; @@ -270,6 +273,11 @@ export class BashTool implements BuiltinTool { GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', SHELL: this.env.shellPath, }; + if (decision.kind === 'sandboxed') { + for (const name of sensitiveEnvNames(Object.keys(process.env))) { + noninteractiveEnv[name] = ''; + } + } return this.runner.exec(shellArgs, { env: noninteractiveEnv }); } diff --git a/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts b/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts index 212f5fe8de..8990f9f341 100644 --- a/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts +++ b/packages/agent-core-v2/src/session/sandbox/backends/bwrapBackend.ts @@ -14,11 +14,14 @@ * (`PathKindProbe`) for tests; the default uses `node:fs`. */ -import { statSync } from 'node:fs'; - import type { ResolvedSandboxPolicy } from '../sandboxTypes'; -import type { ISandboxBackend, PathKind, PathKindProbe, SpawnProbe } from './sandboxBackend'; +import { + defaultPathKind, + type ISandboxBackend, + type PathKindProbe, + type SpawnProbe, +} from './sandboxBackend'; export const BWRAP_DETECT_ARGV: readonly string[] = ['bwrap', '--ro-bind', '/', '/', '--', 'true']; @@ -73,11 +76,3 @@ function isWithinPath(p: string, root: string): boolean { if (root === '/') return true; return p === root || p.startsWith(`${root}/`); } - -function defaultPathKind(p: string): PathKind { - try { - return statSync(p).isDirectory() ? 'dir' : 'file'; - } catch { - return 'missing'; - } -} diff --git a/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts b/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts index 6028dd33f6..f67feff47c 100644 --- a/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts +++ b/packages/agent-core-v2/src/session/sandbox/backends/sandboxBackend.ts @@ -7,9 +7,13 @@ * (Linux) and seatbelt / `sandbox-exec` (macOS). Windows has no backend — the * service reports `unsupported-platform`. `SpawnProbe` runs an argv and * resolves with the exit code; `PathKind` classifies a path so `wrap` can pick - * the right bind strategy (injectable for tests). + * the right bind strategy (injectable for tests; `defaultPathKind` stats the + * host filesystem, classifying anything non-directory — files, sockets, + * devices — as `file` so they get the `/dev/null` mask). */ +import { statSync } from 'node:fs'; + import type { ResolvedSandboxPolicy, SandboxBackendId } from '../sandboxTypes'; export type SpawnProbe = (argv: string[]) => Promise; @@ -23,3 +27,11 @@ export interface ISandboxBackend { detect(spawn: SpawnProbe): Promise; wrap(argv: readonly string[], policy: ResolvedSandboxPolicy): readonly string[]; } + +export function defaultPathKind(p: string): PathKind { + try { + return statSync(p).isDirectory() ? 'dir' : 'file'; + } catch { + return 'missing'; + } +} diff --git a/packages/agent-core-v2/src/session/sandbox/envScrub.ts b/packages/agent-core-v2/src/session/sandbox/envScrub.ts new file mode 100644 index 0000000000..4e34176c85 --- /dev/null +++ b/packages/agent-core-v2/src/session/sandbox/envScrub.ts @@ -0,0 +1,38 @@ +/** + * `sandbox` domain (L3) — sensitive environment-variable classification. + * + * Pure classifier for host env names that must not leak into a sandboxed + * command's environment: case-insensitive suffixes (`_API_KEY`, `_KEY`, + * `_TOKEN`, `_SECRET`, `_PASSWORD`, `_CREDENTIALS`) plus an exact list of + * agent/socket variables (`SSH_AUTH_SOCK`, `SSH_AGENT_PID`, + * `GPG_AGENT_INFO`, `XAUTHORITY`). The Bash tool scrubs the hits to empty + * strings in its exec overlay when a command runs sandboxed. + */ + +const SENSITIVE_ENV_SUFFIXES = [ + '_API_KEY', + '_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', +] as const; + +const SENSITIVE_ENV_EXACT: ReadonlySet = new Set([ + 'SSH_AUTH_SOCK', + 'SSH_AGENT_PID', + 'GPG_AGENT_INFO', + 'XAUTHORITY', +]); + +export function sensitiveEnvNames(names: readonly string[]): readonly string[] { + return names.filter(isSensitiveEnvName); +} + +export function isSensitiveEnvName(name: string): boolean { + const upper = name.toUpperCase(); + if (SENSITIVE_ENV_EXACT.has(upper)) return true; + return SENSITIVE_ENV_SUFFIXES.some( + (suffix) => upper.length > suffix.length && upper.endsWith(suffix), + ); +} diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts index bc8dacddd8..f61e0c0d94 100644 --- a/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts +++ b/packages/agent-core-v2/src/session/sandbox/sandboxPolicy.ts @@ -8,13 +8,23 @@ * and the deny lists, all as normalized absolute paths with `~` expanded * against the host home directory and a trailing `/**` stripped (backends * mask literal paths; subtree semantics live in the matchers). `denyRead` - * always starts from `DEFAULT_DENY_READ` (well-known credential locations, - * plus a literal `.env` under every writable root) — the built-in list cannot - * be turned off; `filesystem.denyRead` appends to it. + * always starts from the built-in masks — `DEFAULT_DENY_READ` credential + * locations, `DEFAULT_DENY_READ_SOCKETS` host daemon sockets plus the + * `$XDG_RUNTIME_DIR` bus/agent sockets (unix sockets bypass `--unshare-net`), + * and a literal `.env` under every writable root — none of it can be turned + * off; `filesystem.denyRead` appends. In `read-only` mode, workspace roots + * sitting under the tmpdir subtree would be re-opened for writing by the + * tmpdir writable root, so they are automatically re-protected through + * `denyWrite`. `resolveSandboxPolicy` itself stays pure; callers resolve the + * host facts (`tmpdir`, `homeDir`, `resolveXdgRuntimeDir`). */ import { normalize } from 'pathe'; +import { tmpdir } from 'node:os'; + +import { isWithinDirectory } from '#/tool/path-access'; + import type { ResolvedSandboxPolicy, SandboxConfig, SandboxMode } from './sandboxTypes'; export interface SandboxWorkspaceRoots { @@ -25,6 +35,7 @@ export interface SandboxWorkspaceRoots { export interface SandboxPathEnv { readonly tmpdir: string; readonly homeDir: string; + readonly xdgRuntimeDir?: string | undefined; } export const DEFAULT_DENY_READ: readonly string[] = [ @@ -40,6 +51,22 @@ export const DEFAULT_DENY_READ: readonly string[] = [ '~/.config/gh', ]; +export const DEFAULT_DENY_READ_SOCKETS: readonly string[] = [ + '/var/run/docker.sock', + '/run/docker.sock', + '/var/run/containerd.sock', + '/run/containerd/containerd.sock', + '/run/crio/crio.sock', + '/run/podman/podman.sock', +]; + +const XDG_RUNTIME_SOCKETS: readonly string[] = [ + 'bus', + 'docker.sock', + 'podman/podman.sock', + 'gnupg', +]; + const SUBTREE_SUFFIX = '/**'; export function resolveSandboxPolicy( @@ -57,20 +84,57 @@ export function resolveSandboxPolicy( : [workspace.workDir, ...workspace.additionalDirs, env.tmpdir, ...allowWrite] ).map(expand), ); + const xdgSockets = + env.xdgRuntimeDir === undefined + ? [] + : XDG_RUNTIME_SOCKETS.map((rel) => `${env.xdgRuntimeDir}/${rel}`); const denyRead = dedupe([ ...DEFAULT_DENY_READ.map(expand), + ...DEFAULT_DENY_READ_SOCKETS, + ...xdgSockets.map(expand), ...writableRoots.map((root) => `${root}/.env`), ...(config.filesystem?.denyRead ?? []).map(expand), ]); + const tmpdirRoot = expand(env.tmpdir); + const readonlyReprotected = + mode === 'read-only' + ? [workspace.workDir, ...workspace.additionalDirs] + .map(expand) + .filter((root) => isWithinDirectory(root, tmpdirRoot)) + : []; return { mode, writableRoots, denyRead, - denyWrite: dedupe((config.filesystem?.denyWrite ?? []).map(expand)), + denyWrite: dedupe([ + ...(config.filesystem?.denyWrite ?? []).map(expand), + ...readonlyReprotected, + ]), networkEnabled: config.network?.enabled ?? false, }; } +export function resolveXdgRuntimeDir( + env: NodeJS.ProcessEnv, + uid: number | undefined, +): string | undefined { + const dir = env['XDG_RUNTIME_DIR']; + if (dir !== undefined && dir !== '') return dir; + return uid === undefined ? undefined : `/run/user/${String(uid)}`; +} + +export function hostSandboxPathEnv(homeDir: string): SandboxPathEnv { + return { + tmpdir: tmpdir(), + homeDir, + xdgRuntimeDir: resolveXdgRuntimeDir(process.env, hostUid()), + }; +} + +function hostUid(): number | undefined { + return typeof process.getuid === 'function' ? process.getuid() : undefined; +} + function expandHome(p: string, homeDir: string): string { if (p === '~') return homeDir; if (p.startsWith('~/')) return `${homeDir}/${p.slice(2)}`; diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxService.ts b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts index 4f6ff2243c..244922d01b 100644 --- a/packages/agent-core-v2/src/session/sandbox/sandboxService.ts +++ b/packages/agent-core-v2/src/session/sandbox/sandboxService.ts @@ -6,7 +6,11 @@ * lazily on first `decide` through `os/interface` host-process spawns and * cached afterwards), and folds the workspace roots from `workspaceContext` * with host path facts (`os/interface` host environment) into a - * `ResolvedSandboxPolicy`. A sandboxed decision wraps the full shell argv + * `ResolvedSandboxPolicy`. The writable roots come only from the session + * workspace — the command's cwd is never promoted: a cwd inside the + * workspace is already covered by workDir, and a cwd outside it stays + * read-only (the shell cds in and can read, but writes hit EROFS). A + * sandboxed decision wraps the full shell argv * (` -c 'cd && '`, POSIX only — Windows reports * `unsupported-platform`) so the caller can exec it directly. Backend * unavailable with `require = true` fails closed (`blocked`); otherwise it @@ -17,8 +21,6 @@ * permission chain. Bound at Session scope. */ -import { tmpdir } from 'node:os'; - import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; @@ -32,7 +34,11 @@ import type { ISandboxBackend } from './backends/sandboxBackend'; import { SeatbeltSandboxBackend } from './backends/seatbeltBackend'; import { resolveSandboxConfig } from './configSection'; import { ISandboxService } from './sandbox'; -import { resolveSandboxPolicy, type SandboxWorkspaceRoots } from './sandboxPolicy'; +import { + hostSandboxPathEnv, + resolveSandboxPolicy, + type SandboxWorkspaceRoots, +} from './sandboxPolicy'; import type { SandboxBackendId, SandboxDecision } from './sandboxTypes'; type BackendProbeResult = @@ -77,10 +83,7 @@ export class SandboxService implements ISandboxService { return { kind: 'unsandboxed', reason: probe.status }; } - const policy = resolveSandboxPolicy(config, this.workspaceRoots(), { - tmpdir: tmpdir(), - homeDir: this.env.homeDir, - }); + const policy = resolveSandboxPolicy(config, this.workspaceRoots(), hostSandboxPathEnv(this.env.homeDir)); const shellArgv = [this.env.shellPath, '-c', `cd ${shellQuote(cwd)} && ${command}`]; return { kind: 'sandboxed', @@ -126,9 +129,6 @@ export class SandboxService implements ISandboxService { } private workspaceRoots(): SandboxWorkspaceRoots { - // Writable roots come only from the session workspace — the command's cwd - // is never promoted: a cwd inside the workspace is covered by workDir, - // and a cwd outside it stays read-only (cd in, read, but writes EROFS). return { workDir: this.workspace.workDir, additionalDirs: this.workspace.additionalDirs }; } } diff --git a/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts b/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts index 980d2e7936..a01b16a62d 100644 --- a/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts +++ b/packages/agent-core-v2/src/session/sandbox/sandboxTypes.ts @@ -5,7 +5,11 @@ * seatbelt on macOS): the `[sandbox]` config-section shape (`SandboxConfig`), * the per-command verdict (`SandboxDecision`) produced by `ISandboxService`, * and the backend-ready `ResolvedSandboxPolicy` (absolute, `~`-expanded paths) - * consumed by the sandbox backends. No IO, no DI. + * consumed by the sandbox backends. `blocked` is the fail-closed verdict: + * `require = true` but no usable backend, so the command must not run. In a + * resolved policy the writable roots are cwd + additionalDirs + tmpdir + + * `filesystem.allowWrite` for `workspace-write`, or tmpdir + + * `filesystem.allowWrite` for `read-only`. No IO, no DI. */ export type SandboxMode = 'workspace-write' | 'read-only'; @@ -41,13 +45,10 @@ export type SandboxDecision = readonly kind: 'unsandboxed'; readonly reason: 'disabled' | 'backend-unavailable' | 'unsupported-platform'; } - // `require = true` but no usable backend: fail-closed, the command must not run. | { readonly kind: 'blocked'; readonly reason: string }; export interface ResolvedSandboxPolicy { readonly mode: SandboxMode; - // Absolute paths with `~` expanded. `workspace-write`: cwd + additionalDirs + - // tmpdir + filesystem.allowWrite; `read-only`: tmpdir + filesystem.allowWrite. readonly writableRoots: readonly string[]; readonly denyRead: readonly string[]; readonly denyWrite: readonly string[]; diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts index 925f762ece..351d263583 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/sandbox-fs-deny.test.ts @@ -1,7 +1,9 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { ToolCall } from '#/kosong/contract/message'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import type { IConfigService } from '#/app/config/config'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; @@ -35,14 +37,30 @@ function stubEnv(): IHostEnvironment { }; } -function policy(section: SandboxConfig | undefined): SandboxFsDenyPermissionPolicyService { +function policy( + section: SandboxConfig | undefined, + workDir = '/workspace/app', + additionalDirs: readonly string[] = ['/workspace/extra'], +): SandboxFsDenyPermissionPolicyService { return new SandboxFsDenyPermissionPolicyService( stubConfig(section), - stubWorkspaceContext('/workspace/app', ['/workspace/extra']), + stubWorkspaceContext(workDir, additionalDirs), stubEnv(), ); } +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'kimi-sandbox-fs-deny-')); + tempDirs.push(dir); + return dir; +} + function policyContext(accesses: ToolAccessList): ResolvedToolExecutionHookContext { const call: ToolCall = { type: 'function', @@ -170,4 +188,42 @@ describe('SandboxFsDenyPermissionPolicyService', () => { ).toBeUndefined(); expect(policy({ enabled: true }).evaluate(policyContext(ToolAccesses.none()))).toBeUndefined(); }); + + it('denies a recursive search whose root contains a deny_read directory', () => { + const ws = makeTempDir(); + const secret = join(ws, 'secret'); + mkdirSync(secret); + const p = policy({ enabled: true, filesystem: { denyRead: [secret] } }, ws); + + const denied = p.evaluate(policyContext(ToolAccesses.searchTree(ws))); + expect(denied).toMatchObject({ kind: 'deny', reason: { matched_rule: secret } }); + expect(denied?.kind === 'deny' && denied.message).toContain('Narrow'); + + expect(p.evaluate(policyContext(ToolAccesses.readTree(ws)))).toMatchObject({ + kind: 'deny', + reason: { matched_rule: secret }, + }); + }); + + it('does not deny recursive searches that only contain deny_read files or nothing denied', () => { + const ws = makeTempDir(); + writeFileSync(join(ws, '.env'), 'SECRET=1'); + const p = policy({ enabled: true }, ws); + + expect(p.evaluate(policyContext(ToolAccesses.searchTree(ws)))).toBeUndefined(); + }); + + it('does not apply reverse containment to non-recursive accesses', () => { + const ws = makeTempDir(); + const secret = join(ws, 'secret'); + mkdirSync(secret); + const p = policy({ enabled: true, filesystem: { denyRead: [secret] } }, ws); + + expect( + p.evaluate(policyContext(ToolAccesses.readFile(join(ws, 'other.txt')))), + ).toBeUndefined(); + expect( + p.evaluate(policyContext([{ kind: 'file', operation: 'search', path: ws }])), + ).toBeUndefined(); + }); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 02c0652d20..6d69f0299b 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -937,6 +937,38 @@ describe('BashTool', () => { expect(result.output).not.toContain('sandbox.excluded_commands'); }); + it('scrubs sensitive env vars only when the command runs sandboxed', async () => { + const key = 'KIMI_TEST_FAKE_API_KEY'; + const previous = process.env[key]; + process.env[key] = 'supersecret'; + try { + const { runner: sandboxedRunner, exec: sandboxedExec } = createTestRunner( + processWithOutput({ stdout: 'ok\n' }), + ); + const sandboxedTool = bashTool( + sandboxedRunner, + posixEnv, + createTestCtx(), + undefined, + undefined, + undefined, + stubSandbox({ kind: 'sandboxed', argv: ['bwrap', '--', 'true'], backendId: 'bwrap' }), + ); + await executeTool(sandboxedTool, context({ command: 'env', timeout: 60 })); + const sandboxedEnv = sandboxedExec.mock.calls[0]?.[1]?.env as Record; + expect(sandboxedEnv[key]).toBe(''); + + const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'ok\n' })); + const tool = bashTool(runner); + await executeTool(tool, context({ command: 'env', timeout: 60 })); + const plainEnv = exec.mock.calls[0]?.[1]?.env as Record; + expect(plainEnv[key]).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + it('uses Git Bash semantics on Windows', async () => { const proc = processWithOutput({ stdout: 'ok\n' }); const { runner, exec } = createTestRunner(proc); diff --git a/packages/agent-core-v2/test/session/sandbox/envScrub.test.ts b/packages/agent-core-v2/test/session/sandbox/envScrub.test.ts new file mode 100644 index 0000000000..108196c3c1 --- /dev/null +++ b/packages/agent-core-v2/test/session/sandbox/envScrub.test.ts @@ -0,0 +1,55 @@ +/** + * envScrub tests for the v2 sandbox domain. + * + * Covers the sensitive env-name classifier: case-insensitive suffix matching + * (`_API_KEY`, `_KEY`, `_TOKEN`, `_SECRET`, `_PASSWORD`, `_CREDENTIALS`), the + * exact agent/socket list, and non-matches. + */ + +import { describe, expect, it } from 'vitest'; + +import { isSensitiveEnvName, sensitiveEnvNames } from '#/session/sandbox/envScrub'; + +describe('isSensitiveEnvName', () => { + it('matches the secret suffixes case-insensitively', () => { + expect(isSensitiveEnvName('KIMI_API_KEY')).toBe(true); + expect(isSensitiveEnvName('OPENAI_API_KEY')).toBe(true); + expect(isSensitiveEnvName('openai_api_key')).toBe(true); + expect(isSensitiveEnvName('AWS_SECRET_ACCESS_TOKEN')).toBe(true); + expect(isSensitiveEnvName('github_token')).toBe(true); + expect(isSensitiveEnvName('DB_PASSWORD')).toBe(true); + expect(isSensitiveEnvName('MY_SECRET')).toBe(true); + expect(isSensitiveEnvName('GCP_CREDENTIALS')).toBe(true); + expect(isSensitiveEnvName('ENCRYPTION_KEY')).toBe(true); + }); + + it('matches the exact agent/socket list', () => { + expect(isSensitiveEnvName('SSH_AUTH_SOCK')).toBe(true); + expect(isSensitiveEnvName('SSH_AGENT_PID')).toBe(true); + expect(isSensitiveEnvName('GPG_AGENT_INFO')).toBe(true); + expect(isSensitiveEnvName('XAUTHORITY')).toBe(true); + expect(isSensitiveEnvName('ssh_auth_sock')).toBe(true); + }); + + it('does not match ordinary variables or near-misses', () => { + expect(isSensitiveEnvName('PATH')).toBe(false); + expect(isSensitiveEnvName('HOME')).toBe(false); + expect(isSensitiveEnvName('KEY')).toBe(false); + expect(isSensitiveEnvName('_KEY')).toBe(false); + expect(isSensitiveEnvName('KEYSTONE')).toBe(false); + expect(isSensitiveEnvName('MONKEY')).toBe(false); + expect(isSensitiveEnvName('TURKEY')).toBe(false); + expect(isSensitiveEnvName('SSH_AGENT')).toBe(false); + expect(isSensitiveEnvName('XDG_RUNTIME_DIR')).toBe(false); + expect(isSensitiveEnvName('GIT_TERMINAL_PROMPT')).toBe(false); + }); +}); + +describe('sensitiveEnvNames', () => { + it('filters a name list down to the sensitive entries', () => { + expect( + sensitiveEnvNames(['PATH', 'KIMI_API_KEY', 'HOME', 'SSH_AUTH_SOCK', 'npm_token']), + ).toEqual(['KIMI_API_KEY', 'SSH_AUTH_SOCK', 'npm_token']); + expect(sensitiveEnvNames([])).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts index 581e5994cc..cc9bed1330 100644 --- a/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts +++ b/packages/agent-core-v2/test/session/sandbox/sandboxPolicy.test.ts @@ -9,7 +9,12 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_DENY_READ, resolveSandboxPolicy } from '#/session/sandbox/sandboxPolicy'; +import { + DEFAULT_DENY_READ, + DEFAULT_DENY_READ_SOCKETS, + resolveSandboxPolicy, + resolveXdgRuntimeDir, +} from '#/session/sandbox/sandboxPolicy'; import type { SandboxConfig } from '#/session/sandbox/sandboxTypes'; const ENV = { tmpdir: '/tmp', homeDir: '/home/test' } as const; @@ -18,7 +23,12 @@ const WORKSPACE = { workDir: '/workspace/app', additionalDirs: ['/workspace/extr const EXPANDED_DEFAULTS = DEFAULT_DENY_READ.map((p) => `/home/test/${p.slice(2)}`); function expectedDenyRead(writableRoots: readonly string[], userRules: readonly string[] = []) { - return [...EXPANDED_DEFAULTS, ...writableRoots.map((root) => `${root}/.env`), ...userRules]; + return [ + ...EXPANDED_DEFAULTS, + ...DEFAULT_DENY_READ_SOCKETS, + ...writableRoots.map((root) => `${root}/.env`), + ...userRules, + ]; } describe('resolveSandboxPolicy', () => { @@ -107,6 +117,44 @@ describe('resolveSandboxPolicy', () => { expect(policy.denyRead.filter((p) => p === '/workspace/app/.env')).toHaveLength(1); }); + it('masks host daemon sockets and (optionally) $XDG_RUNTIME_DIR sockets by default', () => { + const withoutXdg = resolveSandboxPolicy({}, WORKSPACE, ENV); + for (const socket of DEFAULT_DENY_READ_SOCKETS) { + expect(withoutXdg.denyRead).toContain(socket); + } + expect(withoutXdg.denyRead.some((p) => p.startsWith('/run/user/'))).toBe(false); + + const withXdg = resolveSandboxPolicy({}, WORKSPACE, { + ...ENV, + xdgRuntimeDir: '/run/user/1000', + }); + expect(withXdg.denyRead).toEqual( + expect.arrayContaining([ + '/run/user/1000/bus', + '/run/user/1000/docker.sock', + '/run/user/1000/podman/podman.sock', + '/run/user/1000/gnupg', + ]), + ); + }); + + it('re-protects workspace roots under tmpdir in read-only mode', () => { + const policy = resolveSandboxPolicy( + { mode: 'read-only' }, + { workDir: '/tmp/repo', additionalDirs: ['/tmp/lib', '/workspace/outside'] }, + ENV, + ); + + expect(policy.writableRoots).toEqual(['/tmp']); + expect(policy.denyWrite).toEqual(['/tmp/repo', '/tmp/lib']); + }); + + it('keeps denyWrite free of workspace roots outside tmpdir in read-only mode', () => { + const policy = resolveSandboxPolicy({ mode: 'read-only' }, WORKSPACE, ENV); + + expect(policy.denyWrite).toEqual([]); + }); + it('reads networkEnabled from network.enabled', () => { expect(resolveSandboxPolicy({ network: { enabled: true } }, WORKSPACE, ENV).networkEnabled).toBe( true, @@ -114,3 +162,12 @@ describe('resolveSandboxPolicy', () => { expect(resolveSandboxPolicy({ network: {} }, WORKSPACE, ENV).networkEnabled).toBe(false); }); }); + +describe('resolveXdgRuntimeDir', () => { + it('prefers $XDG_RUNTIME_DIR, falls back to /run/user/, then undefined', () => { + expect(resolveXdgRuntimeDir({ XDG_RUNTIME_DIR: '/run/user/42' }, 1000)).toBe('/run/user/42'); + expect(resolveXdgRuntimeDir({ XDG_RUNTIME_DIR: '' }, 1000)).toBe('/run/user/1000'); + expect(resolveXdgRuntimeDir({}, 1000)).toBe('/run/user/1000'); + expect(resolveXdgRuntimeDir({}, undefined)).toBeUndefined(); + }); +});