From 5a85fb8122c51dfe38ec01141bc4eb721fbda005 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Nov 2026 15:48:12 +0000 Subject: [PATCH 1/2] fix(agent): sandbox agent bash and move credentialed steps to host tools (VULN-1413) Pipeline and interviewer agents ran Pi's bash with the host's credentials, env tokens, and network, so a prompt injection could reach RCE and credential theft. - Every role's bash now runs through @anthropic-ai/sandbox-runtime (bubblewrap on Linux, sandbox-exec on macOS): credential paths hidden, env reduced to an allowlist, writes limited to the repo, /tmp and the repo's git dirs (hooks/config read-only), egress limited to registry.npmjs.org, api.workos.com and *.authkit.app. The sandbox fails closed when its dependencies (or Linux seccomp) are missing. - Read/write/edit tools get the same path rules host-side. - The closer's push + `gh pr create` + review comment and the verifier's `ca upload` become fixed host-side tools (create_pull_request, comment_on_pull_request, upload_evidence); prompts updated. - Host-side git push/checkout no longer run repo hooks, which agents can write (e.g. husky's .husky/). - The interviewer session no longer gets Pi's built-in edit/write tools. Co-authored-by: mitch-fultz --- agents/closer.md | 37 ++- agents/verifier.md | 16 +- bun.lock | 63 +++++- package.json | 1 + src/__tests__/orchestrator-session.spec.ts | 1 + src/__tests__/pi-runner.spec.ts | 12 +- src/agent/adapters/pi-adapter.ts | 38 ++-- src/agent/sandbox.ts | 251 +++++++++++++++++++++ src/agent/tools/privileged-tools.ts | 105 +++++++++ src/commands/upload.ts | 4 +- src/dev/run-tests.ts | 1 + src/entry/cli-orchestrator.ts | 7 +- src/interview/session.ts | 10 +- src/package-asset-modules.d.ts | 5 + test/sandbox/bunfig.toml | 2 + test/sandbox/sandbox.spec.ts | 100 ++++++++ test/standalone/pi-runner-unit.spec.ts | 1 + 17 files changed, 588 insertions(+), 66 deletions(-) create mode 100644 src/agent/sandbox.ts create mode 100644 src/agent/tools/privileged-tools.ts create mode 100644 test/sandbox/bunfig.toml create mode 100644 test/sandbox/sandbox.spec.ts diff --git a/agents/closer.md b/agents/closer.md index 7530511..b9e7931 100644 --- a/agents/closer.md +++ b/agents/closer.md @@ -6,7 +6,7 @@ tools: ['Read', 'Bash', 'Glob', 'Grep'] # Closer — PR Creation Agent -Create a pull request with a thorough description based on the task file, progress log, and verification evidence. You are the only agent that runs `gh pr create`. You must verify all evidence gates yourself before attempting to create the PR. +Create a pull request with a thorough description based on the task file, progress log, and verification evidence. You are the only agent that opens PRs, via the `create_pull_request` tool. You must verify all evidence gates yourself before attempting to create the PR. ## Input @@ -62,7 +62,7 @@ fix(scope): or `feat(scope): ...` for features. Keep under 72 characters. -**Body** (use heredoc format for `gh pr create`): +**Body** (pass as the `body` argument to `create_pull_request`): ```markdown ## Summary @@ -106,7 +106,7 @@ Closes # ### 3. Pre-flight -Before running `gh pr create`, verify every requirement. +Before calling `create_pull_request`, verify every requirement. **CRITICAL: Check the task JSON first.** Read the task JSON and confirm the reviewer agent phase shows `"status": "completed"`. If the reviewer never ran, STOP — do not attempt to create the PR. Report the missing reviewer phase in your error output so the orchestrator can dispatch the reviewer. @@ -149,39 +149,31 @@ Before running `gh pr create`, verify every requirement. If any required check fails: - Report exactly what's missing -- Do NOT attempt `gh pr create` +- Do NOT call `create_pull_request` - Set AGENT_RESULT status to `"failed"` with the missing requirement in `"error"` ### 4. Create PR -```bash -gh pr create --title "" --body "$(cat <<'EOF' -<PR body content> -EOF -)" -``` +Your bash is sandboxed with no GitHub access or credentials. Call the `create_pull_request` tool with `title` and `body`; it pushes the current branch and runs `gh pr create` on the host, then returns the PR URL. The body must contain verification keywords (any of: "verif", "tested", "test plan", "what was tested", "how it works"). ### 4.5 Post Review Comments (if findings exist) -If the reviewer produced warnings or info findings (check `.case/<task-slug>/reviewed` for `warnings` and `info` counts), post them as a PR review comment: +If the reviewer produced warnings or info findings (check `.case/<task-slug>/reviewed` for `warnings` and `info` counts), post them by calling the `comment_on_pull_request` tool with this `body` (findings come from the reviewer's progress log entry in the task file): -```bash -# Read findings from the reviewer's progress log entry in the task file -# Format as a comment -gh api repos/{owner}/{repo}/pulls/{pr_number}/reviews \ - --method POST \ - -f body="## Code Review Findings +```markdown +## Code Review Findings ### Warnings + {list of warning findings} ### Info + {list of info findings} -_Automated review by case/reviewer agent_" \ - -f event="COMMENT" +_Automated review by case/reviewer agent_ ``` Only post if there are actual findings to share. Skip this step if the reviewer had 0 warnings and 0 info. @@ -197,7 +189,7 @@ Only post if there are actual findings to share. Skip this step if the reviewer ca status <task.json> prUrl "<PR URL>" ``` - Extract the PR URL from the `gh pr create` output. A null `prUrl` makes the task record incomplete — this is not optional. + Use the PR URL returned by `create_pull_request`. A null `prUrl` makes the task record incomplete — this is not optional. 2. **Append to the task file's Progress Log**: @@ -219,7 +211,7 @@ End your response with the structured result block: AGENT_RESULT>>> ``` -If pre-flight failed or `gh pr create` failed, set `"status":"failed"` and describe what's missing in `"error"`. +If pre-flight failed or `create_pull_request` failed, set `"status":"failed"` and describe what's missing in `"error"`. ## Rules @@ -229,6 +221,5 @@ If pre-flight failed or `gh pr create` failed, set `"status":"failed"` and descr - **Always pre-flight before PR creation.** Catch missing evidence yourself with a clear error. - **Always include verification notes in the PR body.** Include verification keywords. - **Always link the issue.** Use `Closes #N` for GitHub or reference the Linear ID in the body. -- **Always use heredoc format** for the PR body to preserve formatting. - **Always end with `<<<AGENT_RESULT` / `AGENT_RESULT>>>`.** The orchestrator depends on this. -- **Never push to main.** You're on a feature branch — `gh pr create` handles the push. +- **Never push to main.** You're on a feature branch — `create_pull_request` handles the push. diff --git a/agents/verifier.md b/agents/verifier.md index e951429..cd32a3f 100644 --- a/agents/verifier.md +++ b/agents/verifier.md @@ -250,23 +250,11 @@ Most AuthKit example apps redirect to the WorkOS hosted login page. Follow this **Screenshots are the primary evidence.** They render inline on GitHub and are instantly reviewable. Video is optional supplementary evidence for complex multi-step flows. -1. **Upload before/after screenshots** for PR inclusion: - - ```bash - BEFORE=$(ca upload .playwright-cli/before.png) - echo "$BEFORE" - AFTER=$(ca upload .playwright-cli/after.png) - echo "$AFTER" - ``` +1. **Upload before/after screenshots** for PR inclusion by calling the `upload_evidence` tool once per file (e.g. `path: ".playwright-cli/before.png"`, then `".playwright-cli/after.png"`). It returns the markdown reference. Your bash is sandboxed without GitHub credentials, so `ca upload` does not work from bash. Upload ALL screenshots you took during testing (before, intermediate steps, after). Each screenshot should show a distinct state — if two screenshots look identical, one is redundant. -2. **(Optional) Upload video** if you recorded one for a complex flow: - - ```bash - VIDEO=$(ca upload /tmp/verification.webm) - echo "$VIDEO" - ``` +2. **(Optional) Upload video** if you recorded one for a complex flow: call `upload_evidence` with `path: "/tmp/verification.webm"`. Only record video when the flow involves multiple interactions that screenshots can't fully capture (e.g., drag-and-drop, animations, real-time updates). Do NOT record video of a static page load. diff --git a/bun.lock b/bun.lock index c5696e9..3ce024e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@case/orchestrator", "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.82", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", @@ -26,6 +27,8 @@ "@ast-grep/cli", ], "packages": { + "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.82", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "node-forge": "^1.4.0", "zod": "^3.25.0" }, "optionalDependencies": { "@microsoft/mxc-sdk": "^1.0.1" }, "bin": { "srt": "dist/cli.js" } }, "sha512-SCPWkuhLw4ieL+06Jp72yFevltS0s2GD1kMOZdSJ6XxeuqTcoyAa1EDwiLxuONp6szNEOD7x02+vYxPa0oAWwg=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], "@ast-grep/cli": ["@ast-grep/cli@0.42.3", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.42.3", "@ast-grep/cli-darwin-x64": "0.42.3", "@ast-grep/cli-linux-arm64-gnu": "0.42.3", "@ast-grep/cli-linux-x64-gnu": "0.42.3", "@ast-grep/cli-win32-arm64-msvc": "0.42.3", "@ast-grep/cli-win32-ia32-msvc": "0.42.3", "@ast-grep/cli-win32-x64-msvc": "0.42.3" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-uc2gbSwbysWimA5FsrrhksScSA08leq5c35xbBoezvCf9zaJTPHMpSANg4pPHB2D5maQDou/ULXO96GZ4DtHXw=="], @@ -100,6 +103,46 @@ "@google/genai": ["@google/genai@1.46.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg=="], + "@koromix/koffi-android-arm64": ["@koromix/koffi-android-arm64@3.3.7", "", { "os": "android", "cpu": "arm64" }, "sha512-330J8SnMF48DwADEbPYb5gAIM0mo3dZAsLUkuhyEPTPF49h73hRO7LMVXPkUmeXkK/J5Uvgsaem6Lx86Uev3GA=="], + + "@koromix/koffi-android-x64": ["@koromix/koffi-android-x64@3.3.7", "", { "os": "android", "cpu": "x64" }, "sha512-CE1LEAsMyChQQNjGVDy56lBEdU70zOOD709mdKtxM6Rn2I7srT8Jyj66kSwnl4gO4+2BlTBXMyEIAEyYiNXNEA=="], + + "@koromix/koffi-darwin-arm64": ["@koromix/koffi-darwin-arm64@3.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xg1nqqhHRo6QxxYZJtYhhEGAbkHCQxvjoId3BCkhIJhRLi7+dDBRpLEi8x/5xrhqjCp+c/XhMrkAvxWy0NEYBg=="], + + "@koromix/koffi-darwin-x64": ["@koromix/koffi-darwin-x64@3.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-8KeSrHrxzj+SEvaSMc1ufpgn66AdtorKGGb8HNJN5A435stXZ35oxg2+rsAyhTduvEZ9DkCuU94A0crA8KQ0UA=="], + + "@koromix/koffi-freebsd-arm64": ["@koromix/koffi-freebsd-arm64@3.3.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UTo2xw46ctQdcz55I3b5pP1Gs0mGcYZLGAJg7MpEdr8ZEMmmclTzU/WUwWw/lyj5x2Ld2fscjCt69elqfrUBOQ=="], + + "@koromix/koffi-freebsd-ia32": ["@koromix/koffi-freebsd-ia32@3.3.7", "", { "os": "freebsd", "cpu": "ia32" }, "sha512-5vgirS+XISQHzXiad3KYhZYGvuSbAtyrIm2etO118IjpWi87wbKP8xEdf8s4F+vU0HxGs9hcx5a+blwFMe9TFA=="], + + "@koromix/koffi-freebsd-x64": ["@koromix/koffi-freebsd-x64@3.3.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R2POCHtQ46cyhCFtYuq3Uo4MEiMstwLi49fwUgu6Vl38XvqKeje5up5e72LTCXf+ks/y3nua3TYM5oXTBqYAUA=="], + + "@koromix/koffi-linux-arm": ["@koromix/koffi-linux-arm@3.3.7", "", { "os": "linux", "cpu": "arm" }, "sha512-zUb+N1CvsRikXKs93jNdHTAVFt3Njr7ShIA8QwOh2IH/8aV8bkQnkt7AOJvOK/uP0CHd7bxn82YgGUAcZW/pVw=="], + + "@koromix/koffi-linux-arm64": ["@koromix/koffi-linux-arm64@3.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-7ttM8GE2gowelTTyTY0+t79rWaoilZMmW4MYgrIXx5NNShLiHtUNfyGGaNzCd1MD+j+bnNZLJ2lh3lkBS9stfw=="], + + "@koromix/koffi-linux-ia32": ["@koromix/koffi-linux-ia32@3.3.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-hM6CtBgyWGkYzXIl+ciCWSCWnYh/6ft2FRi17aw345lK3Syl2OH06PPMxMd+AHkNrnYWT3nyrzl2Rcun9IeAiw=="], + + "@koromix/koffi-linux-loong64": ["@koromix/koffi-linux-loong64@3.3.7", "", { "os": "linux", "cpu": "none" }, "sha512-plmvZGrC2F02AIDNx3+QcjU39Y9XVgHAHMOM17HJCmxc3f5VpKO0d7aln3/Re6TchBW1h2ldFgI6pp/7vN7IQQ=="], + + "@koromix/koffi-linux-ppc64": ["@koromix/koffi-linux-ppc64@3.3.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bmVe2k1v7tiOGcuX22+HIyOT39bRBrIJd2v6qzSN9Er/f7XI61cbJUnFYWdXpYecU2Ww1LDSkgIifcRyfpipqw=="], + + "@koromix/koffi-linux-riscv64": ["@koromix/koffi-linux-riscv64@3.3.7", "", { "os": "linux", "cpu": "none" }, "sha512-IG1cp7agL1Od03NrKy8B0UGHomco0sygtWhWIXTTbjkJZkjE9Ic79IWzA5d16XbVtob+247XYBDcaeJJ644Xiw=="], + + "@koromix/koffi-linux-x64": ["@koromix/koffi-linux-x64@3.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1cxuG2zsO71wB3RwYt6AjJD9xQW0lUVw4TX4e3Q2DeffEqrVtE3g4IbQsnijTcSeZmmec1kDuuk4CSHUfhAEJQ=="], + + "@koromix/koffi-openbsd-arm64": ["@koromix/koffi-openbsd-arm64@3.3.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-8qqafiMgdmOTEeZsLSnuKdg82Id8IwcxG8MowVtk0zJ4iNObmg2Ygd1dTtWRwpMBW0gfXoodwRdRqnUur3R/aA=="], + + "@koromix/koffi-openbsd-ia32": ["@koromix/koffi-openbsd-ia32@3.3.7", "", { "os": "openbsd", "cpu": "ia32" }, "sha512-ZG06vAX5YUyUGAg9brg4maex7uGj1fybhlMrWt5FlocMBlrY05pn84vlCKavHsCficecAVq7yEzU8f+4FC2T7A=="], + + "@koromix/koffi-openbsd-x64": ["@koromix/koffi-openbsd-x64@3.3.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-jnBD84VCXRmOeJniInO8g5xH2XwVnifp2w35+UbAx8coMIS99YAW9uDNg/U7ooJIyL9hfbatfeNkjNhYhhNpKw=="], + + "@koromix/koffi-win32-arm64": ["@koromix/koffi-win32-arm64@3.3.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Sal267u6Mi+UK/Lbu5vECQDtvZCfK57BO1zmc+Tar7Kdy+hHZ8Eqx7SyxTP6js4ofavQ83pVp1SWQqK8XJVpcg=="], + + "@koromix/koffi-win32-ia32": ["@koromix/koffi-win32-ia32@3.3.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-kOPimggBxcLjHM/rHOWZp8fyxu+UjeEkWM26Gb9XCnirmM76xDu+GXCSDoBPPqF8s+XkY3NrhUdgfIXcwmWiPA=="], + + "@koromix/koffi-win32-x64": ["@koromix/koffi-win32-x64@3.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-yVrh4Yzyjm+fH7CGIa8Ts+DSGeU8QrwEtECXxNswh3i86hwZTOKkqojaDFAYJov0B3w4BFcjR4TOKkPOB0ZpZA=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.6", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.6", "@mariozechner/clipboard-darwin-universal": "0.3.6", "@mariozechner/clipboard-darwin-x64": "0.3.6", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-musl": "0.3.6", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg=="], "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q=="], @@ -130,6 +173,8 @@ "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.73.1", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-ybVsRnUbzQRtbocltJ2OXb2QogrO67N2BlUyKjZz9BHcZYiDJtNkcKQockxDjsVvDc0uBCLDX6iZJoBElBd8fw=="], + "@microsoft/mxc-sdk": ["@microsoft/mxc-sdk@1.0.1", "", { "dependencies": { "koffi": "^3.2.1", "node-pty": "^1.1.0", "semver": "^7.7.4" } }, "sha512-2cP925G5+lQyKzjBVha8uNtVd7XESgDBtgi1yCYYSo9M3xiFyZWtCO4SWPGTk509rdI6nqjyGcZxvEP3t6l8Hg=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], @@ -210,6 +255,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ=="], + "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -306,6 +353,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -418,10 +467,16 @@ "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-forge": ["node-forge@1.4.3", "", {}, "sha512-P6wzfh6t70avmV8FrVEAd/36sOPJQ+hkaJVX1Laap9J/77m7M0eyozDvc8SwnDf4P7MP94gwbAAo0rg3YVXT+g=="], + + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -468,6 +523,8 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "semver": ["semver@7.8.6", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], @@ -532,7 +589,7 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -544,6 +601,10 @@ "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + "@microsoft/mxc-sdk/koffi": ["koffi@3.3.7", "", { "optionalDependencies": { "@koromix/koffi-android-arm64": "3.3.7", "@koromix/koffi-android-x64": "3.3.7", "@koromix/koffi-darwin-arm64": "3.3.7", "@koromix/koffi-darwin-x64": "3.3.7", "@koromix/koffi-freebsd-arm64": "3.3.7", "@koromix/koffi-freebsd-ia32": "3.3.7", "@koromix/koffi-freebsd-x64": "3.3.7", "@koromix/koffi-linux-arm": "3.3.7", "@koromix/koffi-linux-arm64": "3.3.7", "@koromix/koffi-linux-ia32": "3.3.7", "@koromix/koffi-linux-loong64": "3.3.7", "@koromix/koffi-linux-ppc64": "3.3.7", "@koromix/koffi-linux-riscv64": "3.3.7", "@koromix/koffi-linux-x64": "3.3.7", "@koromix/koffi-openbsd-arm64": "3.3.7", "@koromix/koffi-openbsd-ia32": "3.3.7", "@koromix/koffi-openbsd-x64": "3.3.7", "@koromix/koffi-win32-arm64": "3.3.7", "@koromix/koffi-win32-ia32": "3.3.7", "@koromix/koffi-win32-x64": "3.3.7" } }, "sha512-Vrq4UwAE19HPLMou8m7HGCELiTE3l9D+r1nA7XwVHUTNaRdymPM+kzkkD2VGxlyjI2qZTb9tMDaFn7kfZ72v3Q=="], + + "@mistralai/mistralai/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index 8a0992d..95e46b9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "start": "bun src/index.ts" }, "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.82", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", diff --git a/src/__tests__/orchestrator-session.spec.ts b/src/__tests__/orchestrator-session.spec.ts index 9e0fc79..0f2f19d 100644 --- a/src/__tests__/orchestrator-session.spec.ts +++ b/src/__tests__/orchestrator-session.spec.ts @@ -81,6 +81,7 @@ mock.module('@mariozechner/pi-coding-agent', () => ({ createWriteTool: () => ({ name: 'write' }), createEditTool: () => ({ name: 'edit' }), createBashTool: () => ({ name: 'bash' }), + createLocalBashOperations: () => ({ exec: async () => ({ exitCode: 0 }) }), })); const { startOrchestratorSession } = await import('../agent/orchestrator-session.js'); diff --git a/src/__tests__/pi-runner.spec.ts b/src/__tests__/pi-runner.spec.ts index b75076a..96e9386 100644 --- a/src/__tests__/pi-runner.spec.ts +++ b/src/__tests__/pi-runner.spec.ts @@ -41,20 +41,16 @@ describe('PiRuntimeAdapter.createTools', () => { expect(names.length).toBe(2); }); - it('verifier gets read-only + bash', () => { + it('verifier gets read-only + bash + host-side evidence upload', () => { const tools = adapter.createTools('verifier', '/repos/cli'); const names = tools.map((t: { name: string }) => t.name); - expect(names).toContain('read'); - expect(names).toContain('bash'); - expect(names.length).toBe(2); + expect(names).toEqual(['read', 'bash', 'upload_evidence']); }); - it('closer gets read-only + bash', () => { + it('closer gets read-only + bash + host-side PR tools', () => { const tools = adapter.createTools('closer', '/repos/cli'); const names = tools.map((t: { name: string }) => t.name); - expect(names).toContain('read'); - expect(names).toContain('bash'); - expect(names.length).toBe(2); + expect(names).toEqual(['read', 'bash', 'create_pull_request', 'comment_on_pull_request']); }); it('interviewer gets read-only + bash', () => { diff --git a/src/agent/adapters/pi-adapter.ts b/src/agent/adapters/pi-adapter.ts index 5867c29..9a7c298 100644 --- a/src/agent/adapters/pi-adapter.ts +++ b/src/agent/adapters/pi-adapter.ts @@ -1,13 +1,14 @@ import { Agent } from '@mariozechner/pi-agent-core'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; import { streamSimple } from '@mariozechner/pi-ai'; +import { AuthStorage, ModelRegistry } from '@mariozechner/pi-coding-agent'; import { - AuthStorage, - ModelRegistry, - createReadTool, - createWriteTool, - createEditTool, - createBashTool, -} from '@mariozechner/pi-coding-agent'; + createGuardedEditTool, + createGuardedReadTool, + createGuardedWriteTool, + createSandboxedBashTool, +} from '../sandbox.js'; +import { createPrCommentTool, createPullRequestTool, createUploadEvidenceTool } from '../tools/privileged-tools.js'; import { loadSystemPrompt } from '../prompt-loader.js'; import { getModelForAgent } from '../config.js'; import { parseAgentResult } from '../../util/parse-agent-result.js'; @@ -189,28 +190,39 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { return this.createPiTools(agentName, cwd); } - private createPiTools(agentName: string, cwd: string) { + // Bash runs sandboxed without credentials, so credentialed steps (push + PR, PR comment, evidence + // upload) must stay fixed host-side tools. + private createPiTools(agentName: string, cwd: string): AgentTool[] { + const read = createGuardedReadTool(cwd); + const bash = createSandboxedBashTool(cwd); switch (agentName) { case 'implementer': case 'retrospective': - return [createReadTool(cwd), createWriteTool(cwd), createEditTool(cwd), createBashTool(cwd)]; + return [read, createGuardedWriteTool(cwd), createGuardedEditTool(cwd), bash]; case 'scout': // Read-only exploration: Read + Bash only. Glob/Grep are exposed via // the Bash tool in the pi-coding-agent suite (the agent runs `rg`, // `find`, etc.). Crucially: no Write, no Edit — the scout must not // mutate the working tree. - return [createReadTool(cwd), createBashTool(cwd)]; + return [read, bash]; case 'interviewer': // Same read-only constraint as scout: Read + Bash. The interviewer // explores the target repo before `ca onboard --interview` persists // results; it must never mutate the working tree. Human Q&A flows // through the conversation channel, not a tool. - return [createReadTool(cwd), createBashTool(cwd)]; + return [read, bash]; case 'verifier': - case 'reviewer': + return [read, bash, createUploadEvidenceTool(cwd) as unknown as AgentTool]; case 'closer': + return [ + read, + bash, + createPullRequestTool(cwd) as unknown as AgentTool, + createPrCommentTool(cwd) as unknown as AgentTool, + ]; + case 'reviewer': default: - return [createReadTool(cwd), createBashTool(cwd)]; + return [read, bash]; } } diff --git a/src/agent/sandbox.ts b/src/agent/sandbox.ts new file mode 100644 index 0000000..d72546e --- /dev/null +++ b/src/agent/sandbox.ts @@ -0,0 +1,251 @@ +import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; +import applySeccompArm64 from '@anthropic-ai/sandbox-runtime/vendor/seccomp/arm64/apply-seccomp' with { type: 'file' }; +import applySeccompX64 from '@anthropic-ai/sandbox-runtime/vendor/seccomp/x64/apply-seccomp' with { type: 'file' }; +import { + createBashTool, + createEditTool, + createLocalBashOperations, + createReadTool, + createWriteTool, +} from '@mariozechner/pi-coding-agent'; +import type { BashOperations } from '@mariozechner/pi-coding-agent'; +import { spawnSync } from 'node:child_process'; +import { + constants, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { access, mkdir, open, readFile, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { basename, dirname, join, sep } from 'node:path'; +import { resolveDataDir } from '../paths.js'; + +// ponytail: guessed egress allowlist (npm installs, WorkOS API, AuthKit hosted login). Widen here if a repo's verification needs more. +const ALLOWED_DOMAINS = ['registry.npmjs.org', 'api.workos.com', '*.authkit.app']; + +const PASS_ENV = + /^(PATH|HOME|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_\w+|TZ|NO_COLOR|FORCE_COLOR|CI|XDG_(CONFIG|CACHE|DATA|STATE)_HOME|CASE_\w+)$/; + +/** Credential and auth locations hidden from every agent role, in bash and in the file tools. */ +function hiddenPaths(): string[] { + // Bun's homedir() ignores $HOME, which is what `~` means to the sandboxed shell; cover both. + const homes = [...new Set([process.env.HOME, homedir()].filter((home): home is string => !!home))]; + const configHome = process.env.XDG_CONFIG_HOME; + return homes.flatMap((home) => [ + ...[ + '.ssh', + '.gnupg', + '.aws', + '.azure', + '.kube', + '.docker', + '.netrc', + '.npmrc', + '.yarnrc.yml', + '.pypirc', + '.git-credentials', + '.gitconfig', + '.pi', + '.claude', + '.local/share/keyrings', + 'Library/Keychains', + '.bashrc', + '.bash_profile', + '.profile', + '.zshrc', + '.zshenv', + '.zprofile', + ].map((p) => join(home, p)), + ...['gh', 'git', 'gcloud'].map((p) => join(configHome || join(home, '.config'), p)), + ]); +} + +const writeRoots = (cwd: string) => [cwd, '/tmp', tmpdir()]; + +/** Pi loads extensions and settings from these, so agents must never write them. */ +function piConfigDirs(cwd: string): string[] { + return [join(cwd, '.pi'), join(process.cwd(), '.pi'), process.env.PI_CODING_AGENT_DIR].filter( + (dir): dir is string => !!dir, + ); +} + +/** In a compiled `ca` binary the vendored seccomp helper lives in Bun's virtual FS, but bwrap needs it on disk. */ +function seccompApplyPath(): string | undefined { + const embedded = process.arch === 'arm64' ? applySeccompArm64 : applySeccompX64; + if (process.platform !== 'linux' || !embedded.includes('$bunfs')) return undefined; + // Outside the sandbox's write roots, so agents can't swap the helper out. + const target = join(resolveDataDir(), 'bin', `apply-seccomp-${process.arch}`); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(`${target}.${process.pid}`, readFileSync(embedded), { mode: 0o755 }); + renameSync(`${target}.${process.pid}`, target); + return target; +} + +let ready: Promise<void> | undefined; + +function ensureSandbox(): Promise<void> { + ready ??= (async () => { + if (!SandboxManager.isSupportedPlatform()) { + throw new Error(`Agent bash sandbox is not supported on ${process.platform}`); + } + // sandbox-runtime points TMPDIR inside the sandbox here; its /tmp/claude default may not exist. + const sandboxTmp = mkdtempSync(join(tmpdir(), 'case-sandbox-')); + process.env.CLAUDE_CODE_TMPDIR = sandboxTmp; + process.once('exit', () => rmSync(sandboxTmp, { recursive: true, force: true })); + await SandboxManager.initialize({ + network: { allowedDomains: ALLOWED_DOMAINS, deniedDomains: [], allowLocalBinding: true }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + seccomp: { applyPath: seccompApplyPath() }, + }); + // Without seccomp, Linux sandboxes can still reach host Unix sockets (docker, ssh-agent, D-Bus). + const { errors, warnings } = SandboxManager.checkDependencies(); + const problems = [...errors, ...warnings.filter((warning) => warning.includes('seccomp'))]; + if (problems.length > 0) { + await SandboxManager.reset(); + throw new Error( + `Agent bash sandbox unavailable (${problems.join('; ')}). Install bubblewrap, socat and ripgrep on Linux, or ripgrep on macOS.`, + ); + } + })(); + return ready; +} + +/** Git dirs outside the repo (worktrees) must be writable for commits; hooks and config never are. */ +function gitDirs(cwd: string): { writable: string[]; protected: string[] } { + const out = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], { + cwd, + encoding: 'utf8', + }); + const [gitDir, commonDir] = out.status === 0 ? out.stdout.trim().split('\n') : []; + if (!gitDir || !commonDir) return { writable: [], protected: [] }; + return { + writable: [gitDir, commonDir], + protected: [join(commonDir, 'hooks'), join(commonDir, 'config'), join(gitDir, 'config.worktree')], + }; +} + +function sandboxEnv(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = Object.fromEntries(Object.entries(env).filter(([key]) => PASS_ENV.test(key))); + // ~/.gitconfig is hidden, so give git an empty global config and forward only the commit identity. + out.GIT_CONFIG_GLOBAL = '/dev/null'; + out.GIT_CONFIG_NOSYSTEM = '1'; + const identity = spawnSync('git', ['config', '--get-regexp', '^user\\.(name|email)$'], { cwd, encoding: 'utf8' }); + for (const line of (identity.stdout ?? '').split('\n')) { + const [, key, value] = line.match(/^user\.(name|email) (.+)$/) ?? []; + if (key && value) out[`GIT_AUTHOR_${key.toUpperCase()}`] = out[`GIT_COMMITTER_${key.toUpperCase()}`] = value; + } + return out; +} + +const localBash = createLocalBashOperations(); + +const sandboxedBash: BashOperations = { + async exec(command, cwd, options) { + await ensureSandbox(); + const git = gitDirs(cwd); + const wrapped = await SandboxManager.wrapWithSandbox( + command, + undefined, + { + filesystem: { + denyRead: hiddenPaths(), + allowWrite: [...writeRoots(cwd), ...git.writable], + denyWrite: [...git.protected, ...piConfigDirs(cwd)], + }, + }, + options.signal, + ); + try { + return await localBash.exec(wrapped, cwd, { ...options, env: sandboxEnv(options.env ?? process.env, cwd) }); + } finally { + SandboxManager.cleanupAfterCommand(); + } + }, +}; + +function resolveReal(path: string): string { + try { + return realpathSync(path); + } catch { + const parent = dirname(path); + return parent === path ? path : join(resolveReal(parent), basename(path)); + } +} + +const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep); + +// ponytail: paths are checked via realpath and then opened by that real path; a concurrent symlink swap of a +// parent directory can still race the check. Upgrade to per-component O_NOFOLLOW opens if that matters. +function readablePath(path: string): string { + const real = resolveReal(path); + if ([...hiddenPaths(), '/proc'].some((hidden) => within(real, resolveReal(hidden)))) { + throw new Error(`Access denied: ${path}`); + } + return real; +} + +/** Real path of a file an agent may write: inside the repo or /tmp, never git internals or Pi config. */ +export function writablePath(path: string, cwd: string): string { + const real = readablePath(path); + if (!writeRoots(cwd).some((root) => within(real, resolveReal(root)))) { + throw new Error(`Write denied outside the working repo and /tmp: ${path}`); + } + const agentDir = process.env.PI_CODING_AGENT_DIR; + if ( + real.split(sep).some((part) => part === '.git' || part === '.pi') || + (agentDir && within(real, resolveReal(agentDir))) + ) { + throw new Error(`Write denied: ${path} is protected`); + } + return real; +} + +async function imageMimeType(path: string): Promise<string | null> { + const file = await open(path, 'r'); + try { + const { buffer, bytesRead } = await file.read(Buffer.alloc(12), 0, 12, 0); + const hex = buffer.subarray(0, bytesRead).toString('hex'); + if (hex.startsWith('89504e47')) return 'image/png'; + if (hex.startsWith('ffd8ff')) return 'image/jpeg'; + if (hex.startsWith('47494638')) return 'image/gif'; + if (hex.startsWith('52494646') && hex.slice(16, 24) === '57454250') return 'image/webp'; + return null; + } finally { + await file.close(); + } +} + +export const createSandboxedBashTool = (cwd: string) => createBashTool(cwd, { operations: sandboxedBash }); + +export const createGuardedReadTool = (cwd: string) => + createReadTool(cwd, { + operations: { + readFile: (path) => readFile(readablePath(path)), + access: (path) => access(readablePath(path), constants.R_OK), + detectImageMimeType: (path) => imageMimeType(readablePath(path)), + }, + }); + +export const createGuardedWriteTool = (cwd: string) => + createWriteTool(cwd, { + operations: { + writeFile: (path, content) => writeFile(writablePath(path, cwd), content, 'utf-8'), + mkdir: async (dir) => { + await mkdir(writablePath(dir, cwd), { recursive: true }); + }, + }, + }); + +export const createGuardedEditTool = (cwd: string) => + createEditTool(cwd, { + operations: { + readFile: (path) => readFile(writablePath(path, cwd)), + writeFile: (path, content) => writeFile(writablePath(path, cwd), content, 'utf-8'), + access: (path) => access(writablePath(path, cwd), constants.R_OK | constants.W_OK), + }, + }); diff --git a/src/agent/tools/privileged-tools.ts b/src/agent/tools/privileged-tools.ts new file mode 100644 index 0000000..eb5b20c --- /dev/null +++ b/src/agent/tools/privileged-tools.ts @@ -0,0 +1,105 @@ +import { constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { open } from 'node:fs/promises'; +import { basename, extname, join, resolve } from 'node:path'; +import { Type } from '@sinclair/typebox'; +import { defineTool } from './define-tool.js'; +import { runCommand } from '../../util/run-command.js'; +import { resolveDataDir } from '../../paths.js'; +import { writablePath } from '../sandbox.js'; + +// Host-side tools for the credentialed steps agents can't do from sandboxed bash. Arguments are fixed; +// the model only supplies text or a validated file path, never a command line. + +const MEDIA = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp4', 'mov', 'webm']; + +const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }], details: {} }); + +async function run(command: string, args: string[], cwd: string, timeout: number): Promise<string> { + const result = await runCommand(command, args, { cwd, timeout }); + if (result.exitCode !== 0) { + throw new Error(`${command} ${args.join(' ').slice(0, 80)} failed: ${(result.stderr || result.stdout).trim()}`); + } + return result.stdout.trim(); +} + +export function createPullRequestTool(cwd: string) { + return defineTool({ + name: 'create_pull_request', + label: 'Create PR', + description: + 'Push the current feature branch to origin and open a pull request with `gh pr create`. Refuses main/master. Returns the PR URL.', + parameters: Type.Object({ + title: Type.String({ description: 'PR title in conventional commit format' }), + body: Type.String({ description: 'PR body markdown' }), + }), + execute: async (_toolCallId, { title, body }) => { + const branch = await run('git', ['branch', '--show-current'], cwd, 30_000); + if (!branch || branch === 'main' || branch === 'master') { + throw new Error(`Refusing to push "${branch || 'detached HEAD'}"; switch to a feature branch.`); + } + // Hooks can live in the sandbox-writable working tree (e.g. husky's core.hooksPath=.husky), so never run them here. + await run( + 'git', + ['-c', 'core.hooksPath=/dev/null', 'push', '--no-recurse-submodules', '-u', 'origin', 'HEAD'], + cwd, + 120_000, + ); + return text(await run('gh', ['pr', 'create', `--title=${title}`, `--body=${body}`], cwd, 60_000)); + }, + }); +} + +export function createPrCommentTool(cwd: string) { + return defineTool({ + name: 'comment_on_pull_request', + label: 'PR comment', + description: "Post a COMMENT review on the current branch's pull request with `gh pr review --comment`.", + parameters: Type.Object({ + body: Type.String({ description: 'Review comment markdown' }), + }), + execute: async (_toolCallId, { body }) => + text((await run('gh', ['pr', 'review', '--comment', `--body=${body}`], cwd, 60_000)) || 'Review comment posted.'), + }); +} + +export function createUploadEvidenceTool(cwd: string) { + return defineTool({ + name: 'upload_evidence', + label: 'Upload evidence', + description: + 'Upload a screenshot or video from the repo or /tmp with `ca upload` and return its markdown reference for the PR.', + parameters: Type.Object({ + path: Type.String({ description: `File inside the repo or /tmp (${MEDIA.join(', ')})` }), + }), + execute: async (_toolCallId, { path }) => { + const real = writablePath(resolve(cwd, path), cwd); + const ext = extname(real).slice(1).toLowerCase(); + if (!MEDIA.includes(ext)) throw new Error(`upload_evidence only accepts ${MEDIA.join(', ')} files`); + + // Snapshot the file where sandboxed bash can't write, so it can't be swapped for a symlink mid-upload. + const file = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW); + let data: Buffer; + try { + if (!(await file.stat()).isFile()) throw new Error(`Not a regular file: ${path}`); + data = await file.readFile(); + } finally { + await file.close(); + } + // `ca upload` hands webm to ffmpeg, which content-sniffs (e.g. HLS playlists that pull in local files). + if (ext === 'webm' && data.subarray(0, 4).toString('hex') !== '1a45dfa3') { + throw new Error(`Not a webm video: ${path}`); + } + + const dataDir = resolveDataDir(); + mkdirSync(dataDir, { recursive: true }); + const dir = mkdtempSync(join(dataDir, 'upload-')); + try { + const copy = join(dir, basename(real)); + writeFileSync(copy, data); + return text(await run('ca', ['upload', copy], dir, 300_000)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + }); +} diff --git a/src/commands/upload.ts b/src/commands/upload.ts index e9738cf..a941cb0 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { basename, extname, resolve } from 'node:path'; +import { basename, dirname, extname, join, resolve } from 'node:path'; import { resolveDataDir } from '../paths.js'; export const description = 'Upload a screenshot or video to case-assets, print markdown reference'; @@ -99,7 +99,7 @@ export async function handler(argv: string[]): Promise<number> { const ffmpegCheck = Bun.spawn(['which', 'ffmpeg'], { stdout: 'ignore', stderr: 'ignore' }); if ((await ffmpegCheck.exited) === 0) { const stem = basename(filePath, `.${ext}`); - mp4Path = `/tmp/${stem}.mp4`; + mp4Path = join(dirname(filePath), `${stem}.mp4`); process.stderr.write('Converting webm to mp4...\n'); const convert = Bun.spawn( [ diff --git a/src/dev/run-tests.ts b/src/dev/run-tests.ts index 800dd45..9b5d1e2 100644 --- a/src/dev/run-tests.ts +++ b/src/dev/run-tests.ts @@ -3,4 +3,5 @@ import { runSequence } from './run-sequence.js'; await runSequence([ { label: 'unit tests', args: ['bun', 'test', './src/__tests__/'] }, { label: 'standalone tests', args: ['bun', 'test', '--cwd', 'test/standalone'] }, + { label: 'sandbox tests', args: ['bun', 'test', '--cwd', 'test/sandbox'] }, ]); diff --git a/src/entry/cli-orchestrator.ts b/src/entry/cli-orchestrator.ts index 9b8e174..f01cba3 100644 --- a/src/entry/cli-orchestrator.ts +++ b/src/entry/cli-orchestrator.ts @@ -240,7 +240,8 @@ async function ensureBranch(branchName: string, repoPath: string, warnOnCreate = const check = await runCommand('git', ['rev-parse', '--verify', branchName], { cwd: repoPath }); if (check.exitCode === 0) { - const co = await runCommand('git', ['checkout', branchName], { cwd: repoPath }); + // Agent-written hooks (e.g. husky's .husky/) must not run host-side. + const co = await runCommand('git', ['-c', 'core.hooksPath=/dev/null', 'checkout', branchName], { cwd: repoPath }); if (co.exitCode !== 0) { throw new Error(`Failed to checkout branch ${branchName}: ${co.stderr.trim()}`); } @@ -248,7 +249,9 @@ async function ensureBranch(branchName: string, repoPath: string, warnOnCreate = if (warnOnCreate) { process.stdout.write(` Warning: branch ${branchName} not found, recreating from HEAD\n`); } - const create = await runCommand('git', ['checkout', '-b', branchName], { cwd: repoPath }); + const create = await runCommand('git', ['-c', 'core.hooksPath=/dev/null', 'checkout', '-b', branchName], { + cwd: repoPath, + }); if (create.exitCode !== 0) { throw new Error(`Failed to create branch ${branchName}: ${create.stderr.trim()}`); } diff --git a/src/interview/session.ts b/src/interview/session.ts index bd2fa6a..fd463f3 100644 --- a/src/interview/session.ts +++ b/src/interview/session.ts @@ -23,8 +23,6 @@ import { AuthStorage, createAgentSession, createAgentSessionRuntime, - createBashTool, - createReadTool, DefaultResourceLoader, getAgentDir, InteractiveMode, @@ -42,6 +40,7 @@ import { fileURLToPath } from 'node:url'; import { getModelForAgent } from '../agent/config.js'; import { createIsolatedAgentDir } from '../agent/isolated-agent-dir.js'; import { loadSystemPrompt } from '../agent/prompt-loader.js'; +import { createGuardedReadTool, createSandboxedBashTool } from '../agent/sandbox.js'; import { parseAgentResult } from '../util/parse-agent-result.js'; import { parseInterviewFindings } from './findings.js'; import type { InterviewFindings, ProjectEntry } from '../types.js'; @@ -142,7 +141,12 @@ export async function startInterviewSession(options: InterviewSessionOptions): P model: model ?? undefined, resourceLoader: rl, sessionManager: factoryOpts.sessionManager, - customTools: [createReadTool(factoryOpts.cwd), createBashTool(factoryOpts.cwd)] as unknown as ToolDefinition[], + customTools: [ + createGuardedReadTool(factoryOpts.cwd), + createSandboxedBashTool(factoryOpts.cwd), + ] as unknown as ToolDefinition[], + // Without an allowlist Pi also activates its built-in edit/write tools. + tools: ['read', 'bash', 'ask_user_question'], }); return { diff --git a/src/package-asset-modules.d.ts b/src/package-asset-modules.d.ts index 5e4a7a5..52a6737 100644 --- a/src/package-asset-modules.d.ts +++ b/src/package-asset-modules.d.ts @@ -12,3 +12,8 @@ declare module '*.yaml' { const content: string; export default content; } + +declare module '*/apply-seccomp' { + const path: string; + export default path; +} diff --git a/test/sandbox/bunfig.toml b/test/sandbox/bunfig.toml new file mode 100644 index 0000000..f8c6c82 --- /dev/null +++ b/test/sandbox/bunfig.toml @@ -0,0 +1,2 @@ +[test] +# Own process and no preload: these tests need the real Pi SDK and sandbox runtime, unmocked. diff --git a/test/sandbox/sandbox.spec.ts b/test/sandbox/sandbox.spec.ts new file mode 100644 index 0000000..5b5de4a --- /dev/null +++ b/test/sandbox/sandbox.spec.ts @@ -0,0 +1,100 @@ +/** + * Proves agent tools are sandboxed for real (bubblewrap on Linux, sandbox-exec on macOS): + * credentials hidden, secrets stripped from env, writes confined to the repo and /tmp. + */ +import { afterAll, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; + +// Outside /tmp so "write outside the repo and /tmp" is meaningful. +const root = mkdtempSync('/var/tmp/case-sandbox-test-'); +const home = join(root, 'home'); +const repo = join(root, 'repo'); +const SECRET = ['sandbox', 'leak', 'canary'].join('-'); +const tmpProbe = `/tmp/case-sandbox-test-${process.pid}`; + +for (const [file, content] of [ + ['.ssh/id_rsa', SECRET], + ['.config/gh/hosts.yml', SECRET], + ['.aws/credentials', SECRET], + ['.npmrc', SECRET], + ['.git-credentials', SECRET], +] as const) { + mkdirSync(join(home, file, '..'), { recursive: true }); + writeFileSync(join(home, file), content); +} +mkdirSync(repo); +spawnSync('git', ['init', '-q', repo]); +spawnSync('git', ['-C', repo, 'config', 'user.name', 'Sandbox Tester']); +spawnSync('git', ['-C', repo, 'config', 'user.email', 'sandbox@example.com']); + +process.env.HOME = home; +process.env.GH_TOKEN = SECRET; +process.env.ANTHROPIC_API_KEY = SECRET; + +const { PiRuntimeAdapter } = await import('../../src/agent/adapters/pi-adapter.js'); + +type Tool = { name: string; execute: (id: string, params: object) => Promise<{ content: { text?: string }[] }> }; +const adapter = new PiRuntimeAdapter(); +const tool = (role: string, name: string) => (adapter.createTools(role, repo) as Tool[]).find((t) => t.name === name)!; +const run = async (t: Tool, params: object) => (await t.execute('test', params)).content.map((c) => c.text).join(''); + +const supported = SandboxManager.isSupportedPlatform() && SandboxManager.checkDependencies().errors.length === 0; + +afterAll(async () => { + await SandboxManager.reset(); + for (const dir of [root, process.env.CLAUDE_CODE_TMPDIR]) if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe.skipIf(!supported)('agent sandbox', () => { + for (const role of ['scout', 'implementer', 'verifier', 'reviewer', 'closer', 'retrospective', 'interviewer']) { + it(`${role}: bash hides credentials, strips secrets, and confines writes`, async () => { + const out = await run(tool(role, 'bash'), { + command: [ + 'cat ~/.ssh/id_rsa ~/.config/gh/hosts.yml ~/.aws/credentials ~/.npmrc ~/.git-credentials 2>&1', + 'env', + "cat /proc/*/environ 2>/dev/null | tr '\\0' '\\n'", + `echo x > ~/pwned-${role} 2>/dev/null && echo HOME_WRITTEN`, + 'echo x > .git/hooks/pre-push 2>/dev/null && echo HOOK_WRITTEN', + `echo x > repo-${role}.txt && echo REPO_WRITTEN`, + `echo x > ${tmpProbe}-${role} && echo TMP_WRITTEN && rm ${tmpProbe}-${role}`, + 'curl -s -m 5 https://example.com >/dev/null 2>&1 || echo NET_BLOCKED', + 'true', + ].join('; '), + }); + + expect(out).not.toContain(SECRET); + expect(out).toContain('GIT_CONFIG_GLOBAL=/dev/null'); + expect(out).toContain('REPO_WRITTEN'); + expect(out).toContain('TMP_WRITTEN'); + expect(out).toContain('NET_BLOCKED'); + expect(out).not.toContain('HOME_WRITTEN'); + expect(out).not.toContain('HOOK_WRITTEN'); + expect(existsSync(join(home, `pwned-${role}`))).toBe(false); + }); + + it(`${role}: read tool refuses credential files and /proc`, async () => { + const read = tool(role, 'read'); + await expect(run(read, { path: join(home, '.ssh/id_rsa') })).rejects.toThrow('Access denied'); + await expect(run(read, { path: join(home, '.config/gh/hosts.yml') })).rejects.toThrow('Access denied'); + await expect(run(read, { path: '/proc/self/environ' })).rejects.toThrow('Access denied'); + }); + } + + it('implementer: git commit works inside the sandbox with the forwarded identity', async () => { + const out = await run(tool('implementer', 'bash'), { + command: 'echo a > a.txt && git add a.txt && git commit -qm sandboxed && git log -1 --format=%an', + }); + expect(out).toContain('Sandbox Tester'); + }); + + it('implementer: write tool stays inside the repo and out of .git', async () => { + const write = tool('implementer', 'write'); + await run(write, { path: 'src/ok.ts', content: 'ok' }); + expect(existsSync(join(repo, 'src/ok.ts'))).toBe(true); + await expect(run(write, { path: join(home, 'pwned'), content: 'x' })).rejects.toThrow('Write denied'); + await expect(run(write, { path: '.git/hooks/pre-push', content: 'x' })).rejects.toThrow('Write denied'); + }); +}); diff --git a/test/standalone/pi-runner-unit.spec.ts b/test/standalone/pi-runner-unit.spec.ts index d707eca..a2d9ef3 100644 --- a/test/standalone/pi-runner-unit.spec.ts +++ b/test/standalone/pi-runner-unit.spec.ts @@ -41,6 +41,7 @@ mock.module('@mariozechner/pi-coding-agent', () => ({ createWriteTool: mock(() => ({ name: 'write', label: 'Write' })), createEditTool: mock(() => ({ name: 'edit', label: 'Edit' })), createBashTool: mock(() => ({ name: 'bash', label: 'Bash' })), + createLocalBashOperations: mock(() => ({ exec: async () => ({ exitCode: 0 }) })), AuthStorage: { create: () => ({}) }, ModelRegistry: { create: () => ({ From 8cb48fc621ed9de696c473ae1834e3c5ab57cae1 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Tue, 10 Nov 2026 15:48:15 +0000 Subject: [PATCH 2/2] fix(orchestrator): restrict ca --agent to read-only tools (VULN-1413) `ca --agent` started its Pi session without a tool allowlist, so Pi's default bash/edit/write were active alongside Case's tools. The session now gets only a path-guarded read plus run_pipeline, fetch_issue, create_task and run_baseline. Tests cover the allowlist with mocked Pi and with a real AgentSession. Co-authored-by: mitch-fultz <mitch-fultz@users.noreply.github.com> --- src/__tests__/orchestrator-session.spec.ts | 10 +++------- src/agent/orchestrator-session.ts | 22 ++++++++++++++------- test/sandbox/sandbox.spec.ts | 23 +++++++++++++++++++++- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/__tests__/orchestrator-session.spec.ts b/src/__tests__/orchestrator-session.spec.ts index 0f2f19d..8fefe54 100644 --- a/src/__tests__/orchestrator-session.spec.ts +++ b/src/__tests__/orchestrator-session.spec.ts @@ -133,18 +133,14 @@ describe('startOrchestratorSession', () => { mockFindTaskByMarker.mockResolvedValue(null); }); - it('creates session with four custom tools', async () => { + it('creates a read-only session: read plus the four case tools, no bash/edit/write', async () => { await startOrchestratorSession({ caseRoot: '/case', mode: 'attended' }); expect(mockCreateAgentSession).toHaveBeenCalledTimes(1); const opts = mockCreateAgentSession.mock.calls[0][0]; - expect(opts.customTools).toHaveLength(4); - const toolNames = opts.customTools.map((t: { name: string }) => t.name); - expect(toolNames).toContain('run_pipeline'); - expect(toolNames).toContain('fetch_issue'); - expect(toolNames).toContain('create_task'); - expect(toolNames).toContain('run_baseline'); + expect(toolNames).toEqual(['read', 'run_pipeline', 'fetch_issue', 'create_task', 'run_baseline']); + expect(opts.tools).toEqual(toolNames); }); it('detects repo and includes it in initial message', async () => { diff --git a/src/agent/orchestrator-session.ts b/src/agent/orchestrator-session.ts index c32e5dd..29f332f 100644 --- a/src/agent/orchestrator-session.ts +++ b/src/agent/orchestrator-session.ts @@ -21,6 +21,7 @@ import { createPipelineTool } from './tools/pipeline-tool.js'; import { createIssueTool } from './tools/issue-tool.js'; import { createTaskTool } from './tools/task-tool.js'; import { createBaselineTool } from './tools/baseline-tool.js'; +import { createGuardedReadTool } from './sandbox.js'; import { isEmbeddedPackageRoot } from '../paths.js'; export interface OrchestratorSessionOptions { @@ -91,12 +92,7 @@ export async function startOrchestratorSession(options: OrchestratorSessionOptio model: model ?? undefined, resourceLoader: rl, sessionManager: factoryOpts.sessionManager, - customTools: [ - createPipelineTool(caseRoot), - createIssueTool(caseRoot), - createTaskTool(caseRoot), - createBaselineTool(caseRoot), - ] as unknown as ToolDefinition[], + ...orchestratorTools(caseRoot, factoryOpts.cwd), }); return { @@ -125,6 +121,18 @@ export async function startOrchestratorSession(options: OrchestratorSessionOptio await interactive.run(); } +/** `ca --agent` is read-only: guarded read plus the fixed pipeline tools. `tools` stops Pi adding bash/edit/write. */ +export function orchestratorTools(caseRoot: string, cwd: string) { + const customTools = [ + createGuardedReadTool(cwd), + createPipelineTool(caseRoot), + createIssueTool(caseRoot), + createTaskTool(caseRoot), + createBaselineTool(caseRoot), + ] as unknown as ToolDefinition[]; + return { customTools, tools: customTools.map((tool) => tool.name) }; +} + async function gatherContext(options: OrchestratorSessionOptions): Promise<string> { const lines: string[] = []; @@ -293,7 +301,7 @@ function buildOrchestratorSystemPrompt(caseRoot: string): string { If you catch yourself about to edit a source file or run a command that changes repo state — stop. That work belongs to the implementer agent, not you. -**Reading and exploring is always fine.** Read files, run \`git log\`, check configs, run \`--help\` commands — anything read-only to understand the problem. +**Reading and exploring is always fine.** Use the \`read\` tool to inspect files and configs. You have no shell; anything that needs a command belongs to the pipeline. ## Tools diff --git a/test/sandbox/sandbox.spec.ts b/test/sandbox/sandbox.spec.ts index 5b5de4a..604ca15 100644 --- a/test/sandbox/sandbox.spec.ts +++ b/test/sandbox/sandbox.spec.ts @@ -1,12 +1,14 @@ /** * Proves agent tools are sandboxed for real (bubblewrap on Linux, sandbox-exec on macOS): - * credentials hidden, secrets stripped from env, writes confined to the repo and /tmp. + * credentials hidden, secrets stripped from env, writes confined to the repo and /tmp, + * and the `ca --agent` session has no bash. */ import { afterAll, describe, expect, it } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; +import { createAgentSession, SessionManager } from '@mariozechner/pi-coding-agent'; // Outside /tmp so "write outside the repo and /tmp" is meaningful. const root = mkdtempSync('/var/tmp/case-sandbox-test-'); @@ -35,6 +37,7 @@ process.env.GH_TOKEN = SECRET; process.env.ANTHROPIC_API_KEY = SECRET; const { PiRuntimeAdapter } = await import('../../src/agent/adapters/pi-adapter.js'); +const { orchestratorTools } = await import('../../src/agent/orchestrator-session.js'); type Tool = { name: string; execute: (id: string, params: object) => Promise<{ content: { text?: string }[] }> }; const adapter = new PiRuntimeAdapter(); @@ -98,3 +101,21 @@ describe.skipIf(!supported)('agent sandbox', () => { await expect(run(write, { path: '.git/hooks/pre-push', content: 'x' })).rejects.toThrow('Write denied'); }); }); + +describe('ca --agent', () => { + it('session is read-only: no bash, edit, or write', async () => { + const { session } = await createAgentSession({ + cwd: repo, + agentDir: join(root, 'pi-agent'), + sessionManager: SessionManager.inMemory(repo), + ...orchestratorTools('/case', repo), + }); + expect(session.getActiveToolNames().sort()).toEqual([ + 'create_task', + 'fetch_issue', + 'read', + 'run_baseline', + 'run_pipeline', + ]); + }); +});