Terminal tasks run in a bubblewrap jail inside the sandbox, opted into at open (#146)
* Terminal tasks run in a bubblewrap jail inside the sandbox, opted into at open The agent sees only its own clone, the checkout's objects read-only and the CLI's own sign-in, with no network but a CONNECT proxy outside the jail that reaches the providers and package registries by name. Its commits leave as a git bundle that the terminal's checkout pushes, with no hooks. Nothing is intercepted in the Worker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * Terminal tasks: the jail gets private copies of the sign-in, read-only tools, no claude.ai; fsck the bundle; harden the live jail check Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent e647e03commit 43725608de0d8b12d49cb8f5ee8da0729f5274ddBrowse files
17 files changed+711−47
Files (17)
- .github/workflows/e2e.yml+7−0
- .github/workflows/live-test.yml+50−0
- apps/diffinery/docs/agents/terminal.md+43−9
- apps/diffinery/docs/security/audit-log.md+1−1
- apps/diffinery/migrations/0028_terminal_tasks.sql+3−0
- apps/diffinery/src/agentic/ProjectDO.ts+61−15
- apps/diffinery/src/agentic/runtimes.ts+20−0
- apps/diffinery/src/agentic/terminal-web.tsx+16−6
- apps/diffinery/src/agentic/terminals.ts+12−9
- apps/diffinery/test/e2e/agents-harness.mjs+17−1
- apps/diffinery/test/e2e/terminals.test.mjs+27−3
- apps/diffinery/test/unit/terminal-command.test.ts+15−0
- containers/runner/Dockerfile+1−0
- containers/runner/src/jail.ts+305−0
- containers/runner/src/main.ts+3−0
- containers/runner/src/sandbox.ts+10−3
- containers/runner/test/jail.test.ts+120−0
modified.github/workflows/e2e.yml+7−0View file
| @@ -43,6 +43,13 @@ | ||
| 43 | 43 | - run: pnpm -r --if-present build |
| 44 | 44 | - if: matrix.shard == 'rest' |
| 45 | 45 | run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium |
| 46 | + # Terminal tasks run in a bubblewrap jail (containers/runner/src/jail.ts); Ubuntu's AppArmor otherwise keeps | |
| 47 | + # it from creating user namespaces. | |
| 48 | + - if: matrix.shard == 'rest' | |
| 49 | + run: | | |
| 50 | + sudo apt-get install -y --no-install-recommends bubblewrap | |
| 51 | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true | |
| 52 | + bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc -- true | |
| 46 | 53 | - name: Run the shard's tests |
| 47 | 54 | working-directory: apps/diffinery |
| 48 | 55 | run: | |
modified.github/workflows/live-test.yml+50−0View file
| @@ -503,6 +503,56 @@ | ||
| 503 | 503 | curl -sS -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true |
| 504 | 504 | jq -e --arg r "# $REPO" '.exitCode == 0 and (.stdout | contains($r))' >/dev/null <<<"$out" || { echo "::error::the sandbox did not run the command in the repository"; exit 1; } |
| 505 | 505 | |
| 506 | + # Owned by the agentic thread: proves a terminal task's jail (containers/runner/src/jail.ts) on Cloudflare. | |
| 507 | + - name: A terminal task's jail (bubblewrap, no network but the allowlist proxy) | |
| 508 | + if: steps.token.outputs.skip != 'true' | |
| 509 | + run: | | |
| 510 | + set -euo pipefail | |
| 511 | + base="$DIFFINERY/api/v3/repos/$OWNER/$REPO/diffinery/sandboxes" | |
| 512 | + id="" | |
| 513 | + close() { if [ -n "$id" ]; then curl -sS --max-time 60 -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true; fi; id=""; } | |
| 514 | + trap close EXIT | |
| 515 | + run() { curl -sS --max-time "${2:-240}" -X POST "$base/$id/exec" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data "$(jq -n --arg c "$1" '{cmd: $c, timeoutMs: 180000}')"; } | |
| 516 | + # One create (no retry: a retried POST could open a second sandbox nothing closes), then wait for it to answer. | |
| 517 | + open() { | |
| 518 | + close | |
| 519 | + id=$(curl -sS --max-time 240 -X POST "$base" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{}' | jq -r '.id // empty' || true) | |
| 520 | + [ -n "$id" ] || { echo "::error::could not open a sandbox for the jail check"; exit 1; } | |
| 521 | + for i in $(seq 1 30); do | |
| 522 | + jq -e '.exitCode == 0' >/dev/null 2>&1 <<<"$(run true 20 || true)" && return 0 | |
| 523 | + sleep 6 | |
| 524 | + done | |
| 525 | + echo "::error::the sandbox never became ready" | |
| 526 | + exit 1 | |
| 527 | + } | |
| 528 | + # Sandboxes roll out on their own: one opened right after a deploy can still be on the previous image. | |
| 529 | + jail=no | |
| 530 | + for attempt in 1 2 3 4 5; do | |
| 531 | + open | |
| 532 | + has=$(run '[ -n "${DIFFINERY_JAIL:-}" ] && [ -x "$DIFFINERY_JAIL" ] && echo yes || echo no' 20 | jq -r '.stdout // empty' || true) | |
| 533 | + if [ "$has" = yes ]; then jail=yes; break; fi | |
| 534 | + echo "sandbox $id is on a runner image without the jail; trying another in a minute" | |
| 535 | + close | |
| 536 | + sleep 60 | |
| 537 | + done | |
| 538 | + [ "$jail" = yes ] || { echo "::error::the sandbox started on a runner image without the jail (rollout not finished?)"; exit 1; } | |
| 539 | + inside='echo "direct=$(node -e "require(\"net\").connect(443, \"1.1.1.1\").on(\"connect\", () => (console.log(\"open\"), process.exit())).on(\"error\", (e) => (console.log(e.code), process.exit()))")" | |
| 540 | + echo "other=$(curl -sS -m 20 -o /dev/null -w "%{http_connect}" https://example.com 2>/dev/null || true)" | |
| 541 | + echo "checkout=$(ls -A /workspace/repo | paste -sd,)" | |
| 542 | + echo "credential=$(cat /workspace/repo/.git/config 2>/dev/null || echo hidden)" | |
| 543 | + echo "npm=$(npm view left-pad version 2>&1 | tail -1)"' | |
| 544 | + out=$(run "mkdir -p ../jail-check ../jail-check.out && \"\$DIFFINERY_JAIL\" agent --dir ../jail-check --out ../jail-check.out -- sh -c $(printf '%q' "$inside")") | |
| 545 | + echo "$out" | jq -r '"exit \(.exitCode)", .stdout, .stderr' | |
| 546 | + o=$(jq -r .stdout <<<"$out") | |
| 547 | + fail=0 | |
| 548 | + jq -e '.exitCode == 0' >/dev/null <<<"$out" || { echo "::error::the jail didn't run (exit $(jq -r .exitCode <<<"$out"))"; fail=1; } | |
| 549 | + grep -qE '^direct=(ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|EACCES)$' <<<"$o" || { echo "::error::the jail has a route out of its own"; fail=1; } | |
| 550 | + grep -qx 'other=403' <<<"$o" || { echo "::error::the proxy didn't refuse example.com"; fail=1; } | |
| 551 | + grep -qx 'checkout=.git' <<<"$o" || { echo "::error::the jail sees the person's checkout"; fail=1; } | |
| 552 | + grep -qx 'credential=hidden' <<<"$o" || { echo "::error::the jail sees the checkout's git config"; fail=1; } | |
| 553 | + grep -qE '^npm=[0-9]+\.[0-9]+' <<<"$o" || { echo "::error::npm view left-pad didn't work through the proxy"; fail=1; } | |
| 554 | + exit $fail | |
| 555 | + | |
| 506 | 556 | - name: Pull requests and the merge queue |
| 507 | 557 | if: steps.token.outputs.skip != 'true' |
| 508 | 558 | run: | |
modifiedapps/diffinery/docs/agents/terminal.md+43−9View file
| @@ -18,8 +18,8 @@ | ||
| 18 | 18 | ## Open a terminal |
| 19 | 19 | |
| 20 | 20 | 1. Open the repository's **Project** tab and click **Terminals** (the **Agent tasks** page has the same button), or go to `/<owner>/<repo>/terminals`. |
| 21 | -2. Pick the **agent** and the **branch**. | |
| 22 | -3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again. It also says what a task in your terminal can reach (see [Give it a task](#give-it-a-task)). | |
| 21 | +2. Pick the **agent** and the **branch**. Tick **Will run agent tasks** if you'll [give it tasks](#give-it-a-task). Without it, the terminal is only a shell and never runs one. | |
| 22 | +3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again. It also says what a task in your terminal can reach, and what that leaves exposed (see [What a task can reach](#what-a-task-can-reach)). | |
| 23 | 23 | 4. Click **Open a terminal**. |
| 24 | 24 | |
| 25 | 25 | The page shows **Starting** while the sandbox starts, then the terminal. Reloading the page, or opening it in another tab, connects to the same shell and redraws its recent output. Only one tab is connected at a time: the newest one wins, and the older tab says so. |
| @@ -37,26 +37,60 @@ | ||
| 37 | 37 | |
| 38 | 38 | ## Give it a task |
| 39 | 39 | |
| 40 | -Your terminal can run [agent tasks](/docs/agents/tasks) with the Claude Code or Codex you signed in to there, on your own subscription, with no API key. | |
| 40 | +A terminal you open with **Will run agent tasks** can run [agent tasks](/docs/agents/tasks) with the Claude Code or Codex you signed in to there, on your own subscription, with no API key. A terminal opened without it never runs one; open another to give tasks. | |
| 41 | 41 | |
| 42 | 42 | - **Start one from the terminal.** Under the terminal, **Give it a task** takes a goal and **Runs with** Claude Code or Codex (Codex on private repositories only). Click **Run with my subscription**. The task's page opens and shows the transcript as it runs. |
| 43 | -- **Run a task again in it.** On a task you started, with the same agent as an open terminal of yours, **Run in my terminal** runs it again there, and **Send to my terminal** sends your follow-up there. | |
| 43 | +- **Run a task again in it.** On a task you started, with the same agent as an open terminal of yours that runs tasks, **Run in my terminal** runs it again there, and **Send to my terminal** sends your follow-up there. | |
| 44 | 44 | |
| 45 | 45 | What happens: |
| 46 | 46 | |
| 47 | 47 | | Step | What Diffinery does | |
| 48 | 48 | | --- | --- | |
| 49 | -| Check | Checks again, on each click, that the terminal is yours, open and acting as the task's agent, that you accepted the notice, and that you and the agent may still write to the repository. | | |
| 50 | -| Checkout | Checks out the task's branch (or the terminal's branch, for a new task) in a directory of its own beside yours, so your checkout and anything you're doing in it are left alone. | | |
| 51 | -| Run | Runs the CLI headless with the goal, as a task run does, with no model key or token: it uses the account you signed in to in the terminal. If it isn't installed yet, Diffinery installs the pinned version first. | | |
| 52 | -| Push | Pushes the work to the task's branch as the agent and opens a pull request, as for any task. Then it removes the task's directory. | | |
| 49 | +| Check | Checks again, on each click, that the terminal is yours, open for tasks and acting as the task's agent, that you accepted the notice, and that you and the agent may still write to the repository. | | |
| 50 | +| Clone | Gives the task a clone of its own of the task's branch (or the terminal's branch, for a new task), beside your checkout. Your checkout and anything you're doing in it are left alone. | | |
| 51 | +| Run | Runs the CLI headless with the goal, in a jail (below), with no model key or token: it signs itself in with the account you signed in to in the terminal. If the CLI isn't installed yet, Diffinery installs the pinned version first, outside the repository. | | |
| 52 | +| Push | Commits what the agent left, in a jail with no network, and takes the new commits out as a git bundle. Your checkout's credential then pushes them to the task's branch as the agent, and Diffinery opens a pull request, as for any task. The task's clone is then removed. | | |
| 53 | 53 | |
| 54 | 54 | Only your own click starts a run in your terminal. Nothing else does: not a coordinator, a schedule, a retry, [Best of N](/docs/agents/tasks#best-of-n) or a failing check. A task whose last run was in a terminal doesn't start a fix run when its checks fail; run it again yourself. A terminal runs one task at a time, it counts as attached while it does (so it doesn't end for being idle), and ending the terminal stops the task. Tasks that plan first or have more than one attempt run in sandboxes of their own. |
| 55 | 55 | |
| 56 | 56 | The task's transcript is kept, as for any task: it is the CLI's output for that run, not what you type in the terminal. |
| 57 | 57 | |
| 58 | -**Give it tasks only in repositories whose contents you trust.** A task runs the agent without asking before each step, in your terminal, where your sign-in is. Anything in the repository the agent reads (a CLAUDE.md, a file, an issue the goal quotes) can steer it, for example into reading your sign-in and sending it somewhere. A task in its own sandbox can't do that: it holds only a token for that run. The notice says this too. | |
| 58 | +### What a task can reach | |
| 59 | 59 | |
| 60 | +The agent runs under [bubblewrap](https://github.com/containers/bubblewrap), inside your terminal's sandbox. Diffinery never sees your sign-in or the agent's traffic: the jail is in the sandbox, not in Diffinery. | |
| 61 | + | |
| 62 | +| In the jail | | | |
| 63 | +| --- | --- | | |
| 64 | +| Files | The task's clone (read and write), your checkout's git objects (read only), and copies of the CLI's own sign-in and settings (`~/.claude/.credentials.json` and `~/.claude.json`, or `~/.codex/auth.json` and `~/.codex/config.toml`). The copies are never written back, so nothing the agent writes there reaches your terminal. If the CLI refreshes its sign-in during a task and the provider retires the old refresh token, the terminal's CLI may ask you to sign in again. Nothing else of your home directory, your checkout or its git config (which holds the sandbox's git credential), or `/tmp`. The rest of the system is read only. | | |
| 65 | +| Processes | Its own: it can't see or signal your shell or anything else in the sandbox. No capabilities. | | |
| 66 | +| Network | None of its own. Its only way out is a proxy outside the jail (set as `HTTPS_PROXY`) that connects only to the hosts below, by name, on port 443. The proxy doesn't decrypt anything. | | |
| 67 | +| Environment | Only what the CLI needs: no sandbox tokens. Web search and fetch are off, and Claude Code is told not to read or edit `~/.claude`, `~/.claude.json`, `~/.codex`, `~/.config` or the checkout's git config. | | |
| 68 | + | |
| 69 | +The hosts: | |
| 70 | + | |
| 71 | +| For | Hosts | | |
| 72 | +| --- | --- | | |
| 73 | +| Claude Code | `api.anthropic.com`, `console.anthropic.com`, `platform.claude.com` | | |
| 74 | +| Codex on a ChatGPT plan | `chatgpt.com`, `auth.openai.com` | | |
| 75 | +| npm and Yarn | `registry.npmjs.org`, `registry.yarnpkg.com` | | |
| 76 | +| PyPI | `pypi.org`, `files.pythonhosted.org` | | |
| 77 | +| Cargo | `index.crates.io`, `static.crates.io` | | |
| 78 | +| RubyGems | `rubygems.org`, `index.rubygems.org` | | |
| 79 | +| Maven Central | `repo1.maven.org`, `repo.maven.apache.org` | | |
| 80 | +| apt | `deb.debian.org`, `security.debian.org`, `archive.ubuntu.com`, `security.ubuntu.com` (also plain HTTP, downloads only) | | |
| 81 | + | |
| 82 | +Anything else is refused, and the refusal shows in the task's transcript. Go modules aren't reachable, because the Go module proxy fetches any address it's given. Vendor them (`go mod vendor`) or run Go tasks in a [sandbox of their own](/docs/agents/tasks). Codex signed in with an API key instead of ChatGPT needs `api.openai.com`, which isn't on the list, so use a ChatGPT sign-in. | |
| 83 | + | |
| 84 | +If the sandbox can't start the jail, the task fails and nothing runs. | |
| 85 | + | |
| 86 | +**Give it tasks only in repositories whose contents you trust.** Anything in the repository the agent reads (a CLAUDE.md, a file, an issue the goal quotes) can steer it, because it runs without asking before each step. The jail limits what a steered agent can do, but not all of it: | |
| 87 | + | |
| 88 | +- **Your sign-in is in the jail.** The CLI needs it there. A shell command the agent runs, such as `cat ~/.claude/.credentials.json`, can read it. The deny rules only cover Claude Code's own file tools, and Codex has no such rules. | |
| 89 | +- **It can send what it read to an allowed host under someone else's account.** For example, the agent could send your sign-in or the repository's code to an AI provider with an attacker's credentials, or publish it to npm or RubyGems with a token planted in the repository. | |
| 90 | +- **The task pushes to this repository.** Anything it read can end up in its branch, its commits or its pull request. | |
| 91 | + | |
| 92 | +A task in its own sandbox has none of these risks: it holds only a model token for that run, which Diffinery's model proxy accepts. The notice you accept says this too. | |
| 93 | + | |
| 60 | 94 | ## Who can reach it |
| 61 | 95 | |
| 62 | 96 | - **Only you.** Nobody else sees your terminal or connects to it: not a repository admin, not the repository's owner, not a site administrator. The terminal may hold your provider sign-in. |
modifiedapps/diffinery/docs/security/audit-log.md+1−1View file
| @@ -89,7 +89,7 @@ | ||
| 89 | 89 | | `task.plan_split` | The person who started a plan-first task hands its plan to their coordinator to split | `owner/repo#<task number>` | `post`, `coordinator_task` | |
| 90 | 90 | | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none | |
| 91 | 91 | | `terminal.terms` | Someone accepts the notice for [terminals](/docs/agents/terminal) | `owner/repo` | `version` | |
| 92 | -| `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `runtimes` (the Claude Code and Codex versions its buttons install) | | |
| 92 | +| `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `tasks` (whether it was opened to run agent tasks), `runtimes` (the Claude Code and Codex versions its buttons install) | | |
| 93 | 93 | | `terminal.task` | Someone runs a task in their [terminal](/docs/agents/terminal#give-it-a-task) | `owner/repo` | `terminal`, `task`, `runtime`, `version` (the pinned version of the CLI) | |
| 94 | 94 | | `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) | |
| 95 | 95 | | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore | |
addedapps/diffinery/migrations/0028_terminal_tasks.sql+3−0View file
| @@ -1,0 +1,3 @@ | ||
| 1 | +-- Whether a web terminal was opened to run agent tasks (phase 4b): only those run one, each in a jail inside the | |
| 2 | +-- terminal's sandbox (containers/runner/src/jail.ts). Terminals opened before this run none. | |
| 3 | +ALTER TABLE agent_terminals ADD COLUMN tasks INTEGER NOT NULL DEFAULT 0; |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+61−15View file
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | import { attemptBranch, isPlanFirst, lineDiff, MAX_ATTEMPTS, MAX_CHECK_FIXES, MAX_PLAN, MAX_POST, MAX_RUNNING_DEFAULT, MEMORY_VERSIONS, projectContext, type MemoryRow, type PickState, type PlanState, type PostRow, type ProjectSettings } from "./project.js"; |
| 14 | 14 | import { COORDINATOR_MAX_MODEL_CALLS, COORDINATOR_TIMEOUT_MS, COORDINATOR_TOKENS_DEFAULT, coordinatorPrompt, parseActions, TASKS_PER_DAY, TASKS_PER_RUN, type CoordinatorSettings } from "./coordinator.js"; |
| 15 | 15 | import { countText, dayAgo, findingComment, rulesFiles, findingId, MAX_AUTOFIXES, placeFindings, REPO_DAILY_DEFAULT, REVIEW_APP, REVIEW_CHECK, REVIEW_CREATOR, REVIEW_MAX_MODEL_CALLS, REVIEW_TIMEOUT_MS, REVIEW_TOKENS_DEFAULT, reviewBody, reviewLimit, reviewOutput, reviewPrompt, fixGoal, shouldMailLimit, type Finding, type PriorFinding, type ReviewLimit, type ReviewSettings } from "./review.js"; |
| 16 | -import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, planPrompt, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js"; | |
| 16 | +import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, planPrompt, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, TERMINAL_CLAUDE_SETTINGS, terminalCommand, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js"; | |
| 17 | 17 | |
| 18 | 18 | /** |
| 19 | 19 | * One per repository: its agent tasks. A task is a goal a person gives one of |
| @@ -1288,7 +1288,8 @@ | ||
| 1288 | 1288 | // In a terminal the CLI is usually there already, signed in: keep it. |
| 1289 | 1289 | const bin = TERMINAL_BIN[t.runtime]; |
| 1290 | 1290 | if (r.terminal_id && bin) install = `command -v ${bin} >/dev/null 2>&1 || { ${install}; }`; |
| 1291 | - if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, r.terminal_id ? null : `Installing ${RUNTIMES[t.runtime].name}.`); | |
| 1291 | + // In a terminal it runs outside the repository: an .npmrc there must not choose what gets installed beside the sign-in. | |
| 1292 | + if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, r.terminal_id ? null : `Installing ${RUNTIMES[t.runtime].name}.`, r.terminal_id ? "/" : undefined); | |
| 1292 | 1293 | const done = await this.poll(r, "setup"); |
| 1293 | 1294 | if (done === null) return; |
| 1294 | 1295 | if (done !== 0) return this.endRun(this.run(r.task, r.seq)!, "failed", `Installing ${RUNTIMES[t.runtime].name} failed (exit ${done}).`); |
| @@ -1296,9 +1297,10 @@ | ||
| 1296 | 1297 | } |
| 1297 | 1298 | case "agent": { |
| 1298 | 1299 | if (!r.proc && r.terminal_id) { |
| 1299 | - // No model token and no proxy: the CLI uses the account the person signed in to in their terminal. | |
| 1300 | - const env = { IS_SANDBOX: "1", DISABLE_AUTOUPDATER: "1", DIFFINERY_TASK_PROMPT: r.prompt }; | |
| 1301 | - return this.startProc(r, "agent", RUNTIMES[t.runtime].command(), env, {}, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null, worktree(r)); | |
| 1300 | + // No model token and no model proxy: the CLI uses the account the person signed in to in their terminal, | |
| 1301 | + // in a jail (the runner's jail.ts) that holds only that sign-in and the task's own clone. | |
| 1302 | + const env = { ...taskDirs(r), DIFFINERY_TASK_PROMPT: r.prompt, DIFFINERY_AGENT_SETTINGS: TERMINAL_CLAUDE_SETTINGS }; | |
| 1303 | + return this.startProc(r, "agent", `"$DIFFINERY_JAIL" agent --dir "$DIFFINERY_WORKTREE" --out "$DIFFINERY_TASK_OUT" --cli ${t.runtime} -- ${terminalCommand(t.runtime)}`, env, {}, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null); | |
| 1302 | 1304 | } |
| 1303 | 1305 | if (!r.proc) { |
| 1304 | 1306 | // The key itself never enters the sandbox: the runtime gets a token for this run that only the model proxy accepts. |
| @@ -1318,6 +1320,7 @@ | ||
| 1318 | 1320 | const run = this.run(r.task, r.seq)!; |
| 1319 | 1321 | // The agent is done: its model token stops working now, before anything else runs. |
| 1320 | 1322 | this.sql.exec("UPDATE runs SET exit_code = ?, token_hash = NULL WHERE task = ? AND seq = ?", done, r.task, r.seq); |
| 1323 | + if (r.terminal_id && done === JAIL_FAILED) return this.endRun(this.run(r.task, r.seq)!, "failed", "The task's jail couldn't start in this terminal's sandbox, so nothing ran. The transcript says why."); | |
| 1321 | 1324 | this.note(r.task, r.seq, `${done === 0 ? `${RUNTIMES[t.runtime].name} finished.` : `${RUNTIMES[t.runtime].name} exited with code ${done}.`}${run.model_calls ? ` ${run.model_calls} model ${run.model_calls === 1 ? "request" : "requests"}, ${formatTokens(run.tokens)} tokens.` : ""}`); |
| 1322 | 1325 | return this.setStep(run, "finish"); |
| 1323 | 1326 | } |
| @@ -1327,7 +1330,7 @@ | ||
| 1327 | 1330 | if (r.kind === "plan") return this.keepPlan(t, r); |
| 1328 | 1331 | const branch = r.branch ?? t.branch; |
| 1329 | 1332 | if (!r.proc) { |
| 1330 | - return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`, r.terminal_id ? worktree(r) : undefined); | |
| 1333 | + return this.startProc(r, "finish", r.terminal_id ? TERMINAL_FINISH_SCRIPT : FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r), ...(r.terminal_id ? { ...taskDirs(r), DIFFINERY_TASK_COMMIT: TERMINAL_COMMIT_SCRIPT } : {}) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`); | |
| 1331 | 1334 | } |
| 1332 | 1335 | const done = await this.poll(r, "setup"); |
| 1333 | 1336 | if (done === null) return; |
| @@ -1383,8 +1386,9 @@ | ||
| 1383 | 1386 | |
| 1384 | 1387 | /** |
| 1385 | 1388 | * A run in the person's web terminal (phase 4b): no sandbox of its own. It checks the terminal is still theirs and |
| 1386 | - * open, and the opt-in, then checks out the task's branch in a separate directory there (a git worktree), so the | |
| 1387 | - * person's own checkout is left alone. | |
| 1389 | + * open, and the opt-in, then gives the task its own clone there, beside the person's checkout and borrowing its | |
| 1390 | + * objects (`git clone --shared`): the person's checkout and its .git, which holds the sandbox's git credential, | |
| 1391 | + * stay out of the task's jail. | |
| 1388 | 1392 | */ |
| 1389 | 1393 | private async terminalBoot(t: TaskRow, r: RunRow): Promise<void> { |
| 1390 | 1394 | await this.checkAccess(t); |
| @@ -1394,12 +1398,20 @@ | ||
| 1394 | 1398 | const ref = branchSha ? t.branch : t.base; |
| 1395 | 1399 | const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`)); |
| 1396 | 1400 | if (!startSha) throw new HttpError(422, `the branch ${t.base} doesn't exist`); |
| 1397 | - this.note(t.number, r.seq, `Checking out ${ref} in ${t.person_login}'s terminal, in its own directory.`); | |
| 1398 | - const wt = worktree(r); | |
| 1401 | + this.note(t.number, r.seq, `Checking out ${ref} in ${t.person_login}'s terminal, in a clone of its own.`); | |
| 1399 | 1402 | // Branch names may hold `$`, backticks, `;` or `|`: everything reaches the script through its environment, quoted. |
| 1400 | 1403 | const prepared = (await this.sandbox(r, "/exec", { |
| 1401 | - cmd: ["set -e", 'git fetch -q origin "refs/heads/$DIFFINERY_TASK_REF"', 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null || rm -rf "$DIFFINERY_WORKTREE"', "git worktree prune", 'git worktree add -q --detach "$DIFFINERY_WORKTREE" "$DIFFINERY_TASK_START"'].join("\n"), | |
| 1402 | - env: { DIFFINERY_TASK_REF: ref, DIFFINERY_WORKTREE: wt, DIFFINERY_TASK_START: startSha }, | |
| 1404 | + cmd: [ | |
| 1405 | + "set -e", | |
| 1406 | + 'g() { git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@"; }', | |
| 1407 | + 'g fetch -q --no-write-fetch-head origin "refs/heads/$DIFFINERY_TASK_REF"', | |
| 1408 | + 'rm -rf "$DIFFINERY_WORKTREE" "$DIFFINERY_TASK_OUT"', | |
| 1409 | + 'g clone -q --shared --no-checkout --no-tags . "$DIFFINERY_WORKTREE"', | |
| 1410 | + 'g -C "$DIFFINERY_WORKTREE" checkout -q --detach "$DIFFINERY_TASK_START"', | |
| 1411 | + 'g -C "$DIFFINERY_WORKTREE" remote remove origin', | |
| 1412 | + 'mkdir "$DIFFINERY_TASK_OUT"', | |
| 1413 | + ].join("\n"), | |
| 1414 | + env: { DIFFINERY_TASK_REF: ref, DIFFINERY_TASK_START: startSha, ...taskDirs(r) }, | |
| 1403 | 1415 | timeoutMs: 5 * 60_000, |
| 1404 | 1416 | })) as { exitCode: number; stderr: string }; |
| 1405 | 1417 | if (prepared.exitCode !== 0) throw new HttpError(422, `checking out ${ref} in the terminal failed: ${prepared.stderr.trim().split("\n").slice(-3).join(" ")}`); |
| @@ -1412,6 +1424,7 @@ | ||
| 1412 | 1424 | const terminals = new Terminals(this.env); |
| 1413 | 1425 | const term = await terminals.get(r.terminal_id!); |
| 1414 | 1426 | if (!term || term.status !== "running" || term.sandbox_id !== r.sandbox_id || term.person_id !== t.person_id || term.agent_id !== t.agent_id) throw new TaskAccessError("the terminal it ran in is no longer open"); |
| 1427 | + if (!term.tasks) throw new TaskAccessError("the terminal was opened without agent tasks"); | |
| 1415 | 1428 | if (!(await terminals.termsAt(t.person_id))) throw new TaskAccessError(`${t.person_login} hasn't accepted the notice for terminals`); |
| 1416 | 1429 | const block = blockedBy(await killSwitches(this.env, t.repo_id), "sandbox"); |
| 1417 | 1430 | if (block) throw new TaskAccessError(block); |
| @@ -1850,7 +1863,7 @@ | ||
| 1850 | 1863 | if (r.sandbox_id) { |
| 1851 | 1864 | if (r.proc) await this.sandbox(r, "/proc/kill", { id: r.proc, signal: "SIGKILL" }).catch(() => {}); |
| 1852 | 1865 | // A terminal's sandbox stays the person's: only the run's own checkout goes. |
| 1853 | - if (r.terminal_id) await this.sandbox(r, "/exec", { cmd: 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null; rm -rf "$DIFFINERY_WORKTREE"; git worktree prune', env: { DIFFINERY_WORKTREE: worktree(r) }, timeoutMs: 60_000 }).catch(() => {}); | |
| 1866 | + if (r.terminal_id) await this.sandbox(r, "/exec", { cmd: 'rm -rf "$DIFFINERY_WORKTREE" "$DIFFINERY_TASK_OUT"', env: taskDirs(r), timeoutMs: 60_000 }).catch(() => {}); | |
| 1854 | 1867 | else |
| 1855 | 1868 | await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)) |
| 1856 | 1869 | .closeTaskSandbox(r.sandbox_id) |
| @@ -1950,9 +1963,15 @@ | ||
| 1950 | 1963 | } |
| 1951 | 1964 | } |
| 1952 | 1965 | |
| 1953 | -/** A terminal run's own checkout, beside the terminal's (relative to the sandbox's repository directory). */ | |
| 1954 | -const worktree = (r: RunRow): string => `../task-${r.task}-${r.seq}`; | |
| 1966 | +/** | |
| 1967 | + * A terminal run's own clone and the directory its finish writes a bundle to, beside the terminal's checkout | |
| 1968 | + * (relative to the sandbox's repository directory, where its commands run). | |
| 1969 | + */ | |
| 1970 | +const taskDirs = (r: RunRow): { DIFFINERY_WORKTREE: string; DIFFINERY_TASK_OUT: string } => ({ DIFFINERY_WORKTREE: `../task-${r.task}-${r.seq}`, DIFFINERY_TASK_OUT: `../task-${r.task}-${r.seq}.out` }); | |
| 1955 | 1971 | |
| 1972 | +/** What the runner's jail exits with when it couldn't start (containers/runner/src/jail.ts). */ | |
| 1973 | +const JAIL_FAILED = 125; | |
| 1974 | + | |
| 1956 | 1975 | /** The CLI a terminal run looks for before installing it. */ |
| 1957 | 1976 | const TERMINAL_BIN: Partial<Record<RuntimeId, string>> = { "claude-code": "claude", codex: "codex" }; |
| 1958 | 1977 | |
| @@ -1983,3 +2002,30 @@ | ||
| 1983 | 2002 | 'git push -q origin "HEAD:refs/heads/$DIFFINERY_TASK_BRANCH" 2>&1', |
| 1984 | 2003 | 'echo "diffinery: pushed $head"', |
| 1985 | 2004 | ].join("\n"); |
| 2005 | + | |
| 2006 | +/** | |
| 2007 | + * A terminal run's finish. The agent's clone is the agent's: anything in it (hooks, config, filters) is its to | |
| 2008 | + * set, so it is only touched in a jail with no network and no credential, which commits what's left and writes | |
| 2009 | + * a bundle of the new commits. Outside, the terminal's checkout (which has the credential) takes the bundle's | |
| 2010 | + * objects as data and pushes them, with no hooks and no fsmonitor. | |
| 2011 | + */ | |
| 2012 | +const TERMINAL_COMMIT_SCRIPT = [ | |
| 2013 | + "set -e", | |
| 2014 | + "git add -A", | |
| 2015 | + 'if ! git diff --cached --quiet; then git commit -q -m "$DIFFINERY_TASK_MESSAGE"; fi', | |
| 2016 | + 'if [ "$(git rev-parse HEAD)" != "$DIFFINERY_TASK_START" ]; then git bundle create -q "$DIFFINERY_TASK_OUT/task.bundle" HEAD "^$DIFFINERY_TASK_START"; fi', | |
| 2017 | +].join("\n"); | |
| 2018 | + | |
| 2019 | +const TERMINAL_FINISH_SCRIPT = [ | |
| 2020 | + "set -e", | |
| 2021 | + 'rm -rf "$DIFFINERY_TASK_OUT/task.bundle"', | |
| 2022 | + '"$DIFFINERY_JAIL" finish --dir "$DIFFINERY_WORKTREE" --out "$DIFFINERY_TASK_OUT" -- sh -c "$DIFFINERY_TASK_COMMIT"', | |
| 2023 | + 'b="$DIFFINERY_TASK_OUT/task.bundle"', | |
| 2024 | + 'if [ -L "$b" ] || [ ! -f "$b" ]; then echo "diffinery: nothing to push"; exit 0; fi', | |
| 2025 | + 'g() { git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@"; }', | |
| 2026 | + 'head=$(g bundle list-heads "$b" HEAD | cut -d" " -f1)', | |
| 2027 | + 'case "$head" in *[!0-9a-f]* | "") echo "diffinery: the bundle has no commit to push" >&2; exit 1 ;; esac', | |
| 2028 | + 'g -c transfer.fsckObjects=true fetch -q --no-write-fetch-head "$b" HEAD', | |
| 2029 | + 'g push -q origin "$head:refs/heads/$DIFFINERY_TASK_BRANCH" 2>&1', | |
| 2030 | + 'echo "diffinery: pushed $head"', | |
| 2031 | +].join("\n"); |
modifiedapps/diffinery/src/agentic/runtimes.ts+20−0View file
| @@ -71,6 +71,26 @@ | ||
| 71 | 71 | }, |
| 72 | 72 | }; |
| 73 | 73 | |
| 74 | +/** | |
| 75 | + * Where a terminal keeps what a task there must not read: the providers' sign-ins and the sandbox's git | |
| 76 | + * credential (an http extraheader in the checkout's config). Deny rules bind Claude Code's own file tools, | |
| 77 | + * not its Bash; terminal.md says so. | |
| 78 | + */ | |
| 79 | +const TERMINAL_SECRETS = ["~/.claude/**", "~/.claude.json", "~/.codex/**", "~/.config/**", "//workspace/repo/.git/config"]; | |
| 80 | + | |
| 81 | +/** Claude Code's settings for a task in a terminal (passed as `--settings`): no reading or editing those files. */ | |
| 82 | +export const TERMINAL_CLAUDE_SETTINGS = JSON.stringify({ permissions: { deny: TERMINAL_SECRETS.flatMap((p) => [`Read(${p})`, `Edit(${p})`]) } }); | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * A task in a person's web terminal (phase 4b), on the CLI signed in there: no web tools, since they run on | |
| 86 | + * the provider, outside the task's jail and its host allowlist (containers/runner/src/jail.ts), and for Claude Code the deny rules above (in | |
| 87 | + * $DIFFINERY_AGENT_SETTINGS, so nothing is quoted into the command). | |
| 88 | + */ | |
| 89 | +export function terminalCommand(runtime: RuntimeId): string { | |
| 90 | + if (runtime === "claude-code") return `${RUNTIMES["claude-code"].command(undefined, undefined, true)} --settings "$DIFFINERY_AGENT_SETTINGS"`; | |
| 91 | + return RUNTIMES[runtime].command(undefined, undefined, true); | |
| 92 | +} | |
| 93 | + | |
| 74 | 94 | export const isRuntime = (r: string): r is RuntimeId => r === "claude-code" || r === "codex" || r === "custom"; |
| 75 | 95 | |
| 76 | 96 | /** The model a runtime asks OpenRouter for unless the person names another. */ |
modifiedapps/diffinery/src/agentic/terminal-web.tsx+16−6View file
| @@ -65,7 +65,7 @@ | ||
| 65 | 65 | </> |
| 66 | 66 | ); |
| 67 | 67 | |
| 68 | -async function listPage(c: AppContext, l: Loaded, error?: string, v: { agent?: string; ref?: string } = {}) { | |
| 68 | +async function listPage(c: AppContext, l: Loaded, error?: string, v: { agent?: string; ref?: string; tasks?: string } = {}) { | |
| 69 | 69 | const me = requireAccount(c); |
| 70 | 70 | const base = repoUrl(l.repo); |
| 71 | 71 | const tk = csrfToken(c); |
| @@ -136,6 +136,12 @@ | ||
| 136 | 136 | <KSelect id="ref" name="ref" block label="Branch" value={v.ref ?? l.repo.default_branch} options={branches.slice(0, 500).map((b) => ({ value: b.name.slice("refs/heads/".length), label: b.name.slice("refs/heads/".length) }))} /> |
| 137 | 137 | </div> |
| 138 | 138 | </div> |
| 139 | + <div class="field"> | |
| 140 | + <KCheckbox name="tasks" label="Will run agent tasks" checked={v.tasks === "1"} /> | |
| 141 | + <div class="hint"> | |
| 142 | + Lets you give it agent tasks that run with the Claude Code or Codex you sign in to there, in a jail with only that sign-in and limited internet. <a href="/docs/agents/terminal#give-it-a-task">How tasks run</a>. Without it, the terminal never runs one. | |
| 143 | + </div> | |
| 144 | + </div> | |
| 139 | 145 | {termsAt ? ( |
| 140 | 146 | <p class="muted small"> |
| 141 | 147 | You accepted the notice about your own provider accounts <Time at={termsAt} />. |
| @@ -147,7 +153,7 @@ | ||
| 147 | 153 | label={ |
| 148 | 154 | <> |
| 149 | 155 | I use Claude Code and Codex in my terminals with my own Anthropic or OpenAI account, under that provider's terms (<TermsLinks /> |
| 150 | - ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in. A task I give a terminal runs the agent without asking before each step, in that terminal, where my sign-in is. Content in the repository can steer it, so I give it tasks only in repositories whose contents I trust. | |
| 156 | + ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in. A task I give a terminal I opened for tasks runs the agent without asking before each step, in a jail in that terminal's sandbox, with my sign-in. Content in the repository can steer it: it can read my sign-in, and send it or the repository's code to an AI provider or package registry it can reach, under someone else's account. So I give tasks only in repositories whose contents I trust. | |
| 151 | 157 | </> |
| 152 | 158 | } |
| 153 | 159 | /> |
| @@ -237,7 +243,11 @@ | ||
| 237 | 243 | <h3>Give it a task</h3> |
| 238 | 244 | </div> |
| 239 | 245 | <div class="body"> |
| 240 | - {current ? ( | |
| 246 | + {!t.tasks ? ( | |
| 247 | + <p class="m0 muted"> | |
| 248 | + This terminal was opened without agent tasks, so it never runs one. To give tasks to the Claude Code or Codex you sign in to, <a href={`${base}/terminals`}>open a terminal</a> with <b>Will run agent tasks</b>. | |
| 249 | + </p> | |
| 250 | + ) : current ? ( | |
| 241 | 251 | <p class="m0"> |
| 242 | 252 | <a href={`${base}/tasks/${current}`}>Task #{current}</a> is running in this terminal. One task at a time: it ends when the task does, or when you stop it. |
| 243 | 253 | </p> |
| @@ -257,7 +267,7 @@ | ||
| 257 | 267 | /> |
| 258 | 268 | </div> |
| 259 | 269 | <p class="muted small"> |
| 260 | - It runs here with the one you're signed in to, on your own subscription, as {agent?.login ?? "your agent"}, in a checkout of {t.ref} of its own (yours is left alone). Diffinery pushes its work to a new branch and opens a pull request. Its checks don't start fix runs by themselves. | |
| 270 | + It runs here with the one you're signed in to, on your own subscription, as {agent?.login ?? "your agent"}, in a clone of {t.ref} of its own (yours is left alone), jailed: it sees only that clone and your sign-in, and reaches only Claude, ChatGPT and package registries. Diffinery pushes its work to a new branch and opens a pull request. Its checks don't start fix runs by themselves. <a href="/docs/agents/terminal#what-a-task-can-reach">What a task can reach</a> | |
| 261 | 271 | </p> |
| 262 | 272 | <button class="btn primary">Run with my subscription</button> |
| 263 | 273 | </Form> |
| @@ -300,9 +310,9 @@ | ||
| 300 | 310 | |
| 301 | 311 | post(web, "/:owner/:repo/terminals", async (c, f) => { |
| 302 | 312 | const l = await forTerminals(c); |
| 303 | - const v = { agent: f.s("agent"), ref: f.s("ref") }; | |
| 313 | + const v = { agent: f.s("agent"), ref: f.s("ref"), tasks: f.all("tasks").length > 0 ? "1" : undefined }; | |
| 304 | 314 | try { |
| 305 | - const t = await openTerminal(c, l, { ...v, accept: f.all("accept").length > 0 }); | |
| 315 | + const t = await openTerminal(c, l, { agent: v.agent, ref: v.ref, accept: f.all("accept").length > 0, tasks: !!v.tasks }); | |
| 306 | 316 | return c.redirect(`${repoUrl(l.repo)}/terminals/${t.id}`, 303); |
| 307 | 317 | } catch (e) { |
| 308 | 318 | const st = statusOf(e); |
modifiedapps/diffinery/src/agentic/terminals.ts+12−9View file
| @@ -30,7 +30,7 @@ | ||
| 30 | 30 | export const TERMINAL_IDLE_MS = 30 * 60_000; |
| 31 | 31 | export const TERMINAL_MAX_MS = 8 * 3600_000; |
| 32 | 32 | /** Bump when the notice changes: everyone opts in again. */ |
| 33 | -export const TERMINAL_TERMS_VERSION = 2; | |
| 33 | +export const TERMINAL_TERMS_VERSION = 3; | |
| 34 | 34 | |
| 35 | 35 | export type TerminalStatus = "starting" | "running" | "ended"; |
| 36 | 36 | export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended"; |
| @@ -47,6 +47,8 @@ | ||
| 47 | 47 | started_at: string | null; |
| 48 | 48 | ended_at: string | null; |
| 49 | 49 | end_reason: string | null; |
| 50 | + /** 1 when the person opened it to run agent tasks (phase 4b); a terminal opened without it never runs one. */ | |
| 51 | + tasks: number; | |
| 50 | 52 | } |
| 51 | 53 | |
| 52 | 54 | export const TERMINAL_END_TEXT: Record<TerminalEndReason, string> = { |
| @@ -111,11 +113,11 @@ | ||
| 111 | 113 | return at; |
| 112 | 114 | } |
| 113 | 115 | |
| 114 | - async insert(t: Pick<TerminalRow, "repo_id" | "person_id" | "agent_id" | "ref">): Promise<TerminalRow> { | |
| 115 | - const row: TerminalRow = { ...t, id: newId(), status: "starting", sandbox_id: null, created_at: now(), started_at: null, ended_at: null, end_reason: null }; | |
| 116 | + async insert(t: Pick<TerminalRow, "repo_id" | "person_id" | "agent_id" | "ref" | "tasks">): Promise<TerminalRow> { | |
| 117 | + const row: TerminalRow = { ...t, id: newId(), status: "starting", sandbox_id: null, created_at: now(), started_at: null, ended_at: null, end_reason: null, tasks: t.tasks }; | |
| 116 | 118 | await this.db |
| 117 | - .prepare("INSERT INTO agent_terminals (id, repo_id, person_id, agent_id, ref, status, sandbox_id, created_at) VALUES (?, ?, ?, ?, ?, 'starting', NULL, ?)") | |
| 118 | - .bind(row.id, row.repo_id, row.person_id, row.agent_id, row.ref, row.created_at) | |
| 119 | + .prepare("INSERT INTO agent_terminals (id, repo_id, person_id, agent_id, ref, status, sandbox_id, created_at, tasks) VALUES (?, ?, ?, ?, ?, 'starting', NULL, ?, ?)") | |
| 120 | + .bind(row.id, row.repo_id, row.person_id, row.agent_id, row.ref, row.created_at, row.tasks) | |
| 119 | 121 | .run(); |
| 120 | 122 | return row; |
| 121 | 123 | } |
| @@ -140,7 +142,7 @@ | ||
| 140 | 142 | * Opens a terminal for the person in this browser session. The sandbox starts in the project's |
| 141 | 143 | * alarm (it can take a minute); the page shows "Starting" until then. |
| 142 | 144 | */ |
| 143 | -export async function openTerminal(c: AppContext, l: Loaded, input: { agent?: string; ref?: string; accept?: boolean }): Promise<TerminalRow> { | |
| 145 | +export async function openTerminal(c: AppContext, l: Loaded, input: { agent?: string; ref?: string; accept?: boolean; tasks?: boolean }): Promise<TerminalRow> { | |
| 144 | 146 | const me = requirePersonSession(c); |
| 145 | 147 | if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery"); |
| 146 | 148 | if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); |
| @@ -171,8 +173,8 @@ | ||
| 171 | 173 | if (block) throw new HttpError(403, block); |
| 172 | 174 | const quota = await mayStart(c.env, l.repo.owner_id, "sandbox"); |
| 173 | 175 | if (!quota.ok) throw new HttpError(429, quota.message); |
| 174 | - const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref }); | |
| 175 | - audit(c, me, l.repo, "terminal.open", { terminal: t.id, agent: agent.login, ref, runtimes: { "claude-code": RUNTIME_VERSIONS["claude-code"], codex: RUNTIME_VERSIONS.codex } }); | |
| 176 | + const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref, tasks: input.tasks ? 1 : 0 }); | |
| 177 | + audit(c, me, l.repo, "terminal.open", { terminal: t.id, agent: agent.login, ref, tasks: !!input.tasks, runtimes: { "claude-code": RUNTIME_VERSIONS["claude-code"], codex: RUNTIME_VERSIONS.codex } }); | |
| 176 | 178 | await projectStub(c.env, l.repo.id).wakeTerminals(l.repo.id); |
| 177 | 179 | return t; |
| 178 | 180 | } |
| @@ -217,6 +219,7 @@ | ||
| 217 | 219 | const terminals = new Terminals(c.env); |
| 218 | 220 | const t = await terminals.mine(terminalId(id), me, l.repo); |
| 219 | 221 | if (t.status !== "running" || !t.sandbox_id) throw new HttpError(409, "the terminal isn't running"); |
| 222 | + if (!t.tasks) throw new HttpError(409, "this terminal was opened without agent tasks: open one with \"Will run agent tasks\" to give it one"); | |
| 220 | 223 | if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account"); |
| 221 | 224 | // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile. |
| 222 | 225 | const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox"); |
| @@ -281,7 +284,7 @@ | ||
| 281 | 284 | if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null; |
| 282 | 285 | if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null; |
| 283 | 286 | const terminals = new Terminals(env); |
| 284 | - const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.agent_id === task.agent_id); | |
| 287 | + const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.tasks && t.agent_id === task.agent_id); | |
| 285 | 288 | if (!open.length || !(await terminals.termsAt(me.id))) return null; |
| 286 | 289 | const project = projectStub(env, repo.id); |
| 287 | 290 | for (const t of open) if ((await project.terminalTask(t.id)) === null) return t.id; |
modifiedapps/diffinery/test/e2e/agents-harness.mjs+17−1View file
| @@ -126,14 +126,30 @@ | ||
| 126 | 126 | 'd=$(basename "$PWD")', |
| 127 | 127 | 'echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"signed-in claude: key=${ANTHROPIC_API_KEY:-none} base=${ANTHROPIC_BASE_URL:-none} dir=$d\\"}]}}"', |
| 128 | 128 | 'printf "%s\\n" "$DIFFINERY_TASK_PROMPT"', |
| 129 | + 'printf "args: %s\\n" "$*"', | |
| 130 | + // What its jail lets it see and reach (containers/runner/src/jail.ts). | |
| 131 | + 'printf "jail: proxy=%s\\n" "$HTTPS_PROXY"', | |
| 132 | + 'printf "jail: signin=%s\\n" "$(cat "$HOME/.claude/.credentials.json" 2>/dev/null || echo none)"', | |
| 133 | + // Writing another account's credentials over the sign-in changes only the jail's copy. | |
| 134 | + 'echo \'{"attacker":"signin"}\' > "$HOME/.claude/.credentials.json"', | |
| 135 | + 'printf "jail: history=%s\\n" "$(cat "$HOME/.bash_history" 2>/dev/null || echo hidden)"', | |
| 136 | + 'printf "jail: credential=%s\\n" "$(cat ../repo/.git/config 2>/dev/null || echo hidden)"', | |
| 137 | + 'printf "jail: direct=%s\\n" "$(node -e "require(\\"net\\").connect(80, \\"1.1.1.1\\").on(\\"connect\\", () => (console.log(\\"open\\"), process.exit())).on(\\"error\\", (e) => (console.log(e.code), process.exit()))")"', | |
| 138 | + // A repository can set config that runs commands; in the task's clone it only ever runs in a jail. | |
| 139 | + "git config core.fsmonitor 'touch ../pwned-fsmonitor; false'", | |
| 129 | 140 | 'echo "made in the terminal" > terminal.txt', |
| 130 | 141 | 'echo \'{"type":"result","subtype":"success","result":"Done in the terminal."}\'', |
| 131 | 142 | ].join("\n"), |
| 132 | 143 | { mode: 0o755 }, |
| 133 | 144 | ); |
| 145 | + // The pool's home is the terminal's: a provider sign-in, and shell history that a task's jail must not show. | |
| 146 | + const poolHome = join(work, "home"); | |
| 147 | + mkdirSync(join(poolHome, ".claude"), { recursive: true }); | |
| 148 | + writeFileSync(join(poolHome, ".claude", ".credentials.json"), '{"fake":"signin"}'); | |
| 149 | + writeFileSync(join(poolHome, ".bash_history"), "HISTORY-SECRET\n"); | |
| 134 | 150 | const poolPort = await freePort(); |
| 135 | 151 | const poolSecret = randomBytes(16).toString("hex"); |
| 136 | - pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true }); | |
| 152 | + pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, HOME: poolHome, PATH: `${fakeBin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true }); | |
| 137 | 153 | pool.stdout.on("data", (d) => (poolLog += d)); |
| 138 | 154 | pool.stderr.on("data", (d) => (poolLog += d)); |
| 139 | 155 | await until("sandbox pool", async () => poolLog.includes("listening"), 30_000); |
modifiedapps/diffinery/test/e2e/terminals.test.mjs+27−3View file
| @@ -210,7 +210,7 @@ | ||
| 210 | 210 | const EVIL = "t$(touch${IFS}injected)"; |
| 211 | 211 | const mainSha = (await ok("GET", `/repos/${owner}/term-tasks/git/ref/heads/main`)).object.sha; |
| 212 | 212 | await ok("POST", `/repos/${owner}/term-tasks/git/refs`, { ref: `refs/heads/${EVIL}`, sha: mainSha }); |
| 213 | - const opened = await form("terminals", { agent: "term-hand", ref: EVIL, accept: "1" }); | |
| 213 | + const opened = await form("terminals", { agent: "term-hand", ref: EVIL, accept: "1", tasks: "1" }); | |
| 214 | 214 | assert.equal(opened.status, 303, await opened.text()); |
| 215 | 215 | const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop(); |
| 216 | 216 | const running = await until("the terminal to start", async () => { |
| @@ -229,6 +229,19 @@ | ||
| 229 | 229 | assert.deepEqual(await ok("GET", R), [], "someone else's click starts nothing"); |
| 230 | 230 | const viaToken = await api("POST", R, { goal: "Do it", runtime: "claude-code", agent: "term-hand", terminal: id }); |
| 231 | 231 | assert.equal(viaToken.status, 403, "a token starts no task, in a terminal or not"); |
| 232 | + // A terminal opened without "Will run agent tasks" never runs one. | |
| 233 | + const plainOpened = await form("terminals", { agent: "term-hand", ref: "main" }); | |
| 234 | + assert.equal(plainOpened.status, 303, await plainOpened.text()); | |
| 235 | + const plainId = new URL(plainOpened.headers.get("location"), base).pathname.split("/").pop(); | |
| 236 | + const plain = await until("the plain terminal to start", async () => { | |
| 237 | + const p = await page(`terminals/${plainId}`); | |
| 238 | + return p.text.includes("data-socket") ? p : null; | |
| 239 | + }); | |
| 240 | + assert.match(plain.text, /opened without agent tasks/); | |
| 241 | + assert.doesNotMatch(plain.text, /Run with my subscription/); | |
| 242 | + assert.equal((await form(`terminals/${plainId}/task`, { goal: "Do it", runtime: "claude-code" })).status, 303); | |
| 243 | + assert.deepEqual(await ok("GET", R), [], "a terminal opened without tasks runs none"); | |
| 244 | + assert.equal((await form(`terminals/${plainId}/end`, {})).status, 303); | |
| 232 | 245 | // Only Claude Code or Codex, as signed in there. |
| 233 | 246 | const custom = await form(`terminals/${id}/task`, { goal: "Do it", runtime: "custom" }); |
| 234 | 247 | assert.equal(custom.status, 303); |
| @@ -251,6 +264,14 @@ | ||
| 251 | 264 | assert.ok(!/base=[^ ]*\/-\/model\//.test(text), "nor the model proxy"); |
| 252 | 265 | assert.match(text, new RegExp(`dir=task-${n}-1`), "in a checkout of its own"); |
| 253 | 266 | assert.match(text, /\[out\] Add a terminal file/, "the goal reaches it"); |
| 267 | + assert.match(text, /--permission-mode bypassPermissions --disallowedTools WebSearch,WebFetch --settings \{/, "no web tools"); | |
| 268 | + assert.match(text, /"Read\(~\/\.claude\/\*\*\)"/, "and deny rules on the sign-ins"); | |
| 269 | + // Its jail: the CLI's own sign-in, the proxy as its only way out, and nothing else of the terminal's. | |
| 270 | + assert.match(text, /jail: proxy=http:\/\/127\.0\.0\.1:\d+/); | |
| 271 | + assert.match(text, /jail: signin=\{"fake":"signin"\}/, "the CLI signs itself in"); | |
| 272 | + assert.match(text, /jail: history=hidden/, "the rest of the home directory isn't there"); | |
| 273 | + assert.match(text, /jail: credential=hidden/, "nor the checkout's git config, with the sandbox's credential"); | |
| 274 | + assert.match(text, /jail: direct=(ENETUNREACH|EHOSTUNREACH|ECONNREFUSED)/, "and there is no route out but the proxy"); | |
| 254 | 275 | const file = await ok("GET", `/repos/${owner}/term-tasks/contents/terminal.txt?ref=${encodeURIComponent(done.branch)}`); |
| 255 | 276 | assert.equal(Buffer.from(file.content, "base64").toString(), "made in the terminal\n"); |
| 256 | 277 | assert.equal(done.runs[0].in_terminal, true); |
| @@ -259,10 +280,13 @@ | ||
| 259 | 280 | // The terminal's own checkout is untouched, and the terminal is still mine to use. |
| 260 | 281 | const t = await termSocket(`${base.replace("http", "ws")}/${owner}/term-tasks/terminals/${id}/socket?cols=100&rows=30`, { origin: new URL(base).origin }); |
| 261 | 282 | assert.equal(t.status, 101); |
| 262 | - t.type("ls terminal.txt 2>&1 | head -1; ls injected ../injected 2>&1 | grep -c 'No such file'; git status --short | wc -l; ls .. | grep -c task- ; echo check-$((6*7))\n"); | |
| 283 | + t.type("cat ~/.claude/.credentials.json; echo; ls terminal.txt 2>&1 | head -1; ls injected ../injected ../pwned-fsmonitor 2>&1 | grep -c 'No such file'; git status --short | wc -l; ls .. | grep -c task- ; echo check-$((6*7))\n"); | |
| 263 | 284 | await t.until(/check-42/); |
| 264 | 285 | assert.match(t.out(), /No such file/); |
| 265 | - assert.match(t.out(), /\n2\r?\n0\r?\n0\r?\n/, "the branch name ran nothing"); | |
| 286 | + assert.match(t.out(), /\{"fake":"signin"\}/, "the terminal's own sign-in is untouched by what the task wrote"); | |
| 287 | + assert.doesNotMatch(t.out(), /attacker/); | |
| 288 | + assert.equal(readFileSync(join(work, "home", ".claude", ".credentials.json"), "utf8"), '{"fake":"signin"}', "byte for byte"); | |
| 289 | + assert.match(t.out(), /\n3\r?\n0\r?\n0\r?\n/, "the branch name ran nothing, nor the clone's config outside its jail"); | |
| 266 | 290 | assert.match(t.out(), /\n0\r?\n0\r?\n/, "a clean checkout, and the task's checkout removed"); |
| 267 | 291 | |
| 268 | 292 | // Its page offers to run it again there; doing so is another run in the terminal. |
addedapps/diffinery/test/unit/terminal-command.test.ts+15−0View file
| @@ -1,0 +1,15 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { TERMINAL_CLAUDE_SETTINGS, terminalCommand } from "../../src/agentic/runtimes.js"; | |
| 3 | + | |
| 4 | +describe("a task in a web terminal", () => { | |
| 5 | + it("runs the agent without web tools, and Claude Code without reading the sign-ins or the git credential", () => { | |
| 6 | + expect(terminalCommand("claude-code")).toContain('--disallowedTools "WebSearch,WebFetch"'); | |
| 7 | + expect(terminalCommand("claude-code")).toContain('--settings "$DIFFINERY_AGENT_SETTINGS"'); | |
| 8 | + expect(terminalCommand("codex")).toContain("-c web_search=disabled"); | |
| 9 | + const deny: string[] = JSON.parse(TERMINAL_CLAUDE_SETTINGS).permissions.deny; | |
| 10 | + for (const p of ["~/.claude/**", "~/.claude.json", "~/.codex/**", "~/.config/**", "//workspace/repo/.git/config"]) { | |
| 11 | + expect(deny).toContain(`Read(${p})`); | |
| 12 | + expect(deny).toContain(`Edit(${p})`); | |
| 13 | + } | |
| 14 | + }); | |
| 15 | +}); |
modifiedcontainers/runner/Dockerfile+1−0View file
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | build-essential pkg-config openssh-client gnupg \ |
| 30 | 30 | python3 python3-pip python3-venv python-is-python3 \ |
| 31 | 31 | libyaml-dev libssl-dev libffi-dev zlib1g-dev libreadline-dev libgmp-dev \ |
| 32 | + bubblewrap \ | |
| 32 | 33 | && git config --system --add safe.directory '*' \ |
| 33 | 34 | && git lfs install --system --skip-repo \ |
| 34 | 35 | && apt-get clean \ |
addedcontainers/runner/src/jail.ts+305−0View file
| @@ -1,0 +1,305 @@ | ||
| 1 | +import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; | |
| 2 | +import { spawn, spawnSync } from "node:child_process"; | |
| 3 | +import { createServer, request as httpRequest, type Server } from "node:http"; | |
| 4 | +import net from "node:net"; | |
| 5 | +import os from "node:os"; | |
| 6 | +import path from "node:path"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The jail an agent task runs in when it runs in someone's web terminal (phase 4b; Security's design). | |
| 10 | + * | |
| 11 | + * The terminal's sandbox holds the person's own Claude or ChatGPT sign-in, so the agent, which content in the | |
| 12 | + * repository can steer, runs under bubblewrap: no network of its own (a new network namespace with only | |
| 13 | + * loopback), a new PID namespace, no capabilities, an empty $HOME except the CLI's own sign-in, and nothing of | |
| 14 | + * the sandbox's work directory but the task's own clone. The person's checkout, its .git (with the sandbox's | |
| 15 | + * git credential) and the runner's state are not there; the checkout's objects are, read-only, because the | |
| 16 | + * task's clone borrows them (`git clone --shared`). | |
| 17 | + * | |
| 18 | + * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and | |
| 19 | + * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal. | |
| 20 | + * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it. | |
| 21 | + * | |
| 22 | + * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the | |
| 23 | + * runner, outside, pushes with the sandbox's credential. | |
| 24 | + * | |
| 25 | + * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ... | |
| 26 | + */ | |
| 27 | + | |
| 28 | +/** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */ | |
| 29 | +export const JAIL_HOSTS = [ | |
| 30 | + // Claude Code: the API, and its sign-in's token refresh. | |
| 31 | + "api.anthropic.com", | |
| 32 | + "console.anthropic.com", | |
| 33 | + "platform.claude.com", | |
| 34 | + // Codex on a ChatGPT plan: its backend and token refresh. | |
| 35 | + "chatgpt.com", | |
| 36 | + "auth.openai.com", | |
| 37 | + // Package registries, download hosts where they have their own (uploads go to other hosts). | |
| 38 | + "registry.npmjs.org", | |
| 39 | + "registry.yarnpkg.com", | |
| 40 | + "pypi.org", | |
| 41 | + "files.pythonhosted.org", | |
| 42 | + "index.crates.io", | |
| 43 | + "static.crates.io", | |
| 44 | + "rubygems.org", | |
| 45 | + "index.rubygems.org", | |
| 46 | + "repo1.maven.org", | |
| 47 | + "repo.maven.apache.org", | |
| 48 | + "deb.debian.org", | |
| 49 | + "security.debian.org", | |
| 50 | + "archive.ubuntu.com", | |
| 51 | + "security.ubuntu.com", | |
| 52 | +]; | |
| 53 | + | |
| 54 | +/** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */ | |
| 55 | +const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]); | |
| 56 | + | |
| 57 | +export function jailAllows(host: string, port: number): boolean { | |
| 58 | + const h = host.toLowerCase().replace(/\.$/, ""); | |
| 59 | + return (port === 443 && JAIL_HOSTS.includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h)); | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** Exit code when the jail itself can't start: nothing ran. */ | |
| 63 | +export const JAIL_FAILED = 125; | |
| 64 | + | |
| 65 | +const RELAY_PORT = 3128; | |
| 66 | + | |
| 67 | +/** Runs inside the jail: listens on 127.0.0.1 for the proxy, then runs the command and exits with it. */ | |
| 68 | +const RELAY = String.raw` | |
| 69 | +import net from "node:net"; | |
| 70 | +import os from "node:os"; | |
| 71 | +import { spawn } from "node:child_process"; | |
| 72 | +const [sock, port, ...cmd] = process.argv.slice(2); | |
| 73 | +const server = net.createServer((c) => { | |
| 74 | + const u = net.connect(sock); | |
| 75 | + c.pipe(u); | |
| 76 | + u.pipe(c); | |
| 77 | + c.on("error", () => u.destroy()); | |
| 78 | + u.on("error", () => c.destroy()); | |
| 79 | +}); | |
| 80 | +server.on("error", (e) => { | |
| 81 | + process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n"); | |
| 82 | + process.exit(${JAIL_FAILED}); | |
| 83 | +}); | |
| 84 | +server.listen(Number(port), "127.0.0.1", () => { | |
| 85 | + const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" }); | |
| 86 | + child.on("error", (e) => { | |
| 87 | + process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n"); | |
| 88 | + process.exit(127); | |
| 89 | + }); | |
| 90 | + child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1))); | |
| 91 | +}); | |
| 92 | +`; | |
| 93 | + | |
| 94 | +export interface JailArgs { | |
| 95 | + mode: "agent" | "finish"; | |
| 96 | + root: string; | |
| 97 | + dir: string; | |
| 98 | + out: string; | |
| 99 | + cli?: "claude-code" | "codex"; | |
| 100 | + cmd: string[]; | |
| 101 | +} | |
| 102 | + | |
| 103 | +export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs { | |
| 104 | + const sep = argv.indexOf("--"); | |
| 105 | + if (sep < 0 || sep === argv.length - 1) throw new Error("usage: --jail --root <dir> agent|finish --dir <dir> --out <dir> [--cli claude-code|codex] -- <command...>"); | |
| 106 | + const opts = argv.slice(0, sep); | |
| 107 | + const cmd = argv.slice(sep + 1); | |
| 108 | + const get = (name: string) => { | |
| 109 | + const i = opts.indexOf(name); | |
| 110 | + return i >= 0 ? opts[i + 1] : undefined; | |
| 111 | + }; | |
| 112 | + const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined; | |
| 113 | + const root = get("--root"); | |
| 114 | + const dir = get("--dir"); | |
| 115 | + const out = get("--out"); | |
| 116 | + const cli = get("--cli"); | |
| 117 | + if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out"); | |
| 118 | + if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`); | |
| 119 | + const abs = (p: string) => path.resolve(cwd, p); | |
| 120 | + const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, cmd }; | |
| 121 | + // The task's own directories: inside the work root, beside the person's checkout and never in it. | |
| 122 | + const repo = path.join(a.root, "repo"); | |
| 123 | + for (const p of [a.dir, a.out]) { | |
| 124 | + if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`); | |
| 125 | + } | |
| 126 | + if (a.dir === a.out) throw new Error("--dir and --out must differ"); | |
| 127 | + return a; | |
| 128 | +} | |
| 129 | + | |
| 130 | +const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`); | |
| 131 | + | |
| 132 | +function which(name: string, PATH: string): string | null { | |
| 133 | + if (name.includes("/")) return existsSync(name) ? name : null; | |
| 134 | + for (const d of PATH.split(":").filter(Boolean)) { | |
| 135 | + const p = path.join(d, name); | |
| 136 | + try { | |
| 137 | + accessSync(p, constants.X_OK); | |
| 138 | + if (statSync(p).isFile()) return p; | |
| 139 | + } catch { | |
| 140 | + // not here | |
| 141 | + } | |
| 142 | + } | |
| 143 | + return null; | |
| 144 | +} | |
| 145 | + | |
| 146 | +function findBwrap(): string | null { | |
| 147 | + return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"); | |
| 148 | +} | |
| 149 | + | |
| 150 | +/** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */ | |
| 151 | +export const CLI_FILES: Record<"claude-code" | "codex", string[]> = { | |
| 152 | + "claude-code": [".claude/.credentials.json", ".claude.json"], | |
| 153 | + codex: [".codex/auth.json", ".codex/config.toml"], | |
| 154 | +}; | |
| 155 | + | |
| 156 | +/** Variables the jail keeps from the sandbox's environment; everything else is cleared. */ | |
| 157 | +const KEEP_ENV = ["LANG", "LC_ALL", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "PIP_BREAK_SYSTEM_PACKAGES", "COREPACK_ENABLE_DOWNLOAD_PROMPT", "DIFFINERY_TASK_MESSAGE", "DIFFINERY_TASK_START"]; | |
| 158 | + | |
| 159 | +/** The bubblewrap command line for a jail (exported for tests). */ | |
| 160 | +export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] { | |
| 161 | + // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and | |
| 162 | + // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory. | |
| 163 | + const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length); | |
| 164 | + const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]; | |
| 165 | + for (const h of hidden) args.push("--tmpfs", h); | |
| 166 | + const isHidden = (p: string) => hidden.some((h) => under(p, h)); | |
| 167 | + // What the jail does see: the task's clone and its output directory, the checkout's objects read-only. | |
| 168 | + args.push("--bind", a.dir, a.dir, "--bind", a.out, a.out, "--ro-bind", path.join(a.root, "repo", ".git", "objects"), path.join(a.root, "repo", ".git", "objects")); | |
| 169 | + // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write). | |
| 170 | + args.push("--ro-bind", o.tools, o.tools); | |
| 171 | + const reveal = (p: string) => { | |
| 172 | + if (isHidden(p)) args.push("--ro-bind", p, p); | |
| 173 | + }; | |
| 174 | + // Node (for the relay) and the CLI, wherever they are installed. | |
| 175 | + reveal(path.dirname(path.dirname(o.nodePath))); | |
| 176 | + if (o.cliPath) reveal(path.dirname(o.cliPath)); | |
| 177 | + // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are. | |
| 178 | + for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) { | |
| 179 | + const f = o.env[k]; | |
| 180 | + if (f && path.isAbsolute(f) && existsSync(f)) reveal(f); | |
| 181 | + } | |
| 182 | + // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never | |
| 183 | + // written back, so nothing the agent writes there (another account's credentials) reaches the terminal. | |
| 184 | + if (a.mode === "agent" && a.cli) { | |
| 185 | + for (const f of CLI_FILES[a.cli]) { | |
| 186 | + const copy = path.join(o.tools, "home", f.replace(/\//g, "_")); | |
| 187 | + if (!existsSync(copy)) continue; | |
| 188 | + args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f)); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":"); | |
| 192 | + args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out); | |
| 193 | + for (const k of KEEP_ENV) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!); | |
| 194 | + if (a.mode === "agent") { | |
| 195 | + // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here). | |
| 196 | + args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); | |
| 197 | + const proxy = `http://127.0.0.1:${RELAY_PORT}`; | |
| 198 | + for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy); | |
| 199 | + } | |
| 200 | + return args; | |
| 201 | +} | |
| 202 | + | |
| 203 | +/** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */ | |
| 204 | +export function startJailProxy(listen: string | number, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> { | |
| 205 | + const server = createServer((req, res) => { | |
| 206 | + let url: URL; | |
| 207 | + try { | |
| 208 | + url = new URL(req.url ?? ""); | |
| 209 | + } catch { | |
| 210 | + res.writeHead(400).end(); | |
| 211 | + return; | |
| 212 | + } | |
| 213 | + const port = Number(url.port || 80); | |
| 214 | + if (url.protocol !== "http:" || !jailAllows(url.hostname, port) || (req.method !== "GET" && req.method !== "HEAD")) { | |
| 215 | + log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`); | |
| 216 | + res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from a terminal task.\n`); | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + const headers = { ...req.headers }; | |
| 220 | + delete headers["proxy-connection"]; | |
| 221 | + delete headers["proxy-authorization"]; | |
| 222 | + const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => { | |
| 223 | + res.writeHead(r.statusCode ?? 502, r.headers); | |
| 224 | + r.pipe(res); | |
| 225 | + }); | |
| 226 | + up.on("error", () => res.writeHead(502).end()); | |
| 227 | + up.end(); | |
| 228 | + }); | |
| 229 | + server.on("connect", (req, socket, head) => { | |
| 230 | + const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? ""); | |
| 231 | + const host = m?.[1] ?? ""; | |
| 232 | + const port = Number(m?.[2] ?? 0); | |
| 233 | + socket.on("error", () => socket.destroy()); | |
| 234 | + if (!m || port !== 443 || !jailAllows(host, port) || net.isIP(host)) { | |
| 235 | + log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)`); | |
| 236 | + socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n"); | |
| 237 | + return; | |
| 238 | + } | |
| 239 | + const up = net.connect(port, host, () => { | |
| 240 | + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); | |
| 241 | + if (head.length) up.write(head); | |
| 242 | + up.pipe(socket); | |
| 243 | + socket.pipe(up); | |
| 244 | + }); | |
| 245 | + up.on("error", () => socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n")); | |
| 246 | + }); | |
| 247 | + return new Promise((resolve, reject) => { | |
| 248 | + server.once("error", reject); | |
| 249 | + server.listen(listen, () => resolve(server)); | |
| 250 | + }); | |
| 251 | +} | |
| 252 | + | |
| 253 | +/** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */ | |
| 254 | +export async function runJail(argv: string[]): Promise<number> { | |
| 255 | + const fail = (m: string) => { | |
| 256 | + process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`); | |
| 257 | + return JAIL_FAILED; | |
| 258 | + }; | |
| 259 | + let a: JailArgs; | |
| 260 | + try { | |
| 261 | + a = parseJailArgs(argv); | |
| 262 | + } catch (e) { | |
| 263 | + return fail((e as Error).message); | |
| 264 | + } | |
| 265 | + const bwrap = findBwrap(); | |
| 266 | + if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox"); | |
| 267 | + for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`); | |
| 268 | + // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't | |
| 269 | + // exist is never mistaken for an agent that failed. | |
| 270 | + const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" }); | |
| 271 | + if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`); | |
| 272 | + const home = os.homedir(); | |
| 273 | + const tools = mkdtempSync(path.join(a.root, ".jail-")); | |
| 274 | + let server: Server | null = null; | |
| 275 | + try { | |
| 276 | + // The CLI, found as the terminal finds it, linked into the jail's PATH. | |
| 277 | + const cliPath = which(a.cmd[0]!, process.env.PATH ?? ""); | |
| 278 | + const real = cliPath ? realpathSync(cliPath) : null; | |
| 279 | + const bin = path.join(tools, "bin"); | |
| 280 | + mkdirSync(bin); | |
| 281 | + if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!))); | |
| 282 | + mkdirSync(path.join(tools, "home")); | |
| 283 | + if (a.mode === "agent" && a.cli) for (const f of CLI_FILES[a.cli]) if (existsSync(path.join(home, f))) copyFileSync(path.join(home, f), path.join(tools, "home", f.replace(/\//g, "_"))); | |
| 284 | + let cmd = a.cmd; | |
| 285 | + if (a.mode === "agent") { | |
| 286 | + const sock = path.join(tools, "proxy.sock"); | |
| 287 | + server = await startJailProxy(sock); | |
| 288 | + writeFileSync(path.join(tools, "relay.mjs"), RELAY); | |
| 289 | + cmd = [process.execPath, path.join(tools, "relay.mjs"), sock, String(RELAY_PORT), ...a.cmd]; | |
| 290 | + } | |
| 291 | + const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd]; | |
| 292 | + const code = await new Promise<number>((resolve) => { | |
| 293 | + const child = spawn(bwrap, args, { stdio: "inherit" }); | |
| 294 | + const stop = (sig: NodeJS.Signals) => child.kill(sig); | |
| 295 | + process.on("SIGTERM", stop); | |
| 296 | + process.on("SIGINT", stop); | |
| 297 | + child.on("error", (e) => resolve(fail(e.message))); | |
| 298 | + child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1))); | |
| 299 | + }); | |
| 300 | + return code; | |
| 301 | + } finally { | |
| 302 | + server?.close(); | |
| 303 | + rmSync(tools, { recursive: true, force: true }); | |
| 304 | + } | |
| 305 | +} |
modifiedcontainers/runner/src/main.ts+3−0View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * agent.mjs --sandbox AI-agent sandbox HTTP server on :8080 |
| 8 | 8 | * agent.mjs --sandbox-pool many sandboxes in one process, for local development and |
| 9 | 9 | * end-to-end tests (see sandbox-pool.ts) |
| 10 | + * agent.mjs --jail ... run a terminal task's command in its jail (see jail.ts) | |
| 10 | 11 | * agent.mjs --registry-relay HTTPS proxy for the Docker daemon that splits big |
| 11 | 12 | * image layer uploads to the Diffinery registry (see relay.ts) |
| 12 | 13 | * |
| @@ -18,6 +19,7 @@ | ||
| 18 | 19 | import { apiBaseFor, runAgentJob } from "./agent.js"; |
| 19 | 20 | import { trustEgressCa } from "./egress.js"; |
| 20 | 21 | import { RunnerApi } from "./api.js"; |
| 22 | +import { runJail } from "./jail.js"; | |
| 21 | 23 | import { runImportAgent } from "./importer.js"; |
| 22 | 24 | import { startSandbox } from "./sandbox.js"; |
| 23 | 25 | import { startSandboxPool } from "./sandbox-pool.js"; |
| @@ -57,6 +59,7 @@ | ||
| 57 | 59 | } |
| 58 | 60 | |
| 59 | 61 | async function main(): Promise<void> { |
| 62 | + if (process.argv[2] === "--jail") process.exit(await runJail(process.argv.slice(3))); | |
| 60 | 63 | const args = parseArgs(process.argv.slice(2)); |
| 61 | 64 | const env = process.env; |
| 62 | 65 | const workRoot = args.workRoot ?? env.DIFFINERY_WORK_ROOT ?? "/workspace"; |
modifiedcontainers/runner/src/sandbox.ts+10−3View file
| @@ -23,8 +23,12 @@ | ||
| 23 | 23 | maskValues?: string[]; |
| 24 | 24 | maxOutputBytes?: number; |
| 25 | 25 | baseEnv?: NodeJS.ProcessEnv; |
| 26 | + /** The runner's own script, for `--jail` (default: the one running). */ | |
| 27 | + agentPath?: string; | |
| 26 | 28 | } |
| 27 | 29 | |
| 30 | +const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; | |
| 31 | + | |
| 28 | 32 | export interface SandboxHandle { |
| 29 | 33 | server: Server; |
| 30 | 34 | port: number; |
| @@ -132,8 +136,12 @@ | ||
| 132 | 136 | }); |
| 133 | 137 | |
| 134 | 138 | const procs = new ProcTable(path.join(o.workRoot, ".procs"), masker); |
| 139 | + // A terminal task's jail (jail.ts), as one command: "$DIFFINERY_JAIL" agent --dir ... -- claude -p ... | |
| 140 | + const jailBin = path.join(o.workRoot, ".jail"); | |
| 141 | + await mkdir(o.workRoot, { recursive: true }); | |
| 142 | + await writeFile(jailBin, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(o.agentPath ?? process.argv[1]!)} --jail --root ${shellQuote(o.workRoot)} "$@"\n`, { mode: 0o755 }); | |
| 135 | 143 | const baseEnv = (): NodeJS.ProcessEnv => { |
| 136 | - const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), CI: "true", DIFFINERY: "true", DIFFINERY_SANDBOX: "true" }; | |
| 144 | + const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), CI: "true", DIFFINERY: "true", DIFFINERY_SANDBOX: "true", DIFFINERY_JAIL: jailBin }; | |
| 137 | 145 | for (const k of SENSITIVE) delete env[k]; |
| 138 | 146 | return env; |
| 139 | 147 | }; |
| @@ -174,8 +182,7 @@ | ||
| 174 | 182 | if (typeof cmd !== "string" || !cmd) throw new HttpErr(400, "`cmd` is required"); |
| 175 | 183 | const cwd = body.cwd ? resolvePath(body.cwd) : repoDir; |
| 176 | 184 | if (!existsSync(cwd)) throw new HttpErr(400, `cwd does not exist: ${cwd}`); |
| 177 | - const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), CI: "true", DIFFINERY: "true", DIFFINERY_SANDBOX: "true" }; | |
| 178 | - for (const k of SENSITIVE) delete env[k]; | |
| 185 | + const env = baseEnv(); | |
| 179 | 186 | if (body.env && typeof body.env === "object") for (const [k, v] of Object.entries(body.env)) env[k] = String(v); |
| 180 | 187 | const timeoutMs = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? body.timeoutMs : 10 * 60_000; |
| 181 | 188 | const started = Date.now(); |
addedcontainers/runner/test/jail.test.ts+120−0View file
| @@ -1,0 +1,120 @@ | ||
| 1 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | +import { request } from "node:http"; | |
| 3 | +import net from "node:net"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; | |
| 6 | +import { bwrapArgs, JAIL_HOSTS, jailAllows, parseJailArgs, startJailProxy } from "../src/jail.js"; | |
| 7 | +import { tmp } from "./helpers.js"; | |
| 8 | + | |
| 9 | +describe("a terminal task's jail", () => { | |
| 10 | + it("takes only task directories beside the checkout", () => { | |
| 11 | + const a = parseJailArgs(["--root", "/w", "agent", "--dir", "../task-1-1", "--out", "../task-1-1.out", "--cli", "claude-code", "--", "claude", "-p", "x"], "/w/repo"); | |
| 12 | + expect(a).toEqual({ mode: "agent", root: "/w", dir: "/w/task-1-1", out: "/w/task-1-1.out", cli: "claude-code", cmd: ["claude", "-p", "x"] }); | |
| 13 | + const bad = (dir: string, out = "../o") => () => parseJailArgs(["--root", "/w", "agent", "--dir", dir, "--out", out, "--", "true"], "/w/repo"); | |
| 14 | + expect(bad(".")).toThrow(/isn't a task directory/); // the person's checkout | |
| 15 | + expect(bad("../repo")).toThrow(/isn't a task directory/); | |
| 16 | + expect(bad("../.jail")).toThrow(/isn't a task directory/); | |
| 17 | + expect(bad("/etc")).toThrow(/isn't a task directory/); | |
| 18 | + expect(bad("../a/b")).toThrow(/isn't a task directory/); | |
| 19 | + expect(bad("../a", "../a")).toThrow(/must differ/); | |
| 20 | + expect(() => parseJailArgs(["--root", "/w", "agent", "--dir", "../a", "--out", "../b"])).toThrow(/usage/); | |
| 21 | + expect(() => parseJailArgs(["--root", "/w", "agent", "--dir", "../a", "--out", "../b", "--cli", "gemini", "--", "x"], "/w/repo")).toThrow(/unknown --cli/); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("reaches the providers' sign-in and API hosts and download hosts of package registries, by name", () => { | |
| 25 | + expect(jailAllows("claude.ai", 443)).toBe(false); | |
| 26 | + for (const h of ["api.anthropic.com", "chatgpt.com", "auth.openai.com", "registry.npmjs.org", "files.pythonhosted.org", "index.crates.io", "API.Anthropic.com."]) expect(jailAllows(h, 443), h).toBe(true); | |
| 27 | + // Upload hosts, fetch-for-you proxies, arbitrary sites, and other ports are not. | |
| 28 | + for (const h of ["upload.pypi.org", "crates.io", "proxy.golang.org", "api.openai.com", "example.com", "github.com", "evil.anthropic.com.example.com"]) expect(jailAllows(h, 443), h).toBe(false); | |
| 29 | + expect(jailAllows("api.anthropic.com", 80)).toBe(false); | |
| 30 | + expect(jailAllows("api.anthropic.com", 22)).toBe(false); | |
| 31 | + expect(jailAllows("archive.ubuntu.com", 80)).toBe(true); | |
| 32 | + expect(jailAllows("registry.npmjs.org", 80)).toBe(false); | |
| 33 | + expect(JAIL_HOSTS).not.toContain("*"); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("hides the home directory, the work root and /tmp, and shows only the task's clone, its output, the checkout's objects and the CLI's sign-in", () => { | |
| 37 | + const a = parseJailArgs(["--root", "/w", "agent", "--dir", "../t", "--out", "../t.out", "--cli", "claude-code", "--", "claude"], "/w/repo"); | |
| 38 | + const tools = tmp("jail-tools-"); | |
| 39 | + mkdirSync(path.join(tools, "home")); | |
| 40 | + writeFileSync(path.join(tools, "home", ".claude_.credentials.json"), "{}"); | |
| 41 | + const args = bwrapArgs(a, { home: "/home/p", tools, env: { PATH: "/home/p/bin:/usr/bin", NODE_EXTRA_CA_CERTS: "/etc/ca.crt", DIFFINERY_JOB_TOKEN: "secret", GIT_AUTHOR_NAME: "bot" }, cliPath: "/usr/lib/claude/cli.js", nodePath: "/usr/bin/node" }); | |
| 42 | + const s = args.join(" "); | |
| 43 | + for (const f of ["--unshare-all", "--die-with-parent", "--new-session", "--clearenv"]) expect(args).toContain(f); | |
| 44 | + expect(s).toContain("--cap-drop ALL"); | |
| 45 | + expect(args).not.toContain("--share-net"); | |
| 46 | + for (const h of ["/tmp", "/home/p", "/w"]) expect(s).toContain(`--tmpfs ${h}`); | |
| 47 | + expect(s).toContain("--bind /w/t /w/t"); | |
| 48 | + expect(s).toContain("--bind /w/t.out /w/t.out"); | |
| 49 | + expect(s).toContain("--ro-bind /w/repo/.git/objects /w/repo/.git/objects"); | |
| 50 | + expect(s).not.toMatch(/\/w\/repo(\/\.git)?(\/config)? /); | |
| 51 | + expect(s).not.toContain("--bind /w/repo/.git "); | |
| 52 | + // The environment is cleared, then given only what it needs: no sandbox secrets. | |
| 53 | + expect(s).not.toContain("secret"); | |
| 54 | + expect(s).toContain("--setenv GIT_AUTHOR_NAME bot"); | |
| 55 | + expect(s).toContain("--setenv HTTPS_PROXY http://127.0.0.1:3128"); | |
| 56 | + expect(s).toContain(`--setenv PATH ${tools}/bin:/usr/bin`); | |
| 57 | + // The sign-in is a private copy, and the jail's tools are read-only. | |
| 58 | + expect(s).toContain(`--bind ${tools}/home/.claude_.credentials.json /home/p/.claude/.credentials.json`); | |
| 59 | + expect(s).not.toContain("--bind /home/p/.claude/.credentials.json"); | |
| 60 | + expect(s).toContain(`--ro-bind ${tools} ${tools}`); | |
| 61 | + expect(s).not.toContain(".claude.json"); // not copied here, so not bound | |
| 62 | + expect(s).toContain("--chdir /w/t"); | |
| 63 | + // A finish jail has no way out and no sign-in. | |
| 64 | + const f = bwrapArgs({ ...a, mode: "finish", cli: undefined }, { home: "/home/p", tools, env: {}, nodePath: "/usr/bin/node" }).join(" "); | |
| 65 | + expect(f).not.toContain("HTTPS_PROXY"); | |
| 66 | + expect(f).not.toContain(".claude"); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + describe("its proxy", () => { | |
| 70 | + let dir: string; | |
| 71 | + let sock: string; | |
| 72 | + let close: () => void; | |
| 73 | + const logs: string[] = []; | |
| 74 | + beforeAll(async () => { | |
| 75 | + dir = tmp("jail-"); | |
| 76 | + sock = path.join(dir, "p.sock"); | |
| 77 | + const server = await startJailProxy(sock, (l) => logs.push(l)); | |
| 78 | + close = () => server.close(); | |
| 79 | + }); | |
| 80 | + afterAll(() => close()); | |
| 81 | + | |
| 82 | + const connect = (target: string) => | |
| 83 | + new Promise<string>((resolve) => { | |
| 84 | + const c = net.connect(sock, () => c.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`)); | |
| 85 | + let got = ""; | |
| 86 | + c.on("data", (d) => { | |
| 87 | + got += d; | |
| 88 | + if (got.includes("\r\n\r\n")) { | |
| 89 | + c.destroy(); | |
| 90 | + resolve(got.split("\r\n")[0]!); | |
| 91 | + } | |
| 92 | + }); | |
| 93 | + c.on("end", () => resolve(got.split("\r\n")[0]!)); | |
| 94 | + c.on("error", () => resolve(got.split("\r\n")[0] ?? "")); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("refuses tunnels anywhere else, and to the allowed hosts on other ports or by address", async () => { | |
| 98 | + expect(await connect("example.com:443")).toBe("HTTP/1.1 403 Forbidden"); | |
| 99 | + expect(await connect("api.anthropic.com:80")).toBe("HTTP/1.1 403 Forbidden"); | |
| 100 | + expect(await connect("1.1.1.1:443")).toBe("HTTP/1.1 403 Forbidden"); | |
| 101 | + expect(await connect("api.anthropic.com.evil.test:443")).toBe("HTTP/1.1 403 Forbidden"); | |
| 102 | + expect(logs.some((l) => l.includes("refused a connection to example.com:443"))).toBe(true); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("serves plain HTTP only to package mirrors, and only downloads", async () => { | |
| 106 | + const send = (method: string, url: string) => | |
| 107 | + new Promise<number>((resolve) => { | |
| 108 | + const r = request({ socketPath: sock, method, path: url, headers: { host: new URL(url).host } }, (res) => { | |
| 109 | + res.resume(); | |
| 110 | + resolve(res.statusCode ?? 0); | |
| 111 | + }); | |
| 112 | + r.on("error", () => resolve(0)); | |
| 113 | + r.end(); | |
| 114 | + }); | |
| 115 | + expect(await send("GET", "http://example.com/")).toBe(403); | |
| 116 | + expect(await send("POST", "http://archive.ubuntu.com/ubuntu/")).toBe(403); | |
| 117 | + expect(await send("GET", "http://registry.npmjs.org/left-pad")).toBe(403); | |
| 118 | + }); | |
| 119 | + }); | |
| 120 | +}); |