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 } : {}),