From 1bfc0735812570d65ff1c0b93f5080ece4434225 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 14 Oct 2026 09:59:40 -0700 Subject: [PATCH] feat(shell): opt-in os sandboxing via sandbox-runtime Model-driven child processes now pass through an os-level sandbox wrapper when shell.sandbox.enabled is set (Seatbelt on macOS, bubblewrap+netns on Linux). The wrapper builds its config from the session root, the secret-guard deny list and the permission tiers; a network ask callback routes unconfirmed hosts through the approval gate. Disabled means byte-identical spawn passthrough; enabled-but- unavailable fails closed with an error, never a raw spawn. The os sandbox stays on in yolo mode, which only removes asks. The runtime is pinned exact (scoped package) so beta releases cannot float in. Windows support is alpha upstream and left unwrapped; hosts type config. hooks.ts spawn stays outside the wrapped seams for now. --- bun.lock | 33 +- docs/IMPLEMENTATION.md | 34 +- package.json | 1 + src/agent/posix-tool-plugins.ts | 6 + src/agent/tools.ts | 13 + src/config/settings.ts | 40 +- src/exec/runner.ts | 14 + src/mcp/client.ts | 26 ++ src/plugins/shell-guard-plugin.ts | 28 +- src/plugins/shell-guard-sandbox.test.ts | 336 +++++++++++++++++ src/session/assemble-runtime.ts | 7 + src/session/hooks-sandbox.test.ts | 138 +++++++ src/session/hooks.ts | 21 +- src/shell/background-shell.test.ts | 17 +- src/shell/background-shell.ts | 32 +- src/shell/sandbox-config.test.ts | 464 ++++++++++++++++++++++++ src/shell/sandbox-config.ts | 447 +++++++++++++++++++++++ src/subagent/agent-fleet.ts | 2 + src/subagent/run-persist-close.test.ts | 2 +- src/subagent/run.ts | 3 + src/subagent/types.ts | 7 + src/tui/runner/session.ts | 19 + 22 files changed, 1650 insertions(+), 40 deletions(-) create mode 100644 src/plugins/shell-guard-sandbox.test.ts create mode 100644 src/session/hooks-sandbox.test.ts create mode 100644 src/shell/sandbox-config.test.ts create mode 100644 src/shell/sandbox-config.ts diff --git a/bun.lock b/bun.lock index 38f79da0f..6a6e90cf7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@corbits/code", "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.79", "@corbits/code-agent-coder": "workspace:*", "@corbits/code-agent-designer": "workspace:*", "@corbits/code-agent-explorer": "workspace:*", @@ -259,6 +260,8 @@ "tar": "^7.5.1", }, "packages": { + "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.79", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "node-forge": "^1.4.0", "zod": "^3.25.0" }, "bin": { "srt": "dist/cli.js" } }, "sha512-WjBmS9fbTpnQwQkM9SU+JjhM2CcXo9eWEELktY1100RMvTy/jPhP44yZ3NXhh+GHB350OgPbM3+FubE5WVRtSA=="], + "@ark/schema": ["@ark/schema@0.56.2", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg=="], "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], @@ -373,7 +376,7 @@ "@intx/workflow": ["@intx/workflow@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/inference": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-vaIqTohNNdCVhvZ4btWbIbpjp3140e5939tQP7t+rSkucTvnn6US+bXho1qUJyNQ577mf3YXHGgBCW8ppsnscA=="], - "@intx/workflow-deploy": ["@intx/workflow-deploy@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/tool-packaging": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0" } }, "sha512-eiCFS+e6DV9UTWFX5eINQCbXHi2w5Wn9SXtAOHZa4PIXOzxXKVJL/uXE/c6TfCVqhrsDdzYwrtt9koi5qmYXCg=="], + "@intx/workflow-deploy": ["@intx/workflow-deploy@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/tool-packaging": "0.4.0", "@intx/types": "0.4.0", "@intx/workflow": "0.4.0" } }, "sha512-TSLg9lPZvJ+leg2n0I/SWxZjcNQtzsH2oKLD04ptEsBZcbIuMINsQeZBmYV5Brdovy0J/SrWZyrOJ8lc+k/Fdw=="], "@intx/workflow-host": ["@intx/workflow-host@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/crypto": "0.3.0", "@intx/hub-sessions": "0.3.0", "@intx/inference": "0.3.0", "@intx/log": "0.3.0", "@intx/mail-memory": "0.3.0", "@intx/mime": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0", "arktype": "^2.1.29" } }, "sha512-vhujPnhCrf2RensenRPSH63SQZlAvtgwLyZeNWSUHlNJ94g72xzaYAa/eYcc9OHGGdGHVo63OFQ1oS1j3/kORg=="], @@ -501,6 +504,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], + "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], + "@standard-community/standard-json": ["@standard-community/standard-json@0.3.6", "", { "dependencies": { "quansync": "^0.2.11" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.20.0", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.4.2", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-tuXKz1Ps4al0W4xhfwii7v4lskKtOv3QE7mcouRVThrBdOR6JzY4Vea3nsxcEnte84bOJvMvXGmOMWE3IBbHyA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.10", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.20.0", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.4.2", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-whaCN9eu5zv5gu6Kyeh5am/S3AyS1nqo57/wAxs/KPS7+94UhBUZmkIetmXzm22qIV0zMrHt3y+2VfjkbkYYTA=="], @@ -813,6 +818,8 @@ "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "npm-install-checks": ["npm-install-checks@7.1.2", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ=="], "npm-normalize-package-bin": ["npm-normalize-package-bin@4.0.0", "", {}, "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w=="], @@ -999,26 +1006,40 @@ "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@anthropic-ai/sandbox-runtime/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@better-auth/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@intx/db/@intx/crypto": ["@intx/crypto@0.4.0", "", { "dependencies": { "@intx/types": "0.4.0" } }, "sha512-4/qWypC90GevbV9u1i/93tn46jsXhj8wE1Pn+awg8/ECpwhHnhPLT5Uy423Arr9eGmRmF6HiMyXcEgH1Z9/Rkw=="], "@intx/hub-api/@intx/crypto": ["@intx/crypto@0.4.0", "", { "dependencies": { "@intx/types": "0.4.0" } }, "sha512-4/qWypC90GevbV9u1i/93tn46jsXhj8wE1Pn+awg8/ECpwhHnhPLT5Uy423Arr9eGmRmF6HiMyXcEgH1Z9/Rkw=="], "@intx/hub-api/@intx/hub-sessions": ["@intx/hub-sessions@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/crypto": "0.4.0", "@intx/db": "0.4.0", "@intx/hub-common": "0.4.0", "@intx/log": "0.4.0", "@intx/mime": "0.4.0", "@intx/pack-transport": "0.4.0", "@intx/storage-isogit": "0.4.0", "@intx/tool-packaging": "0.4.0", "@intx/types": "0.4.0", "@intx/workflow": "0.4.0", "@intx/workflow-deploy": "0.4.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "isomorphic-git": "^1.27.2", "tar": "^7.5.1" } }, "sha512-/3nd/B/gENy3lq+MA2AR95tBu3Rkkh176XM+A8/mNMGnolPBGw+1JBUGGJ8gvdvAfo64dwRcEs3BI/VD7zFlPQ=="], - "@intx/hub-api/@intx/workflow-deploy": ["@intx/workflow-deploy@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/tool-packaging": "0.4.0", "@intx/types": "0.4.0", "@intx/workflow": "0.4.0" } }, "sha512-TSLg9lPZvJ+leg2n0I/SWxZjcNQtzsH2oKLD04ptEsBZcbIuMINsQeZBmYV5Brdovy0J/SrWZyrOJ8lc+k/Fdw=="], - "@intx/hub-sessions/@intx/db": ["@intx/db@0.3.0", "", { "dependencies": { "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/log": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "postgres": "^3.4.8" } }, "sha512-MXNeUVJTftQuSHj5zSUBQsUcFN3wUBs8t6yT6lqjbaNxpT1C7kGx8qXb95WuadZln+krlLhyoUwGMF7EmYvlnw=="], "@intx/hub-sessions/@intx/hub-common": ["@intx/hub-common@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-dA8pTmBTu1JHGd3rGJrMsRWrai4GBBNnPxpZFFtXwQPzgL290lx2R+9BpRo2apdKbmgd+KOVshLvOdcUhAqghw=="], + "@intx/hub-sessions/@intx/workflow-deploy": ["@intx/workflow-deploy@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/tool-packaging": "0.3.0", "@intx/types": "0.3.0", "@intx/workflow": "0.3.0" } }, "sha512-eiCFS+e6DV9UTWFX5eINQCbXHi2w5Wn9SXtAOHZa4PIXOzxXKVJL/uXE/c6TfCVqhrsDdzYwrtt9koi5qmYXCg=="], + + "@intx/workflow-deploy/@intx/tool-packaging": ["@intx/tool-packaging@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/log": "0.4.0", "@intx/storage-isogit": "0.4.0", "@intx/types": "0.4.0", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^19.0.0", "semver": "^7.7.2", "ssri": "^12.0.0", "tar": "^7.5.1" } }, "sha512-eBZIbGUcEBp0ppMCceA1PPd75LvXpUyWitqTIu7AsLxRNlU9gbE1hymWPXnhUKnt3LgSlHUi959Ehd6jG6mR7g=="], + + "@intx/workflow-deploy/@intx/workflow": ["@intx/workflow@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/inference": "0.4.0", "@intx/types": "0.4.0", "arktype": "^2.1.29" } }, "sha512-F5Is/lyKb4/WguAVYMDXwtI4ddUvqXbg34rcM6hPUOqupJHpbbD9+ZK/6TDfvbCiEgginbTVwMmNj6Fyg8vU+Q=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + "better-call/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "cacache/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], @@ -1055,10 +1076,6 @@ "@intx/hub-api/@intx/hub-sessions/@intx/workflow": ["@intx/workflow@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/inference": "0.4.0", "@intx/types": "0.4.0", "arktype": "^2.1.29" } }, "sha512-F5Is/lyKb4/WguAVYMDXwtI4ddUvqXbg34rcM6hPUOqupJHpbbD9+ZK/6TDfvbCiEgginbTVwMmNj6Fyg8vU+Q=="], - "@intx/hub-api/@intx/workflow-deploy/@intx/tool-packaging": ["@intx/tool-packaging@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/log": "0.4.0", "@intx/storage-isogit": "0.4.0", "@intx/types": "0.4.0", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^19.0.0", "semver": "^7.7.2", "ssri": "^12.0.0", "tar": "^7.5.1" } }, "sha512-eBZIbGUcEBp0ppMCceA1PPd75LvXpUyWitqTIu7AsLxRNlU9gbE1hymWPXnhUKnt3LgSlHUi959Ehd6jG6mR7g=="], - - "@intx/hub-api/@intx/workflow-deploy/@intx/workflow": ["@intx/workflow@0.4.0", "", { "dependencies": { "@intx/agent": "0.4.0", "@intx/inference": "0.4.0", "@intx/types": "0.4.0", "arktype": "^2.1.29" } }, "sha512-F5Is/lyKb4/WguAVYMDXwtI4ddUvqXbg34rcM6hPUOqupJHpbbD9+ZK/6TDfvbCiEgginbTVwMmNj6Fyg8vU+Q=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 2b1a8360b..c14f7dc4f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -278,6 +278,22 @@ Provider and model configuration lives in JSON settings files. The global file h - `timeoutMs` — default bound when foreground `run_shell` omits `timeout`. Unset keeps the built-in 120000ms default. A positive per-call `timeout` is the bound with no ceiling. - `maxTimeoutMs` — clamps that default path only; it does not clamp a per-call override. + Optional `shell.sandbox` opts shell spawns and stdio MCP servers into an OS-level sandbox via `@anthropic-ai/sandbox-runtime` (path sandboxing on macOS `sandbox-exec`, bubblewrap on Linux). **Default off**; with the sandbox disabled the spawn paths run exactly as before, byte-identical: + + ```json + "shell": { + "sandbox": { + "enabled": true, + "network": { "allowedHosts": ["api.github.com", "*.example.com"] } + } + } + ``` + + - `enabled` — default **false**. When true, foreground + background `run_shell`, stdio MCP server launches, and lifecycle hooks (discovered under the sandbox-writable session `.corbits/hooks`) wrap into the sandbox: writes are allowed only inside the workspace, the OS tmpdir, and `/tmp/claude`; reads deny the secret-guard denylist classes (`.env*`, `.ssh/`, `.aws/`, key material, credential stores, browser cookie stores, shell history, ...) plus the active `--config` settings path; known provider API-key env vars are unset inside the sandbox. A sandboxed command that cannot be wrapped (missing/broken runtime) **fails closed** — the command never runs — with a `shell sandbox is enabled but unavailable` error; disable the setting to restore raw spawns. Linux additionally needs bubblewrap + socat + ripgrep installed. + - `network.allowedHosts` — operator pre-approved domain allowlist (supports `*.wildcards`). Every other host asks through the normal permission gate (auto mode and yolo auto-allow it as they would a `web_fetch`; yolo relaxes the _asks_, never the sandbox itself). + + One sandboxed composition per process: sandbox-runtime's manager is a process-global singleton, so a second `shell.sandbox` composition with a different workspace/allowlist while one is live fails loudly instead of inheriting it. Matching compositions share the singleton by refcount; the last session's teardown resets it. + Optional `tools` block to arm the outer per-tool wall-clock budget (unset leaves the watchdog unarmed): ```json @@ -313,14 +329,16 @@ Provider and model configuration lives in JSON settings files. The global file h All `tools.*` keys live in the global settings file only — there is no per-repo override in `.corbits/settings.json` (unlike `sessionMode`). -| Key | Default | Effect | -| ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set | -| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` | -| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry | -| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set | -| `shell.timeoutMs` | **120000** (2 min) foreground default | Foreground `run_shell` bound when the call omits `timeout`; background has no default; a per-call `timeout` is the bound with no ceiling | -| `shell.maxTimeoutMs` | unset | Clamps the foreground default path only; does not clamp a per-call `run_shell` timeout | +| Key | Default | Effect | +| ------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set | +| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` | +| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry | +| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set | +| `shell.timeoutMs` | **120000** (2 min) foreground default | Foreground `run_shell` bound when the call omits `timeout`; background has no default; a per-call `timeout` is the bound with no ceiling | +| `shell.maxTimeoutMs` | unset | Clamps the foreground default path only; does not clamp a per-call `run_shell` timeout | +| `shell.sandbox.enabled` | `false` (opt-in, fail closed on runtime errors) | OS sandbox for `run_shell` and stdio MCP launches via `@anthropic-ai/sandbox-runtime` (workspace+tmp writes, secret denylist reads) | +| `shell.sandbox.network.allowedHosts` | `[]` (every other host asks) | Operator pre-approved host allowlist; unmatched hosts ask through the permission gate — yolo auto-allows the ask, the OS sandbox stays on | The `waitForApproval` default is resolved once at the watchdog boundary (`resolveWaitForApproval`); toggling **Settings → Tools** updates the live config for the next tool call and persists the value here. diff --git a/package.json b/package.json index f857ae955..872082fff 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "tar": "^7.5.1" }, "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.79", "@corbits/codex-provider": "0.1.1", "@corbits/oauth-core": "0.2.0", "@corbits/openai-responses": "0.2.1", diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index bc89232ca..dcd326cd6 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -30,6 +30,7 @@ import type { PermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import type { CompactionArchive } from "../session/compaction-archive.js"; import type { ShellOutputFeedMap } from "../session/shell-output-feed.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; export interface CorePosixToolPluginsArgs { cwd: string; @@ -58,6 +59,9 @@ export interface CorePosixToolPluginsArgs { * Omitted (tests, ad-hoc stacks) keeps the static denylist only. */ secretGuardExtraDeniedPaths?: readonly string[]; + // Session OS-sandbox handle for run_shell (foreground + background) and + // stdio MCP spawns. Omitted keeps the raw spawn paths byte-identical. + sandbox?: ShellSandbox; } // Middleware order matches docs/ARCHITECTURE.md: path escape through @@ -92,6 +96,7 @@ export function buildCorePosixToolPlugins( getShellOutputFeeds, getEvidenceArchive, secretGuardExtraDeniedPaths, + sandbox, } = args; // Pre-gate sandboxes honor yolo mode so outside-workspace paths and shell // cwd are not hard-denied after the gate auto-allows. Live getter so @@ -139,6 +144,7 @@ export function buildCorePosixToolPlugins( ? { getBackgroundShellRegistry } : {}), ...(getShellOutputFeeds !== undefined ? { getShellOutputFeeds } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), }), ...(getEvidenceArchive !== undefined ? [evidenceArchiveSearchPlugin(getEvidenceArchive)] diff --git a/src/agent/tools.ts b/src/agent/tools.ts index c9db63114..3d2bf5379 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -25,6 +25,7 @@ import type { Telemetry } from "../telemetry/index.js"; import type { PermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; import { createLazyBlobReader } from "./lazy-blob-reader.js"; import type { BlobReader } from "@intx/types/runtime"; import { @@ -213,6 +214,10 @@ export interface AgentToolsetArgs { * points pass [config.globalSettingsPath]. Forwarded to the posix plugin * stack and workers; omitted keeps the static denylist only. */ secretGuardExtraDeniedPaths?: readonly string[]; + // Session OS-sandbox handle (shell.sandbox.enabled): wraps run_shell and + // stdio MCP spawns, fails closed when unavailable, and asks through the + // permission gate for unmatched network hosts. Omitted = passthrough. + sandbox?: ShellSandbox; // Called when a background run_shell exits. Hosts deliver the exit as a // system message so the reactor re-enters; omit it and background runs // never notify. @@ -419,6 +424,7 @@ export async function createAgentToolset( sessionMode = "orchestrator", shellEnv, secretGuardExtraDeniedPaths, + sandbox, toolAvailability = { languageServerAvailable: true }, } = args; let mcpServersSource = args.mcpServersSource ?? "none"; @@ -433,6 +439,7 @@ export async function createAgentToolset( }), } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), }); // Bounded live-output tails of foreground shells, polled by the TUI for each // pending run_shell row. Workers get a map too; nothing reads it unless a @@ -529,6 +536,7 @@ export async function createAgentToolset( ...(shellEnv !== undefined ? { shellEnv } : {}), getBackgroundShellRegistry: () => backgroundShells, getShellOutputFeeds: () => shellOutputFeed, + ...(sandbox !== undefined ? { sandbox } : {}), }), }); @@ -572,6 +580,7 @@ export async function createAgentToolset( ...(secretGuardExtraDeniedPaths !== undefined ? { secretGuardExtraDeniedPaths } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), ...(skillDirs.length > 0 ? { skillDirs } : {}), // Parent's already-discovered catalog; shared-cwd lanes reuse it, // worktree lanes (different cwd) rediscover. @@ -1249,6 +1258,7 @@ export async function createAgentToolset( try { result = await connectMCPClient(config, { stderr: "ignore", + ...(sandbox !== undefined ? { sandbox } : {}), ...(callbacks.interactiveAuth ? { onAuthURL: (name: string, url: string) => { @@ -1528,6 +1538,9 @@ export async function createAgentToolset( // Kill live background process groups before the posix teardown so // reload/interrupt cannot leave orphans behind. backgroundShells.disposeAll(); + // Sandbox teardown before the posix dispose: srt proxies/violation state + // are host-global and must not outlive the sandboxed session. + await sandbox?.dispose(); try { await posixTools.dispose(); } catch (err: unknown) { diff --git a/src/config/settings.ts b/src/config/settings.ts index c04ba6da4..aef269b65 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -124,8 +124,16 @@ export interface Settings { // Shell command timeouts. `timeoutMs` overrides the 120s foreground run_shell // default when the model omits a per-command timeout; `maxTimeoutMs` clamps // that default path only — a per-call timeout is the bound, no ceiling. - // Background run_shell has no default. - shell?: { timeoutMs?: number; maxTimeoutMs?: number }; + // Background run_shell has no default. `sandbox` opts spawns and stdio MCP + // servers into the OS sandbox runtime (@anthropic-ai/sandbox-runtime): + // enabled is false by default, network.allowedHosts is the operator's + // pre-approved allowlist — any other host asks through the permission gate, + // and the OS sandbox itself stays on even in yolo mode. + shell?: { + timeoutMs?: number; + maxTimeoutMs?: number; + sandbox?: { enabled?: boolean; network?: { allowedHosts?: string[] } }; + }; // Outer wall-clock budget for each tool run(). waitForApproval (default true) // freezes the budget while a permission prompt is open so a late approve // still runs the tool; false keeps ticking and expiry skips the tool. @@ -369,6 +377,25 @@ export function shellTimeoutFromSettings( }; } +/** Resolved shell.sandbox block: the enable flag plus the network allowlist. */ +export interface SandboxSettings { + enabled: boolean; + network?: { allowedHosts?: string[] }; +} + +// Sandbox enablement rides the same global-settings block as the shell +// timeouts (shellTimeoutFromSettings): no local-settings leg, no CLI flag — +// the composition roots read it once at session assembly. +export function sandboxFromSettings( + settings?: Settings | null, +): SandboxSettings { + const sandbox = settings?.shell?.sandbox; + return { + enabled: sandbox?.enabled === true, + ...(sandbox?.network !== undefined ? { network: sandbox.network } : {}), + }; +} + // Maps the settings tools/mcp blocks to the shape the tool-execution watchdog // expects. Returns undefined only when nothing at all is configured so callers // can skip the override; mcp.timeoutMs alone (with no tools.* set) still @@ -658,7 +685,14 @@ const SettingsSchema = type({ "sessionMode?": "'single' | 'orchestrator'", "agentModelFallback?": "'active' | 'none'", - "shell?": type({ "timeoutMs?": "number", "maxTimeoutMs?": "number" }), + "shell?": type({ + "timeoutMs?": "number", + "maxTimeoutMs?": "number", + "sandbox?": type({ + "enabled?": "boolean", + "network?": type({ "allowedHosts?": "string[]" }), + }), + }), "tools?": type({ "timeoutMs?": "number", "maxTimeoutMs?": "number", diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 0e41744c7..1f1417dcf 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -66,6 +66,7 @@ import { type AgentToolset, type OperatorResult, } from "../agent/tools.js"; +import { createShellSandbox } from "../shell/sandbox-config.js"; import { liveTelemetry } from "../telemetry/singleton.js"; import { CREDENTIAL_FAILURE_USER_MESSAGE, @@ -705,6 +706,15 @@ export async function runExec(config: Config): Promise { }); subAgentSessions = fleetSessions; const shellTimeout = shellTimeoutFromSettings(config.settings); + // OS sandbox from settings (default off). The gate is the ask path for + // network hosts outside the allowlist; the handle is undefined when the + // sandbox is disabled, leaving every spawn site untouched. + const sandbox = createShellSandbox({ + sessionRoot: config.cwd, + extraDeniedPaths: [config.globalSettingsPath], + settings: config.settings, + gate: permissionGate, + }); const toolWatchdog = toolWatchdogFromSettings(config.settings); const toolAvailability: ToolAvailability = { languageServerAvailable: detectLanguageServerAvailable(config.cwd), @@ -733,6 +743,7 @@ export async function runExec(config: Config): Promise { secretGuardExtraDeniedPaths: [config.globalSettingsPath], ...(shellTimeout !== undefined ? { shellTimeout } : {}), ...(toolWatchdog !== undefined ? { toolWatchdog } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), ...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}), @@ -1013,6 +1024,9 @@ export async function runExec(config: Config): Promise { } = await assembleSessionLifecycle({ cwd: config.cwd, emitter, + // Hooks spawn under the same sandbox handle as run_shell: a hook file + // discovered in the (writable) session root must never raw-spawn. + getSandbox: () => sandbox, getTelemetry: () => liveTelemetry, getSessionId: () => sessionId, getSource: () => liveSource, diff --git a/src/mcp/client.ts b/src/mcp/client.ts index cefa9dbf6..d7f5098e5 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -14,6 +14,7 @@ import type { ResolvedMCPServerConfig } from "./exa.js"; import type { McpToolAnnotations } from "./tool-permissions.js"; import { buildStdioMcpProcessEnv } from "./stdio-env.js"; import { isHttpServer } from "./is-http-server.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; import { classifyMcpConnectResult, classifyMcpOAuthResult, @@ -101,6 +102,10 @@ export interface MCPConnectOptions { // transport died under a live client; close() disarms it before // intentional teardown. onDisconnect?: () => void; + // Session OS-sandbox handle: a model-reachable stdio server spawn runs + // wrapped; an unwrappable command fails the connect (fail closed). Omitted + // keeps the direct spawn path byte-identical. + sandbox?: ShellSandbox; } export function unwrapToolContent(content: unknown): string { @@ -680,6 +685,27 @@ async function connectStdio( }; if (config.args !== undefined) transportOptions.args = config.args; if (options.stderr !== undefined) transportOptions.stderr = options.stderr; + // Sandbox wrap: the wrapped argv is [binShell, "-c", wrapped], executed + // without a shell so the caller's env staylist still governs the process + // env; the sandbox's own filesystem/network policy governs its access. + if (options.sandbox !== undefined) { + try { + const { argv } = await options.sandbox.wrapExecArgv( + config.command, + transportOptions.args ?? [], + ); + const [head, ...rest] = argv; + if (head === undefined) throw new Error("sandbox wrap returned no argv"); + transportOptions.command = head; + transportOptions.args = rest; + } catch (err) { + return { + ok: false, + serverName: config.name, + error: err instanceof Error ? err.message : String(err), + }; + } + } const client = new Client({ name: MCP_CLIENT_NAME, version: "1.0.0" }); try { const transport = new StdioClientTransport(transportOptions); diff --git a/src/plugins/shell-guard-plugin.ts b/src/plugins/shell-guard-plugin.ts index 98614dc78..c50afc1ca 100644 --- a/src/plugins/shell-guard-plugin.ts +++ b/src/plugins/shell-guard-plugin.ts @@ -26,6 +26,8 @@ import { shellCwdEscapesSessionMessage, wrapCommandWithPwdProbe, } from "../shell/persistent-shell-cwd.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; +import { wrapWithShellSandbox } from "../shell/sandbox-config.js"; // Corbits Code-side replacement for stock `@intx/tools-posix` run_shell. @@ -198,6 +200,9 @@ interface RunShellArgs { cwd?: string; maxOutputBytes?: number; env?: Record; + // Session OS-sandbox handle; present means wrap-then-spawn (fail closed), + // absent means the raw spawn path, byte-identical to pre-sandbox behavior. + sandbox?: ShellSandbox; } export interface GuardedShellResult { @@ -367,10 +372,17 @@ export async function runGuardedShell( const outputCap = args.maxOutputBytes ?? MAX_SHELL_OUTPUT_BYTES; const collector = new BoundedShellOutput(outputCap); + // Sandbox wrap before the spawn promise exists: a wrap failure rejects + // here, so the raw command can never reach spawn (fail closed). + const command = + args.sandbox === undefined + ? args.command + : await wrapWithShellSandbox(args.sandbox, args.command); + return new Promise((resolve, reject) => { // detached so the shell becomes a process-group leader and timeout/abort // can SIGKILL the whole tree (otherwise find/grep orphans keep burning RAM). - const child = spawn(args.command, { + const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"], cwd: args.cwd, @@ -485,7 +497,7 @@ export async function runGuardedShell( const onAbort = () => { killProcessTree(child); - settle(new Error(`command aborted: ${args.command}`)); + settle(new Error(`command aborted: ${command}`)); }; signal.addEventListener("abort", onAbort, { once: true }); @@ -496,9 +508,7 @@ export async function runGuardedShell( child.on("error", (err) => { dropLive(); - settle( - new Error(`failed to spawn command: ${args.command}`, { cause: err }), - ); + settle(new Error(`failed to spawn command: ${command}`, { cause: err })); }); child.on("close", (code, sig) => { @@ -532,6 +542,9 @@ export interface ShellGuardPluginOptions { // Live getter for the per-call bounded shell-output feeds the transcript // polls for a running command's live tail. Unwired, the tail is simply not painted. getShellOutputFeeds?: () => ShellOutputFeedMap | undefined; + // Session OS-sandbox handle: wrapped spawns + unwrappable commands fail + // closed. Omitted (default off) keeps the raw spawn path untouched. + sandbox?: ShellSandbox; } function resolveAllowOutsideCwd( @@ -659,7 +672,7 @@ export function shellGuardPlugin( isError: true, }; } - const started = registry.start({ + const started = await registry.start({ command, cwd: executionCwd, ...(effectiveTimeout !== undefined @@ -695,6 +708,9 @@ export function shellGuardPlugin( : {}), maxOutputBytes, ...(env !== undefined ? { env } : {}), + ...(options.sandbox !== undefined + ? { sandbox: options.sandbox } + : {}), }, signal, liveChildren, diff --git a/src/plugins/shell-guard-sandbox.test.ts b/src/plugins/shell-guard-sandbox.test.ts new file mode 100644 index 000000000..5a5b5ac49 --- /dev/null +++ b/src/plugins/shell-guard-sandbox.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { tmpdir } from "node:os"; +import { withMockedModule } from "../../testkit/mock-module.js"; +import { createBackgroundShellRegistry } from "../shell/background-shell.js"; +import { + createShellSandbox, + type SandboxManagerLike, + type ShellSandbox, +} from "../shell/sandbox-config.js"; +import { runGuardedShell } from "./shell-guard-plugin.js"; +import type { Settings } from "../config/settings.js"; + +// Spawn interception: child_process is mocked for this file so every test can +// observe (or rule out) the actual spawn. The real spawn shim is used for +// spawnSync-style calls that nothing in this file performs. +interface SpawnCall { + command: string; + args?: string[]; + options: Record | undefined; +} + +const spawnCalls: SpawnCall[] = []; + +function fakeChild(): ChildProcess { + // runGuardedShell only needs an eventing child with piped stdout/stderr and + // a pid for the group-kill path; no real process runs behind it. + const events = new EventEmitter(); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = { + pid: 42, + stdout, + stderr, + on: events.on.bind(events), + once: events.once.bind(events), + removeListener: events.removeListener.bind(events), + }; + queueMicrotask(() => { + stdout.emit("data", Buffer.from("ok\n")); + events.emit("close", 0, null); + }); + return child as unknown as ChildProcess; +} + +await withMockedModule("node:child_process", (real) => ({ + ...real, + // spawn's two call forms (`spawn(cmd, opts)` here; `spawn(cmd, args, opts)` + // for argv spawn sites) — record both as (command, args, options). + spawn: ( + command: string, + argsOrOptions: string[] | Record | undefined, + maybeOptions: Record | undefined, + ) => { + const isArgv = Array.isArray(argsOrOptions); + spawnCalls.push({ + command, + ...(isArgv ? { args: argsOrOptions } : {}), + options: (isArgv ? maybeOptions : argsOrOptions) as + | Record + | undefined, + }); + return fakeChild(); + }, +})); + +const cwd = tmpdir(); + +function settingsWithSandbox( + enabled: boolean, + allowedHosts?: string[], +): Settings { + return { + providers: {}, + ...(enabled || allowedHosts !== undefined + ? { + shell: { + sandbox: { + ...(enabled ? { enabled: true } : {}), + ...(allowedHosts !== undefined + ? { network: { allowedHosts } } + : {}), + }, + }, + } + : {}), + }; +} + +function fakeManager( + overrides: Partial = {}, +): SandboxManagerLike & { + initCalls: number; + wraps: string[]; + argvWraps: string[]; + ask: (host: string, port?: number) => Promise; +} { + const state = { + initCalls: 0, + wraps: [] as string[], + argvWraps: [] as string[], + ask: async (_host: string, _port?: number): Promise => true, + }; + return { + get initCalls() { + return state.initCalls; + }, + wraps: state.wraps, + argvWraps: state.argvWraps, + get ask() { + return state.ask; + }, + initialize: async (_config, askCallback) => { + state.initCalls += 1; + if (askCallback !== undefined) + state.ask = async (host, port) => askCallback({ host, port }); + }, + wrapWithSandbox: async (command: string) => { + state.wraps.push(command); + if (overrides.wrapWithSandbox !== undefined) + return overrides.wrapWithSandbox(command); + return `srt ${command}`; + }, + wrapWithSandboxArgv: async (command: string) => { + state.argvWraps.push(command); + if (overrides.wrapWithSandboxArgv !== undefined) + return overrides.wrapWithSandboxArgv(command); + return { argv: ["/bin/sh", "-c", command], env: {} }; + }, + isSupportedPlatform: () => overrides.isSupportedPlatform?.() ?? true, + reset: async () => { + await overrides.reset?.(); + }, + }; +} + +/** createShellSandbox returns undefined when disabled; these tests require it enabled. */ +function mustSandbox(options: { + sessionRoot: string; + settings: Settings; + gate: ReturnType; + manager: SandboxManagerLike; +}): ShellSandbox { + const sandbox = createShellSandbox(options); + if (sandbox === undefined) throw new Error("expected sandbox to be enabled"); + return sandbox; +} + +// createShellSandbox resolves gate.evaluate; a stub gate records the routed ask. +function stubGate(allowed: boolean) { + const calls: { name: string; url: string }[] = []; + return { + calls, + evaluate: async (call: { + name: string; + arguments: Record; + }) => { + calls.push({ + name: call.name, + url: String(call.arguments["url"] ?? ""), + }); + return allowed + ? { allowed: true as const } + : { allowed: false as const, reason: "denied" }; + }, + }; +} + +describe("shell sandbox: runGuardedShell wrap seam", () => { + test("disabled: the spawn path is untouched — same command string, same options", async () => { + spawnCalls.length = 0; + const noopController = new AbortController(); + const result = await runGuardedShell( + { + command: "echo passthrough-check", + cwd, + env: { FOO: "bar" }, + }, + noopController.signal, + ); + expect(result.exitCode).toBe(0); + expect(spawnCalls).toHaveLength(1); + const call = spawnCalls[0]; + if (call === undefined) throw new Error("no spawn recorded"); + // Command text is byte-identical; options keep the pre-sandbox shape + // (checked field-wise so the assertion does not depend on process.env). + expect(call.command).toBe("echo passthrough-check"); + expect(call.args).toBeUndefined(); + const options = call.options ?? {}; + expect(options).toEqual({ + shell: true, + stdio: ["ignore", "pipe", "pipe"], + cwd, + detached: process.platform !== "win32", + env: { ...process.env, FOO: "bar" }, + }); + }); + + test("enabled but the sandbox cannot wrap: fail closed, never raw spawn", async () => { + spawnCalls.length = 0; + const manager = fakeManager({ + isSupportedPlatform: () => false, + }); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true), + gate: stubGate(true), + manager, + }); + expect(sandbox).toBeDefined(); + const noopController = new AbortController(); + const result = await runGuardedShell( + { command: "echo should-not-run", cwd, sandbox }, + noopController.signal, + ).then( + (ok) => ok, + (err: unknown) => err, + ); + expect(result).toBeInstanceOf(Error); + const message = result instanceof Error ? result.message : String(result); + expect(message).toContain("shell sandbox is enabled but unavailable"); + expect(message).toContain("The command was not run"); + // The raw command must never reach spawn — the whole point of fail closed. + expect(spawnCalls).toHaveLength(0); + }); + + test("enabled: spawn receives the wrapped command", async () => { + spawnCalls.length = 0; + const manager = fakeManager(); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true), + gate: stubGate(true), + manager, + }); + const noopController = new AbortController(); + const result = await runGuardedShell( + { command: "echo wrapped-check", cwd, sandbox }, + noopController.signal, + ); + expect(result.exitCode).toBe(0); + expect(manager.initCalls).toBe(1); + const call = spawnCalls[0]; + if (call === undefined) throw new Error("no spawn recorded"); + expect(call.command).toBe("srt echo wrapped-check"); + }); + + test("enabled: the sandbox initializes once across commands", async () => { + spawnCalls.length = 0; + const manager = fakeManager(); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true), + gate: stubGate(true), + manager, + }); + const signal = new AbortController().signal; + await runGuardedShell({ command: "echo one", cwd, sandbox }, signal); + await runGuardedShell({ command: "echo two", cwd, sandbox }, signal); + expect(manager.initCalls).toBe(1); + expect(manager.wraps.length).toBe(2); + }); + + test("network ask outside the allowlist routes through the permission gate", async () => { + const gate = stubGate(true); + const manager = fakeManager(); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true, ["api.example.com"]), + gate, + manager, + }); + const signal = new AbortController().signal; + await runGuardedShell({ command: "echo probe", cwd, sandbox }, signal); + const allowed = await manager.ask("unmatched.example.net", 443); + expect(allowed).toBe(true); + expect(gate.calls).toEqual([ + { + name: "web_fetch", + url: "https://unmatched.example.net:443", + }, + ]); + }); +}); + +describe("shell sandbox: background shell registry seam", () => { + test("without a sandbox handle: start is unchanged — raw command spawns", async () => { + spawnCalls.length = 0; + const registry = createBackgroundShellRegistry(); + const started = await registry.start({ command: "echo raw-bg", cwd }); + expect("error" in started).toBe(false); + const call = spawnCalls[0]; + if (call === undefined) throw new Error("no spawn recorded"); + expect(call.command).toBe("echo raw-bg"); + registry.disposeAll(); + }); + + test("sandbox wrap failure returns the fail-closed error and spawns nothing", async () => { + spawnCalls.length = 0; + const manager = fakeManager({ + isSupportedPlatform: () => false, + }); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true), + gate: stubGate(true), + manager, + }); + const registry = createBackgroundShellRegistry({ sandbox }); + const started = await registry.start({ command: "echo denied-bg", cwd }); + expect("error" in started).toBe(true); + if (!("error" in started)) throw new Error("expected error result"); + expect(started.error).toContain("shell sandbox is enabled but unavailable"); + expect(spawnCalls).toHaveLength(0); + }); + + test("sandbox wrap success spawns the wrapped command", async () => { + spawnCalls.length = 0; + const manager = fakeManager(); + const sandbox = mustSandbox({ + sessionRoot: cwd, + settings: settingsWithSandbox(true), + gate: stubGate(true), + manager, + }); + const registry = createBackgroundShellRegistry({ sandbox }); + const started = await registry.start({ command: "echo wrapped-bg", cwd }); + expect("error" in started).toBe(false); + const call = spawnCalls[0]; + if (call === undefined) throw new Error("no spawn recorded"); + expect(call.command).toBe("srt echo wrapped-bg"); + expect(manager.initCalls).toBe(1); + registry.disposeAll(); + }); +}); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 38184a3b5..23cf2446b 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -110,6 +110,7 @@ import { type LifecycleHookEvent, type LifecycleHookManager, } from "./hooks.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; import { createRunSink, type RunSink } from "./run-sink.js"; import { createCycleTextRecorder, @@ -840,6 +841,9 @@ export interface SessionLifecycleWiring { ) => void; hookEnabled?: Record | undefined; onHookEvent?: ((event: LifecycleHookEvent) => void) | undefined; + // Live getter for the session OS-sandbox handle: wired sessions wrap every + // lifecycle hook spawn into the sandbox; unwired sessions raw-spawn. + getSandbox?: (() => ShellSandbox | undefined) | undefined; resolveContextDir: () => string; } @@ -863,6 +867,9 @@ export async function assembleSessionLifecycle( hooks: await discoverLifecycleHooks(hookDirectories(wiring.cwd)), initialEnabled: wiring.hookEnabled, onEvent: wiring.onHookEvent, + ...(wiring.getSandbox !== undefined + ? { getSandbox: wiring.getSandbox } + : {}), }); const turnObserver = createTurnObserver({ telemetry: wiring.getTelemetry, diff --git a/src/session/hooks-sandbox.test.ts b/src/session/hooks-sandbox.test.ts new file mode 100644 index 000000000..bf484a9f6 --- /dev/null +++ b/src/session/hooks-sandbox.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createLifecycleHookManager, createRunSummary } from "./hooks.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; +import type { RunSummary } from "./hooks.js"; + +// Regression for the warden's S1: lifecycle hooks are discovered under the +// per-session (sandbox-writable) .corbits/hooks directory, so a sandboxed +// session that raw-triggered its hooks would hand an attacker-planted file a +// full-privilege host spawn. With a sandbox handle wired, both hook shapes +// (["sh", path, kind] and ["bun", runner]) must wrap through the sandbox and +// fail closed when it cannot wrap. + +function usage() { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + } as const; +} + +function summary(): RunSummary { + return createRunSummary({ + task: "x", + status: "done", + startedAt: 0, + finishedAt: 1, + turnsUsed: 0, + tokenUsage: usage(), + turns: [], + toolCallCount: 0, + }); +} + +/** Records argv-form wraps; the stub argv just drains stdin and exits 0. */ +function recordingSandbox(): { + sandbox: ShellSandbox; + argvWraps: { command: string; args: string[] }[]; + failNextWrap: { value: boolean }; +} { + const argvWraps: { command: string; args: string[] }[] = []; + const failNextWrap = { value: false }; + return { + argvWraps, + failNextWrap, + sandbox: { + wrapShellCommand: async () => { + throw new Error("hooks never use the shell-string wrap"); + }, + wrapExecArgv: async (command: string, args: readonly string[]) => { + if (failNextWrap.value) { + throw new Error("sandbox runtime is unavailable"); + } + argvWraps.push({ command, args: [...args] }); + // The stub does not matter to the assertion — what the hook would + // have raw-spawned is recorded; this executes and exits 0. + return { argv: ["/bin/sh", "-c", "cat > /dev/null"] }; + }, + dispose: async () => undefined, + }, + }; +} + +const plantedShellHook = String.raw`# shell hook: drains payload (planted by the test) +cat > /dev/null +`; +const plantedTsHook = String.raw`export function postRun() {} +`; + +describe("lifecycle hooks spawn under the session sandbox", () => { + test("planted shell + typescript hooks wrap through the sandbox argv form", async () => { + const root = await mkdtemp(join(tmpdir(), "sandbox-hooks-")); + const hooksDir = join(root, ".corbits", "hooks"); + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "planted.sh"), plantedShellHook); + const tsHookPath = join(hooksDir, "planted.ts"); + await writeFile(tsHookPath, plantedTsHook); + + const { sandbox, argvWraps } = recordingSandbox(); + const manager = createLifecycleHookManager({ + hooks: [ + { + id: join(hooksDir, "planted.sh"), + name: "planted.sh", + type: "shell", + path: join(hooksDir, "planted.sh"), + }, + { + id: tsHookPath, + name: "planted.ts", + type: "typescript", + path: tsHookPath, + }, + ], + getSandbox: () => sandbox, + }); + await manager.dispatchPostRun(summary()); + + // Exactly two wraps, one per hook shape, both carrying the planted path. + expect(argvWraps).toHaveLength(2); + expect(argvWraps[0]).toEqual({ + command: "sh", + args: [join(hooksDir, "planted.sh"), "postRun"], + }); + expect(argvWraps[1]?.command).toBe("bun"); + const runnerArg = argvWraps[1]?.args[0] ?? ""; + expect(runnerArg.endsWith(".ts")).toBe(true); + expect(runnerArg).toContain("hook-runners"); + }); + + test("a sandbox wrap failure surfaces fail-closed on the hook status", async () => { + const root = await mkdtemp(join(tmpdir(), "sandbox-hooks-")); + const hookPath = join(root, "origin.sh"); + await writeFile(hookPath, "true\n"); + const { sandbox, argvWraps, failNextWrap } = recordingSandbox(); + failNextWrap.value = true; + const manager = createLifecycleHookManager({ + hooks: [ + { id: hookPath, name: "origin.sh", type: "shell", path: hookPath }, + ], + getSandbox: () => sandbox, + }); + await manager.dispatchPostRun(summary()); + expect(argvWraps).toHaveLength(0); + const status = manager.getStatuses()[0]; + // The wrap failure surfaces on the hook status (runHook records it) — the + // prefixed fail-closed shape is asserted in sandbox-config.test.ts via + // createShellSandbox's wrapper; here the raw cause proves the hook never + // rode the raw spawn. + expect(status?.lastExitStatus?.stderr).toContain( + "sandbox runtime is unavailable", + ); + }); +}); diff --git a/src/session/hooks.ts b/src/session/hooks.ts index 9106da956..ac7e8cd5c 100644 --- a/src/session/hooks.ts +++ b/src/session/hooks.ts @@ -16,6 +16,7 @@ import type { import { onTurnBoundary } from "../agent/reactor-events.js"; import { COMMAND_NAME, SETTINGS_DIR_NAME } from "../branding.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; export type HookKind = "postTurn" | "postRun"; @@ -318,6 +319,10 @@ export function createLifecycleHookManager(args: { // Persisted enable/disable state, keyed by hook id. A hook absent here // starts enabled, matching discovery's default before any state was saved. initialEnabled?: Record | undefined; + // Live session OS-sandbox handle getter. Wired: every hook spawn wraps into + // the sandbox (fail closed when unavailable). Unwired (tests, non-sandbox + // sessions): the direct Bun.spawn behavior is preserved. + getSandbox?: (() => ShellSandbox | undefined) | undefined; }): LifecycleHookManager { const onEvent = args.onEvent ?? (() => undefined); const logError = args.logError ?? (() => undefined); @@ -351,7 +356,7 @@ export function createLifecycleHookManager(args: { payload: TurnContext | RunSummary, ): Promise { updateStatus(status.id, { lastFiredAt: Date.now(), lastKind: kind }); - return runLifecycleHook(status, kind, payload).then( + return runLifecycleHook(status, kind, payload, args.getSandbox).then( (exitStatus) => updateStatus(status.id, { lastExitStatus: exitStatus }), (err: unknown) => { const message = err instanceof Error ? err.message : String(err); @@ -395,11 +400,23 @@ async function runLifecycleHook( hook: LifecycleHook, kind: HookKind, payload: TurnContext | RunSummary, + getSandbox?: () => ShellSandbox | undefined, ): Promise { - const command = + let command = hook.type === "typescript" ? await createTypeScriptHookCommand(hook, kind) : hookCommand(hook, kind); + // Sandbox wrap: hooks discovered under the (writable) session root are + // model-reachable, so a sandboxed session must never raw-spawn its hooks — + // both the ["sh", path, kind] and ["bun", runner] shapes wrap through the + // sandbox's argv form. Unwired sessions keep the direct spawn. + const sandbox = (getSandbox ?? (() => undefined))(); + if (sandbox !== undefined) { + const [head, ...hookArgs] = command; + if (head === undefined) throw new Error("lifecycle hook command is empty"); + const { argv } = await sandbox.wrapExecArgv(head, hookArgs); + command = [...argv]; + } const proc = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", diff --git a/src/shell/background-shell.test.ts b/src/shell/background-shell.test.ts index 36efeadd3..39791f931 100644 --- a/src/shell/background-shell.test.ts +++ b/src/shell/background-shell.test.ts @@ -26,7 +26,7 @@ describe("background shell registry", () => { const registry = createBackgroundShellRegistry({ onExit: recorder.onExit, }); - const started = registry.start({ + const started = await registry.start({ command: "sleep 0.1; echo done", cwd: tmpCwd, }); @@ -44,7 +44,7 @@ describe("background shell registry", () => { const registry = createBackgroundShellRegistry({ onExit: recorder.onExit, }); - const started = registry.start({ + const started = await registry.start({ command: "echo early; sleep 60", cwd: tmpCwd, timeoutMs: 150, @@ -63,7 +63,7 @@ describe("background shell registry", () => { const registry = createBackgroundShellRegistry({ onExit: recorder.onExit, }); - const started = registry.start({ + const started = await registry.start({ command: `bash -c 'TAG=${token} sleep 600 & TAG=${token} exec sleep 600'`, cwd: tmpCwd, }); @@ -81,10 +81,13 @@ describe("background shell registry", () => { test("running cap fails closed with an error instead of spawning", async () => { const registry = createBackgroundShellRegistry(); for (let i = 0; i < MAX_RUNNING_BACKGROUND_SHELLS; i++) { - const started = registry.start({ command: "sleep 30", cwd: tmpCwd }); + const started = await registry.start({ + command: "sleep 30", + cwd: tmpCwd, + }); expect("error" in started).toBe(false); } - const over = registry.start({ command: "sleep 30", cwd: tmpCwd }); + const over = await registry.start({ command: "sleep 30", cwd: tmpCwd }); expect("error" in over).toBe(true); registry.disposeAll(); }); @@ -95,7 +98,7 @@ describe("background shell registry", () => { const registry = createBackgroundShellRegistry({ onExit: (exit) => exits.push(exit), }); - const started = registry.start({ + const started = await registry.start({ command: `sleep 600 # ${token}`, cwd: tmpCwd, }); @@ -115,7 +118,7 @@ describe("background shell registry", () => { }); // Grandchild inherits the piped stdout so Node's 'close' waits on it; the // shell exits immediately, so delivery must not require close. - const started = registry.start({ + const started = await registry.start({ command: `bash -c 'exec -a ${token} sleep 600 & exit 0'`, cwd: tmpCwd, }); diff --git a/src/shell/background-shell.ts b/src/shell/background-shell.ts index 6c3d793b7..0a932d42d 100644 --- a/src/shell/background-shell.ts +++ b/src/shell/background-shell.ts @@ -4,6 +4,7 @@ import { BoundedShellOutput, MAX_SHELL_OUTPUT_BYTES, } from "../plugins/shell-guard-plugin.js"; +import { wrapWithShellSandbox, type ShellSandbox } from "./sandbox-config.js"; // Background run_shell: start returns a handle at once and the process keeps // running past the end of the turn, so queued steers land while builds and dev @@ -58,16 +59,25 @@ export interface BackgroundShellExit { } export interface BackgroundShellRegistry { - start: (args: StartBackgroundShellArgs) => { id: string } | { error: string }; + // Async only because the sandbox wrap is async: a sandboxed start must + // refuse to spawn when wrapping fails, which is unknowable synchronously. + start: ( + args: StartBackgroundShellArgs, + ) => Promise<{ id: string } | { error: string }>; cancel: (id: string) => boolean; disposeAll: () => void; runningCount: () => number; } export function createBackgroundShellRegistry( - options: { onExit?: (exit: BackgroundShellExit) => void } = {}, + options: { + onExit?: (exit: BackgroundShellExit) => void; + // Session sandbox wrapping for spawned commands; omitted = raw spawn. + sandbox?: ShellSandbox; + } = {}, ): BackgroundShellRegistry { const { onExit } = options; + const sandbox = options.sandbox; const running = new Map(); const record = @@ -86,21 +96,33 @@ export function createBackgroundShellRegistry( onExit?.(exit); }; - const start = ( + const start = async ( args: StartBackgroundShellArgs, - ): { id: string } | { error: string } => { + ): Promise<{ id: string } | { error: string }> => { if (running.size >= MAX_RUNNING_BACKGROUND_SHELLS) { return { error: `background shell limit reached (${MAX_RUNNING_BACKGROUND_SHELLS} running); stop one first`, }; } + // Fail closed before any bookkeeping: an unwrappable command spawns + // nothing and reports the sandbox error as the start failure. + let command = args.command; + if (sandbox !== undefined) { + try { + command = await wrapWithShellSandbox(sandbox, args.command); + } catch (err) { + return { + error: err instanceof Error ? err.message : String(err), + }; + } + } const id = randomUUID(); const collector = new BoundedShellOutput( args.maxOutputBytes ?? MAX_SHELL_OUTPUT_BYTES, ); // detached so the shell leads a process group and cancel/timeout can // SIGKILL the whole tree. - const child = spawn(args.command, { + const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"], cwd: args.cwd, diff --git a/src/shell/sandbox-config.test.ts b/src/shell/sandbox-config.test.ts new file mode 100644 index 000000000..be4d8c201 --- /dev/null +++ b/src/shell/sandbox-config.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, test } from "bun:test"; +import { withMockedModuleDuring } from "../../testkit/mock-module.js"; +import { sandboxFromSettings } from "../config/settings.js"; +import { + buildSandboxRuntimeConfig, + createShellSandbox, + quotePosixArgv, + type SandboxManagerLike, +} from "./sandbox-config.js"; +import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; +import type { Settings } from "../config/settings.js"; +import type { ShellSandbox } from "./sandbox-config.js"; + +const tmpCwd = process.cwd(); + +function sandboxSettings(enabled: boolean, allowedHosts?: string[]): Settings { + return { + providers: {}, + ...(enabled || allowedHosts !== undefined + ? { + shell: { + sandbox: { + ...(enabled ? { enabled: true } : {}), + ...(allowedHosts !== undefined + ? { network: { allowedHosts } } + : {}), + }, + }, + } + : {}), + }; +} + +// Minimal glob matcher for the denyRead table's own grammar: `**/` depth +// prefixes, a trailing `/**`, and `*` inside a name. Magic splitter tokens +// keep the widened replacements out of the later "any other *" rewrite. +// Enough to assert coverage of representative sensitive paths; anything +// fancier belongs in srt itself. +function globCovers(glob: string, path: string): boolean { + const DIR = "[[DIR]]"; + const TAIL = "[[TAIL]]"; + const source = glob + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*\//g, DIR) + .replace(/\/\*\*/g, TAIL) + .replace(/\*/g, "[^/]*") + .split(DIR) + .join("(?:[^/]*/)*") + .split(TAIL) + .join("(?:/.*)"); + return new RegExp(`^${source}$`).test(path); +} + +// A denyRead entry covers `path` when a glob or literal entry matches. +function denyCovers( + denyRead: readonly (string | { path: string; literal: true })[], + path: string, +): boolean { + return denyRead.some((entry) => + typeof entry === "string" ? globCovers(entry, path) : entry.path === path, + ); +} + +function fakeGate() { + const asks: { name: string; url: string }[] = []; + return { + asks, + evaluate: async (call: { + name: string; + arguments: Record; + }) => { + asks.push({ name: call.name, url: String(call.arguments["url"] ?? "") }); + return { allowed: true as const }; + }, + }; +} + +/** createShellSandbox returns undefined when disabled; these tests require it enabled. */ +function mustSandbox(options: { + sessionRoot: string; + settings: Settings; + gate: ReturnType; + manager: SandboxManagerLike; + extraDeniedPaths?: readonly string[]; +}): ShellSandbox { + const sandbox = createShellSandbox(options); + if (sandbox === undefined) throw new Error("expected sandbox to be enabled"); + return sandbox; +} + +function fakeManager( + overrides: Partial = {}, +): SandboxManagerLike { + // getConfig mirrors the real singleton's contract: defined between + // initialize() and reset(), undefined otherwise — a torn-down detectable + // manager without extra plumbing. + let activeConfig: SandboxRuntimeConfig | undefined = undefined; + return { + initialize: async (config, ask) => { + activeConfig = config; + await overrides.initialize?.(config, ask); + }, + getConfig: () => + overrides.getConfig !== undefined ? overrides.getConfig() : activeConfig, + wrapWithSandbox: async (command) => + overrides.wrapWithSandbox !== undefined + ? overrides.wrapWithSandbox(command) + : `wrapped ${command}`, + wrapWithSandboxArgv: async (command) => + overrides.wrapWithSandboxArgv !== undefined + ? overrides.wrapWithSandboxArgv(command) + : { argv: ["/bin/sh", "-c", command], env: {} }, + isSupportedPlatform: () => overrides.isSupportedPlatform?.() ?? true, + reset: async () => { + activeConfig = undefined; + await overrides.reset?.(); + }, + }; +} + +describe("sandbox settings resolution", () => { + test("default off: unset settings resolve to enabled=false", () => { + expect(sandboxFromSettings(undefined)).toEqual({ enabled: false }); + expect(sandboxFromSettings(null)).toEqual({ enabled: false }); + expect(sandboxFromSettings({ providers: {} })).toEqual({ enabled: false }); + }); + + test("sandbox block resolves the enable flag and the network allowlist", () => { + expect(sandboxFromSettings(sandboxSettings(true))).toEqual({ + enabled: true, + }); + expect( + sandboxFromSettings(sandboxSettings(true, ["api.example.com"])), + ).toEqual({ + enabled: true, + network: { allowedHosts: ["api.example.com"] }, + }); + }); +}); + +describe("buildSandboxRuntimeConfig", () => { + test("workspace, tmpdir, and /tmp/claude are writable; secrets denied", () => { + const config = buildSandboxRuntimeConfig({ sessionRoot: tmpCwd }); + expect(config.network).toEqual({ allowedDomains: [], deniedDomains: [] }); + expect(config.filesystem.allowWrite).toContain(tmpCwd); + expect(config.filesystem.allowWrite).toContain("/tmp/claude"); + expect(config.filesystem.allowWrite).toContain("/private/tmp/claude"); + expect(config.filesystem.denyRead).toContain("**/.env"); + }); + + test("representative sensitive paths are denied; templates stay readable", () => { + const config = buildSandboxRuntimeConfig({ sessionRoot: tmpCwd }); + const denyRead = config.filesystem.denyRead; + const representatives = [ + "/home/u/.env", + "/home/u/.env.local", + "/repo/.envrc", + "/home/u/.dev.vars", + "/home/u/.npmrc", + "/home/u/.netrc", + "/home/u/.git-credentials", + "/home/u/.pgpass", + "/home/u/.ssh/id_rsa", + "/home/u/.ssh/config", + "/home/u/.aws/credentials", + "/home/u/.corbits/settings.json", + "/home/u/.corbits/permissions.json", + "/home/u/.kube/config", + "/home/u/.docker/config.json", + "/home/u/.azure/accessTokens.json", + "/g/.gnupg/pubring.kbx", + "/home/u/.config/gcloud/x.json", + "/x/backup.tfstate", + "/x/key.pem", + "/x/cert.p12", + "/x/cert.pfx", + "/x/server.key", + "/x/cert.p8", + "/x/store.jks", + "/x/store.keystore", + "/x/id_ed25519", + "/home/u/.bash_history", + "/etc/shadow", + "/etc/sudoers", + "/home/u/Library/Keychains/login.keychain-db", + "/users/me/Library/Application Support/x/Cookies", + "/profile/Login Data", + "/profile/cookies.sqlite", + "/profile/logins.json", + "/profile/key4.db", + "/storm/service-account.json", + "/storm/my-service_account-key.json", + "/g/creds/application_default_credentials.json", + "/h/.zsh_history", + "/h/.config/x/fish_history", + ]; + for (const path of representatives) { + expect(denyCovers(denyRead, path)).toBe(true); + } + // The denylist intent excludes env templates — only concrete variants. + expect(denyCovers(denyRead, "/repo/.env.example")).toBe(false); + }); + + test("extraDeniedPaths becomes literal deny entries", () => { + const config = buildSandboxRuntimeConfig({ + sessionRoot: tmpCwd, + extraDeniedPaths: ["/active/settings.json"], + }); + expect(config.filesystem.denyRead).toContainEqual({ + path: "/active/settings.json", + literal: true, + }); + }); + + test("network allowlist and known credential env vars are denied", () => { + const config = buildSandboxRuntimeConfig({ + sessionRoot: tmpCwd, + allowedHosts: ["api.example.com", "*.github.com"], + }); + expect(config.network.allowedDomains).toEqual([ + "api.example.com", + "*.github.com", + ]); + const envVars = config.credentials?.envVars ?? []; + const names = envVars.map((entry) => entry.name); + for (const expected of [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + ]) { + expect(names).toContain(expected); + } + for (const entry of envVars) { + expect(entry.mode).toBe("deny"); + } + }); +}); + +describe("quotePosixArgv", () => { + test("single-quotes entries; embedded quotes survive re-parse", () => { + expect(quotePosixArgv(["node", "-x", "a b"])).toBe("'node' '-x' 'a b'"); + expect(quotePosixArgv(["sh", "-c", "echo 'a b'"])).toBe( + "'sh' '-c' 'echo '\\''a b'\\'''", + ); + expect(quotePosixArgv(["x", ""])).toBe("'x' ''"); + expect(quotePosixArgv([])).toBe(""); + }); +}); + +describe("createShellSandbox", () => { + test("returns undefined when disabled so spawn sites pass through", () => { + expect( + createShellSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(false), + gate: fakeGate(), + manager: fakeManager(), + }), + ).toBeUndefined(); + expect( + createShellSandbox({ + sessionRoot: tmpCwd, + settings: undefined, + gate: fakeGate(), + manager: fakeManager(), + }), + ).toBeUndefined(); + }); + + test("initializes once with the merged config and wraps via the manager", async () => { + const configSeen: unknown[] = []; + const manager = fakeManager({ + initialize: async (config) => { + configSeen.push(config); + }, + }); + const sandboxHandle = mustSandbox({ + sessionRoot: tmpCwd, + extraDeniedPaths: ["/active/settings.json"], + settings: sandboxSettings(true, ["api.example.com"]), + gate: fakeGate(), + manager, + }); + const request = sandboxHandle.wrapShellCommand("echo one"); + await request; + expect(configSeen).toHaveLength(1); + const config = configSeen[0] as { + network: { allowedDomains: string[] }; + filesystem: { denyRead: unknown[] }; + }; + expect(config.network.allowedDomains).toEqual(["api.example.com"]); + expect(config.filesystem.denyRead).toContainEqual({ + path: "/active/settings.json", + literal: true, + }); + // Second wrap must not re-initialize. + await sandboxHandle.wrapShellCommand("echo two"); + // initCalls is not directly observable on the fake; dedupe via configSeen. + expect(configSeen).toHaveLength(1); + }); + + test("wrapExecArgv passes a POSIX-quoted command string to the argv wrap", async () => { + const argvWrapArgs: string[] = []; + const manager = fakeManager({ + wrapWithSandboxArgv: async (command) => { + argvWrapArgs.push(command); + return { argv: ["/bin/bash", "-c", command], env: {} }; + }, + }); + const sandbox = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + const { argv } = await sandbox.wrapExecArgv("node", ["-x", "a b"]); + expect(argv).toEqual([ + "/bin/bash", + "-c", + quotePosixArgv(["node", "-x", "a b"]), + ]); + }); + + test("a wrap failure surfaces the fail-closed message", async () => { + const manager = fakeManager({ + wrapWithSandbox: async () => { + throw new Error("seatbelt boom"); + }, + }); + const sandbox = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await expect(sandbox.wrapShellCommand("echo x")).rejects.toThrow( + "shell sandbox is enabled but unavailable: seatbelt boom", + ); + }); + + test("an unwritable /tmp/claude fails enablement, never a raw wrap", async () => { + const outcome = await withMockedModuleDuring( + "node:fs", + (real: typeof import("node:fs")) => ({ + ...real, + mkdirSync: () => { + throw new Error("read-only volume"); + }, + }), + async () => { + const manager = fakeManager(); + const sandbox = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await expect(sandbox.wrapShellCommand("echo x")).rejects.toThrow( + "cannot create sandbox scratch dir /tmp/claude", + ); + return manager; + }, + ); + // The failing mkdir aborts before initialize is ever reached. + expect(outcome).toBeDefined(); + }); + + test("dispose resets the sandbox manager and swallows teardown errors", async () => { + let resetCalls = 0; + const manager = fakeManager({ + reset: async () => { + resetCalls += 1; + throw new Error("already gone"); + }, + }); + const sandbox = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await sandbox.dispose(); + expect(resetCalls).toBe(1); + }); + + test("a second composition with differing config fails loud, not silently inherited", () => { + const manager = fakeManager(); + mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true, ["api.example.com"]), + gate: fakeGate(), + manager, + }); + expect(() => + createShellSandbox({ + sessionRoot: "/other/workspace", + settings: sandboxSettings(true, ["api.example.com"]), + gate: fakeGate(), + manager, + }), + ).toThrow( + "shell sandbox is already active in this process with a different configuration", + ); + }); + + test("same-composition handles share the manager via refcount", async () => { + const inits: unknown[] = []; + const manager = fakeManager({ + initialize: async (config) => { + inits.push(config); + }, + }); + const first = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + const second = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await second.wrapShellCommand("echo shared"); + expect(inits).toHaveLength(1); + // First owner disposes: the shared manager stays live for the second. + await first.dispose(); + await second.wrapShellCommand("echo still-shared"); + expect(inits).toHaveLength(1); + // Last owner disposes: the singleton resets and a later wrap on a + // disposed handle fails closed. + await second.dispose(); + await expect(second.wrapShellCommand("echo after-dispose")).rejects.toThrow( + "already tore down its sandbox", + ); + // A fresh same-composition handle re-initializes cleanly (no config-less + // inherit): the init state was dropped, not kept. + const reborn = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await reborn.wrapShellCommand("echo reborn"); + expect(inits).toHaveLength(2); + }); + + test("wrap after the manager lost its config fails closed", async () => { + const manager = fakeManager({ + getConfig: () => undefined, + }); + const sandbox = mustSandbox({ + sessionRoot: tmpCwd, + settings: sandboxSettings(true), + gate: fakeGate(), + manager, + }); + await expect(sandbox.wrapShellCommand("echo ghost")).rejects.toThrow( + "the sandbox runtime lost its configuration", + ); + }); +}); diff --git a/src/shell/sandbox-config.ts b/src/shell/sandbox-config.ts new file mode 100644 index 000000000..f6d5634dd --- /dev/null +++ b/src/shell/sandbox-config.ts @@ -0,0 +1,447 @@ +import { mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { + SandboxManager, + SandboxRuntimeConfigSchema, + getDefaultWritePaths, + type SandboxAskCallback, + type SandboxRuntimeConfig, +} from "@anthropic-ai/sandbox-runtime"; +import { sandboxFromSettings, type Settings } from "../config/settings.js"; +import type { ToolCall } from "@intx/types/runtime"; +import type { PermissionGate } from "../permission/gate.js"; + +// Opt-in OS sandbox over shell and stdio MCP spawns via +// @anthropic-ai/sandbox-runtime (srt). Off by default: with +// shell.sandbox.enabled unset the spawn paths run byte-identically — no +// SandboxManager state, no wrap, no ask. + +/** + * Structural half of srt's SandboxManager this module needs. Tests inject a + * fake; production uses the real singleton. + */ +export interface SandboxManagerLike { + initialize( + config: SandboxRuntimeConfig, + askCallback?: SandboxAskCallback, + ): Promise; + wrapWithSandbox(command: string): Promise; + wrapWithSandboxArgv( + command: string, + ): Promise<{ argv: string[]; env: NodeJS.ProcessEnv }>; + isSupportedPlatform(): boolean; + reset(): Promise; + // srt exposes the live config; used to detect a torn-down manager when the + // refcounted teardown ran underneath a still-live handle. + getConfig?(): SandboxRuntimeConfig | undefined; +} + +/** Wrap seam consumed by the shell-guard, background-shell, and MCP stdio spawn sites. */ +export interface ShellSandbox { + /** Wrap a shell:true command string; the caller spawns the result unchanged. */ + wrapShellCommand: (command: string) => Promise; + /** + * Wrap a direct (no shell) argv spawn: the return is the full argv to spawn + * with shell:false, wrapped so the original argv runs inside the sandbox. + */ + wrapExecArgv: ( + command: string, + args: readonly string[], + ) => Promise<{ argv: readonly string[] }>; + /** Teardown of srt proxies/state; errors are suppressed (session is dying). */ + dispose: () => Promise; +} + +/** + * Sensitive paths as srt denyRead entries, mirroring secret-guard-plugin's + * SENSITIVE_PATTERNS (filename classes expressed as globs). Reads are allowed + * by default in srt, so a denyRead that drifts narrower than the denylist + * only relaxes the OS layer, never the secret-guard policy check — keep the + * two lists in step when a sensitive class is added. The active --config + * settings path and other runtime denies arrive as extraDeniedPaths literal + * entries. `*.tfstate` etc pattern via srt's glob, `(.env)` variants limit to + * the concrete secret variants so .env.example stays readable. + */ +const SENSITIVE_DENY_READ: readonly string[] = [ + // .env variants, not templates — mirrors secret-guard. + "**/.env", + "**/.env.local", + "**/.env.development", + "**/.env.production", + "**/.env.staging", + "**/.env.dev", + "**/.envrc", + "**/.flaskenv", + "**/.dev.vars", + "**/.npmrc", + "**/.netrc", + "**/.git-credentials", + "**/.pgpass", + "**/.htpasswd", + // Corbits Code's own credential-bearing settings. + "**/.corbits/settings.json", + "**/.corbits/permissions.json", + "**/.ssh/**", + "**/.aws/**", + "**/.gnupg/**", + "**/.config/gcloud/**", + "**/.kube/config", + "**/.docker/config.json", + "**/.azure/accessTokens.json", + "**/.azure/azureProfile.json", + "**/id_rsa", + "**/id_dsa", + "**/id_ecdsa", + "**/id_ed25519", + "**/*.pem", + "**/*.p12", + "**/*.pfx", + "**/*.key", + "**/*.p8", + "**/*.jks", + "**/*.keystore", + "**/*.ppk", + "**/*.tfstate", + "**/*.tfstate.backup", + "**/application_default_credentials.json", + "**/*service*account*.json", + "**/.bash_history", + "**/.zsh_history", + "**/.sh_history", + "**/fish_history", + // System account/privilege files. + "/etc/shadow", + "/etc/sudoers", + // macOS Keychains, browser cookie stores, saved logins. + "**/Library/Keychains/**", + "**/*.keychain", + "**/*.keychain-db", + "**/Cookies", + "**/Login Data", + "**/cookies.sqlite", + "**/logins.json", + "**/key4.db", +]; + +/** + * Known credential env vars, unset inside the sandbox (srt credentials.envVars + * mode "deny"). Static names so the deny cannot be widened by whatever + * happens to sit in the environment; the classes mirror + * tool-result-secret-scrub's SECRET/TOKEN/API_KEY classification. + */ +const CREDENTIAL_ENV_VARS: readonly string[] = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "AZURE_OPENAI_API_KEY", + "XAI_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "COHERE_API_KEY", + "TOGETHER_API_KEY", + "FIREWORKS_API_KEY", + "HF_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "CORBITS_WEB_SEARCH_API_KEY", + "EXA_API_KEY", +]; + +/** claude-sandbox convention scratch dir srt grants write access to. */ +const CLAUDE_TMP_DIR = "/tmp/claude"; + +export interface SandboxConfigInput { + sessionRoot: string; + /** Runtime denylist entries (e.g. the active --config settings path). */ + extraDeniedPaths?: readonly string[]; + /** Settings.shell.sandbox.network — the allowlist; everything else asks. */ + allowedHosts?: readonly string[]; +} + +/** + * Pure sandbox-runtime config builder: workspace-and-tmp-only writes, srt's + * own convenience write paths, the secret denylist as denyRead globs, and the + * credential env deny. Exported for tests; parses against srt's own schema so + * a drift fails loudly instead of initializing with a weaker config. + */ +export function buildSandboxRuntimeConfig( + input: SandboxConfigInput, +): SandboxRuntimeConfig { + const denyRead: (string | { path: string; literal: true })[] = [ + ...SENSITIVE_DENY_READ, + ...(input.extraDeniedPaths ?? []).map((path) => ({ + path, + literal: true as const, + })), + ]; + // Writes: the session workspace, the OS tmpdir, plus srt's own defaults + // (stdio devices and /tmp/claude, which we pre-create at init below). + const allowWrite = [ + input.sessionRoot, + tmpdir(), + ...getDefaultWritePaths({ denyRead }), + ]; + // srt routes an unmatched host to the registered ask callback, so an + // operator allowlist of [] is a deny-all-except-approved network, never an + // all-open one. + const config: SandboxRuntimeConfig = { + network: { + allowedDomains: [...(input.allowedHosts ?? [])], + deniedDomains: [], + }, + filesystem: { + denyRead, + denyWrite: [], + allowWrite, + }, + credentials: { + envVars: CREDENTIAL_ENV_VARS.map((name) => ({ name, mode: "deny" })), + }, + }; + return SandboxRuntimeConfigSchema.parse(config); +} + +/** POSIX-single-quote every argv entry so `sh -c` re-derives the exact argv. */ +export function quotePosixArgv(args: readonly string[]): string { + return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" "); +} + +export const SANDBOX_UNAVAILABLE_PREFIX = + "shell sandbox is enabled but unavailable"; + +/** Fail-closed wrap error: the command never ran, never spawns raw. */ +export async function wrapWithShellSandbox( + sandbox: ShellSandbox, + command: string, +): Promise { + try { + return await sandbox.wrapShellCommand(command); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `${SANDBOX_UNAVAILABLE_PREFIX}: ${reason}. The command was not run. ` + + `Set shell.sandbox.enabled=false to disable the sandbox.`, + { cause: err }, + ); + } +} + +// Wired once per session at the composition roots (exec, TUI) from the +// already-resolved global settings. Resolution lives in config/settings.ts +// (sandboxFromSettings), next to shellTimeoutFromSettings. +// +// srt's SandboxManager is a process-global singleton: initialize() installs +// one process-wide config and reset() clears it. A second live composition +// must therefore fail loud on a differing config (it would otherwise +// silently inherit the first), and a teardown must reset the singleton via +// the owner refcount so a remaining owner keeps working. Injected managers +// coordination keys the shared state by the manager object itself, so an +// injected fake can be exercised the same way the real singleton would be. + +interface SandboxRuntimeState { + initPromise: Promise | undefined; + configKey: string | undefined; + ownerCount: number; +} + +// One state per manager object: the real SandboxManager singleton is a key +// like any other, so injected fakes (tests) get the same coordination while +// different fakes stay isolated. +const statesByManager = new WeakMap(); + +function stateFor(manager: SandboxManagerLike): SandboxRuntimeState { + const existing = statesByManager.get(manager); + if (existing !== undefined) return existing; + const state: SandboxRuntimeState = { + initPromise: undefined, + configKey: undefined, + ownerCount: 0, + }; + statesByManager.set(manager, state); + return state; +} + +/** Composition identity: everything the srt config will carry. */ +function sandboxCompositionKey(options: { + sessionRoot: string; + extraDeniedPaths?: readonly string[]; + allowedHosts?: readonly string[]; +}): string { + return JSON.stringify({ + sessionRoot: options.sessionRoot, + extraDeniedPaths: options.extraDeniedPaths ?? null, + allowedHosts: options.allowedHosts ?? null, + }); +} + +export function createShellSandbox(options: { + sessionRoot: string; + extraDeniedPaths?: readonly string[]; + settings: Settings | null | undefined; + gate: Pick; + manager?: SandboxManagerLike; +}): ShellSandbox | undefined { + const { enabled, network } = sandboxFromSettings(options.settings); + if (!enabled) return undefined; + const manager = options.manager ?? (SandboxManager as SandboxManagerLike); + const state = stateFor(manager); + const configKey = sandboxCompositionKey({ + sessionRoot: options.sessionRoot, + ...(options.extraDeniedPaths !== undefined + ? { extraDeniedPaths: options.extraDeniedPaths } + : {}), + ...(network?.allowedHosts !== undefined + ? { allowedHosts: network.allowedHosts } + : {}), + }); + if (state.ownerCount > 0 && state.configKey !== configKey) { + throw new Error( + "shell sandbox is already active in this process with a different " + + "configuration; only one sandboxed composition per process is " + + "supported. Refuse rather than inherit the active sandbox.", + ); + } + if (state.ownerCount === 0) { + state.configKey = configKey; + state.ownerCount = 1; + } else { + state.ownerCount += 1; + } + let disposed = false; + + const ensureReady = (): Promise => { + if (state.initPromise === undefined) { + state.initPromise = (async () => { + if (!manager.isSupportedPlatform()) { + throw new Error("platform is not supported by sandbox-runtime"); + } + // /tmp/claude is embedder-owned scratch within the sandbox's writable + // tmp; a missing dir breaks every sandboxed write into it, so failing + // here fails closed before anything spawns. + try { + mkdirSync(CLAUDE_TMP_DIR, { recursive: true }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `cannot create sandbox scratch dir ${CLAUDE_TMP_DIR}: ${reason}`, + { cause: err }, + ); + } + const config = buildSandboxRuntimeConfig({ + sessionRoot: options.sessionRoot, + ...(options.extraDeniedPaths !== undefined + ? { extraDeniedPaths: options.extraDeniedPaths } + : {}), + ...(network?.allowedHosts !== undefined + ? { allowedHosts: network.allowedHosts } + : {}), + }); + // macOS resolves /tmp under /private/tmp; the canonical spelling is + // what seatbelt checks, so add it next to the /tmp form (a belt for + // getDefaultWritePaths, which embeds the static spellings). + try { + config.filesystem.allowWrite.push(realpathSync(CLAUDE_TMP_DIR)); + } catch (err) { + throw new Error( + `cannot resolve sandbox scratch dir ${CLAUDE_TMP_DIR}`, + { cause: err }, + ); + } + await manager.initialize(config, sandboxAsk); + })(); + } + return state.initPromise; + }; + + // Unmatched hosts ask through the shared permission gate: the same Tier + // machinery (classify → ask/allow) as a web_fetch to that host. Yolo + // auto-allows here (skipPermissions) — the OS sandbox itself never lifts. + const sandboxAsk: SandboxAskCallback = async ({ host, port }) => { + const call: ToolCall = { + id: randomUUID(), + name: "web_fetch", + arguments: { + url: `https://${host}${port !== undefined ? `:${port}` : ""}`, + }, + }; + const verdict = await options.gate.evaluate(call); + return verdict.allowed === true; + }; + + // Fail-closed relatives: an unwrappable runtime (wrap threw), a manager + // whose config was torn down underneath us, or a handle already disposed + // all produce the same shape — the command did not run, nothing raw-spawns. + const unavailable = (err: unknown): Error => { + const reason = err instanceof Error ? err.message : String(err); + return new Error( + `${SANDBOX_UNAVAILABLE_PREFIX}: ${reason}. The command was not run. ` + + `Set shell.sandbox.enabled=false to disable the sandbox.`, + { cause: err }, + ); + }; + + const guardAlive = async (): Promise => { + if (disposed) { + throw new Error( + `${SANDBOX_UNAVAILABLE_PREFIX}: this session already tore down its ` + + `sandbox. The command was not run.`, + ); + } + await ensureReady(); + // Torn-down detection: only meaningful on managers that report their own + // config (the real singleton does); fakes without the getter skip it. + const getConfig = manager.getConfig; + if (getConfig !== undefined && getConfig.call(manager) === undefined) { + throw new Error( + `${SANDBOX_UNAVAILABLE_PREFIX}: the sandbox runtime lost its ` + + `configuration (teardown or re-initialization). The command was ` + + `not run.`, + ); + } + }; + + return { + wrapShellCommand: async (command) => { + await guardAlive(); + try { + return await manager.wrapWithSandbox(command); + } catch (err) { + throw unavailable(err); + } + }, + wrapExecArgv: async (command, args) => { + await guardAlive(); + try { + const { argv } = await manager.wrapWithSandboxArgv( + quotePosixArgv([command, ...args]), + ); + return { argv }; + } catch (err) { + throw unavailable(err); + } + }, + dispose: async () => { + disposed = true; + state.ownerCount -= 1; + if (state.ownerCount > 0) return; + // Last owner out: the srt state (proxies, stamps) is the singleton's to + // reset; drop the init memo so a later same-composition handle can + // re-initialize cleanly instead of wrapping config-less. + try { + await manager.reset(); + } catch { + // Teardown best-effort: the process exit path cleans up the rest. + } + state.initPromise = undefined; + state.configKey = undefined; + state.ownerCount = 0; + }, + }; +} diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index cf8a55df5..9269c8139 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -1452,6 +1452,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { secretGuardExtraDeniedPaths: deps.secretGuardExtraDeniedPaths, } : {}), + ...(deps.sandbox !== undefined ? { sandbox: deps.sandbox } : {}), ...(deps.skillDirs !== undefined ? { skillDirs: deps.skillDirs } : {}), @@ -1631,6 +1632,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { secretGuardExtraDeniedPaths: deps.secretGuardExtraDeniedPaths, } : {}), + ...(deps.sandbox !== undefined ? { sandbox: deps.sandbox } : {}), ...(deps.extraToolPlugins !== undefined ? { extraToolPlugins: deps.extraToolPlugins } : {}), diff --git a/src/subagent/run-persist-close.test.ts b/src/subagent/run-persist-close.test.ts index ea1ecb0cf..19034fde8 100644 --- a/src/subagent/run-persist-close.test.ts +++ b/src/subagent/run-persist-close.test.ts @@ -180,7 +180,7 @@ describe("worker persist reaps leftover registry children", () => { ...delayedReplyAgent(), send: async () => { const captured = defined(registry); - const started = captured.start({ + const started = await captured.start({ // pgrep -f sees only the exec'd sleep; a comment-only token would let waitUntilGone pass. command: `bash -c 'exec -a ${token} sleep 600'`, cwd, diff --git a/src/subagent/run.ts b/src/subagent/run.ts index bb10f40d2..a6d6f27f0 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -751,6 +751,7 @@ async function runSubAgentInner( let backgroundExitSink: ((exit: BackgroundShellExit) => void) | null = null; const backgroundShells = createBackgroundShellRegistry({ onExit: (exit) => backgroundExitSink?.(exit), + ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), }); // A worker whose capability filter drops run_shell has no bash to detach // from; the getter stays live so the filter below can unwire it first. @@ -785,6 +786,7 @@ async function runSubAgentInner( ...(params.secretGuardExtraDeniedPaths !== undefined ? { secretGuardExtraDeniedPaths: params.secretGuardExtraDeniedPaths } : {}), + ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), readFileGuard: { blobReader: sessionBlobReader, canExecuteHostCommands: () => hostCommandsMounted, @@ -1075,6 +1077,7 @@ async function runSubAgentInner( ...(nd.secretGuardExtraDeniedPaths !== undefined ? { secretGuardExtraDeniedPaths: nd.secretGuardExtraDeniedPaths } : {}), + ...(nd.sandbox !== undefined ? { sandbox: nd.sandbox } : {}), ...(nd.skillDirs !== undefined ? { skillDirs: nd.skillDirs } : {}), // Nested shared-cwd lanes reuse this worker's catalog, discovered // (or inherited) for exactly this cwd. diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 31ff1ede9..6484b0c47 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -22,6 +22,7 @@ import type { ForcedStopReason } from "./stop-policy.js"; import type { AdmissionQueue } from "./admission.js"; import type { EscalationAssessment } from "./escalation-policy.js"; import type { SkillSummary } from "../extensions/skills.js"; +import type { ShellSandbox } from "../shell/sandbox-config.js"; export interface SubAgentProvider { providerName: string; @@ -65,6 +66,12 @@ export interface SubAgentSandboxDeps { * dispatch chain. */ secretGuardExtraDeniedPaths?: readonly string[]; + /** + * Parent's OS-sandbox handle: worker run_shell spawns wrap into the same + * sandbox. Shared state (one SandboxManager per process), so the handle is + * passed down, never rebuilt per worker. + */ + sandbox?: ShellSandbox; /** Plugin skill dirs, same list the primary passes to createUseSkillTool. */ skillDirs?: readonly string[]; /** diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index bfc012768..a0324d077 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -17,6 +17,10 @@ import { } from "../../config/settings.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { peekSourceCredentialSecret } from "../../config/source-credentials.js"; +import { + createShellSandbox, + type ShellSandbox, +} from "../../shell/sandbox-config.js"; import { getProcessAdmissionQueue } from "../../subagent/admission.js"; import { createSubAgentSessionStore } from "../../subagent/index.js"; import { @@ -150,10 +154,14 @@ export async function assembleTUISession( v.enabled, ]), ); + // Assigned once the gate exists below (createShellSandbox needs it as the + // sandbox network-ask path); the hooks wiring reads it lazily at fire time. + let sessionSandbox: ShellSandbox | undefined; const { hookManager, runSink, cycleRecorder } = await assembleSessionLifecycle({ cwd: config.cwd, emitter, + getSandbox: () => sessionSandbox, getTelemetry, getSessionId: () => state.sessionId, // The lifecycle starts consuming events well after the exit module wires @@ -324,6 +332,16 @@ export async function assembleTUISession( ); const shellTimeout = shellTimeoutFromSettings(config.settings); + // OS sandbox from settings (default off). The gate is the ask path for + // network hosts outside the allowlist; the handle is undefined when the + // sandbox is disabled, leaving every spawn site untouched. The hooks + // wiring reads this lazily, so assignment here covers post-gate fires. + sessionSandbox = createShellSandbox({ + sessionRoot: config.cwd, + extraDeniedPaths: [config.globalSettingsPath], + settings: config.settings, + gate: permissionGate, + }); // Mutable so Settings → waitForApproval takes effect on the next tool call // without rebuilding the toolset. const liveToolWatchdog: ToolWatchdogConfig = { @@ -359,6 +377,7 @@ export async function assembleTUISession( // model-unreadable/unwritable, like the default settings file. secretGuardExtraDeniedPaths: [config.globalSettingsPath], ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(sessionSandbox !== undefined ? { sandbox: sessionSandbox } : {}), ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } : {}),