Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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)
modified.github/workflows/e2e.yml+7−0View file
@@ -43,6 +43,13 @@
4343 - run: pnpm -r --if-present build
4444 - if: matrix.shard == 'rest'
4545 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
4653 - name: Run the shard's tests
4754 working-directory: apps/diffinery
4855 run: |
modified.github/workflows/live-test.yml+50−0View file
@@ -503,6 +503,56 @@
503503 curl -sS -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true
504504 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; }
505505
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+
506556 - name: Pull requests and the merge queue
507557 if: steps.token.outputs.skip != 'true'
508558 run: |
modifiedapps/diffinery/docs/agents/terminal.md+43−9View file
@@ -18,8 +18,8 @@
1818 ## Open a terminal
1919
2020 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)).
2323 4. Click **Open a terminal**.
2424
2525 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 @@
3737
3838 ## Give it a task
3939
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.
4141
4242 - **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.
4444
4545 What happens:
4646
4747 | Step | What Diffinery does |
4848 | --- | --- |
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. |
5353
5454 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.
5555
5656 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.
5757
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
5959
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+
6094 ## Who can reach it
6195
6296 - **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 @@
8989 | `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` |
9090 | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none |
9191 | `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) |
9393 | `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) |
9494 | `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) |
9595 | `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 @@
1313 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";
1414 import { COORDINATOR_MAX_MODEL_CALLS, COORDINATOR_TIMEOUT_MS, COORDINATOR_TOKENS_DEFAULT, coordinatorPrompt, parseActions, TASKS_PER_DAY, TASKS_PER_RUN, type CoordinatorSettings } from "./coordinator.js";
1515 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";
1717
1818 /**
1919 * One per repository: its agent tasks. A task is a goal a person gives one of
@@ -1288,7 +1288,8 @@
12881288 // In a terminal the CLI is usually there already, signed in: keep it.
12891289 const bin = TERMINAL_BIN[t.runtime];
12901290 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);
12921293 const done = await this.poll(r, "setup");
12931294 if (done === null) return;
12941295 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 @@
12961297 }
12971298 case "agent": {
12981299 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);
13021304 }
13031305 if (!r.proc) {
13041306 // 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 @@
13181320 const run = this.run(r.task, r.seq)!;
13191321 // The agent is done: its model token stops working now, before anything else runs.
13201322 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.");
13211324 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.` : ""}`);
13221325 return this.setStep(run, "finish");
13231326 }
@@ -1327,7 +1330,7 @@
13271330 if (r.kind === "plan") return this.keepPlan(t, r);
13281331 const branch = r.branch ?? t.branch;
13291332 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}.`);
13311334 }
13321335 const done = await this.poll(r, "setup");
13331336 if (done === null) return;
@@ -1383,8 +1386,9 @@
13831386
13841387 /**
13851388 * 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.
13881392 */
13891393 private async terminalBoot(t: TaskRow, r: RunRow): Promise<void> {
13901394 await this.checkAccess(t);
@@ -1394,12 +1398,20 @@
13941398 const ref = branchSha ? t.branch : t.base;
13951399 const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`));
13961400 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.`);
13991402 // Branch names may hold `$`, backticks, `;` or `|`: everything reaches the script through its environment, quoted.
14001403 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) },
14031415 timeoutMs: 5 * 60_000,
14041416 })) as { exitCode: number; stderr: string };
14051417 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 @@
14121424 const terminals = new Terminals(this.env);
14131425 const term = await terminals.get(r.terminal_id!);
14141426 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");
14151428 if (!(await terminals.termsAt(t.person_id))) throw new TaskAccessError(`${t.person_login} hasn't accepted the notice for terminals`);
14161429 const block = blockedBy(await killSwitches(this.env, t.repo_id), "sandbox");
14171430 if (block) throw new TaskAccessError(block);
@@ -1850,7 +1863,7 @@
18501863 if (r.sandbox_id) {
18511864 if (r.proc) await this.sandbox(r, "/proc/kill", { id: r.proc, signal: "SIGKILL" }).catch(() => {});
18521865 // 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(() => {});
18541867 else
18551868 await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id))
18561869 .closeTaskSandbox(r.sandbox_id)
@@ -1950,9 +1963,15 @@
19501963 }
19511964 }
19521965
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` });
19551971
1972+/** What the runner's jail exits with when it couldn't start (containers/runner/src/jail.ts). */
1973+const JAIL_FAILED = 125;
1974+
19561975 /** The CLI a terminal run looks for before installing it. */
19571976 const TERMINAL_BIN: Partial<Record<RuntimeId, string>> = { "claude-code": "claude", codex: "codex" };
19581977
@@ -1983,3 +2002,30 @@
19832002 'git push -q origin "HEAD:refs/heads/$DIFFINERY_TASK_BRANCH" 2>&1',
19842003 'echo "diffinery: pushed $head"',
19852004 ].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 @@
7171 },
7272 };
7373
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+
7494 export const isRuntime = (r: string): r is RuntimeId => r === "claude-code" || r === "codex" || r === "custom";
7595
7696 /** 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 @@
6565 </>
6666 );
6767
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 } = {}) {
6969 const me = requireAccount(c);
7070 const base = repoUrl(l.repo);
7171 const tk = csrfToken(c);
@@ -136,6 +136,12 @@
136136 <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) }))} />
137137 </div>
138138 </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>
139145 {termsAt ? (
140146 <p class="muted small">
141147 You accepted the notice about your own provider accounts <Time at={termsAt} />.
@@ -147,7 +153,7 @@
147153 label={
148154 <>
149155 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.
151157 </>
152158 }
153159 />
@@ -237,7 +243,11 @@
237243 <h3>Give it a task</h3>
238244 </div>
239245 <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 ? (
241251 <p class="m0">
242252 <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.
243253 </p>
@@ -257,7 +267,7 @@
257267 />
258268 </div>
259269 <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>
261271 </p>
262272 <button class="btn primary">Run with my subscription</button>
263273 </Form>
@@ -300,9 +310,9 @@
300310
301311 post(web, "/:owner/:repo/terminals", async (c, f) => {
302312 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 };
304314 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 });
306316 return c.redirect(`${repoUrl(l.repo)}/terminals/${t.id}`, 303);
307317 } catch (e) {
308318 const st = statusOf(e);
modifiedapps/diffinery/src/agentic/terminals.ts+12−9View file
@@ -30,7 +30,7 @@
3030 export const TERMINAL_IDLE_MS = 30 * 60_000;
3131 export const TERMINAL_MAX_MS = 8 * 3600_000;
3232 /** Bump when the notice changes: everyone opts in again. */
33-export const TERMINAL_TERMS_VERSION = 2;
33+export const TERMINAL_TERMS_VERSION = 3;
3434
3535 export type TerminalStatus = "starting" | "running" | "ended";
3636 export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended";
@@ -47,6 +47,8 @@
4747 started_at: string | null;
4848 ended_at: string | null;
4949 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;
5052 }
5153
5254 export const TERMINAL_END_TEXT: Record<TerminalEndReason, string> = {
@@ -111,11 +113,11 @@
111113 return at;
112114 }
113115
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 };
116118 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)
119121 .run();
120122 return row;
121123 }
@@ -140,7 +142,7 @@
140142 * Opens a terminal for the person in this browser session. The sandbox starts in the project's
141143 * alarm (it can take a minute); the page shows "Starting" until then.
142144 */
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> {
144146 const me = requirePersonSession(c);
145147 if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery");
146148 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
@@ -171,8 +173,8 @@
171173 if (block) throw new HttpError(403, block);
172174 const quota = await mayStart(c.env, l.repo.owner_id, "sandbox");
173175 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 } });
176178 await projectStub(c.env, l.repo.id).wakeTerminals(l.repo.id);
177179 return t;
178180 }
@@ -217,6 +219,7 @@
217219 const terminals = new Terminals(c.env);
218220 const t = await terminals.mine(terminalId(id), me, l.repo);
219221 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");
220223 if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account");
221224 // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile.
222225 const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
@@ -281,7 +284,7 @@
281284 if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null;
282285 if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null;
283286 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);
285288 if (!open.length || !(await terminals.termsAt(me.id))) return null;
286289 const project = projectStub(env, repo.id);
287290 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 @@
126126 'd=$(basename "$PWD")',
127127 'echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"signed-in claude: key=${ANTHROPIC_API_KEY:-none} base=${ANTHROPIC_BASE_URL:-none} dir=$d\\"}]}}"',
128128 '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'",
129140 'echo "made in the terminal" > terminal.txt',
130141 'echo \'{"type":"result","subtype":"success","result":"Done in the terminal."}\'',
131142 ].join("\n"),
132143 { mode: 0o755 },
133144 );
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");
134150 const poolPort = await freePort();
135151 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 });
137153 pool.stdout.on("data", (d) => (poolLog += d));
138154 pool.stderr.on("data", (d) => (poolLog += d));
139155 await until("sandbox pool", async () => poolLog.includes("listening"), 30_000);
modifiedapps/diffinery/test/e2e/terminals.test.mjs+27−3View file
@@ -210,7 +210,7 @@
210210 const EVIL = "t$(touch${IFS}injected)";
211211 const mainSha = (await ok("GET", `/repos/${owner}/term-tasks/git/ref/heads/main`)).object.sha;
212212 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" });
214214 assert.equal(opened.status, 303, await opened.text());
215215 const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
216216 const running = await until("the terminal to start", async () => {
@@ -229,6 +229,19 @@
229229 assert.deepEqual(await ok("GET", R), [], "someone else's click starts nothing");
230230 const viaToken = await api("POST", R, { goal: "Do it", runtime: "claude-code", agent: "term-hand", terminal: id });
231231 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);
232245 // Only Claude Code or Codex, as signed in there.
233246 const custom = await form(`terminals/${id}/task`, { goal: "Do it", runtime: "custom" });
234247 assert.equal(custom.status, 303);
@@ -251,6 +264,14 @@
251264 assert.ok(!/base=[^ ]*\/-\/model\//.test(text), "nor the model proxy");
252265 assert.match(text, new RegExp(`dir=task-${n}-1`), "in a checkout of its own");
253266 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");
254275 const file = await ok("GET", `/repos/${owner}/term-tasks/contents/terminal.txt?ref=${encodeURIComponent(done.branch)}`);
255276 assert.equal(Buffer.from(file.content, "base64").toString(), "made in the terminal\n");
256277 assert.equal(done.runs[0].in_terminal, true);
@@ -259,10 +280,13 @@
259280 // The terminal's own checkout is untouched, and the terminal is still mine to use.
260281 const t = await termSocket(`${base.replace("http", "ws")}/${owner}/term-tasks/terminals/${id}/socket?cols=100&rows=30`, { origin: new URL(base).origin });
261282 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");
263284 await t.until(/check-42/);
264285 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");
266290 assert.match(t.out(), /\n0\r?\n0\r?\n/, "a clean checkout, and the task's checkout removed");
267291
268292 // 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 @@
2929 build-essential pkg-config openssh-client gnupg \
3030 python3 python3-pip python3-venv python-is-python3 \
3131 libyaml-dev libssl-dev libffi-dev zlib1g-dev libreadline-dev libgmp-dev \
32+ bubblewrap \
3233 && git config --system --add safe.directory '*' \
3334 && git lfs install --system --skip-repo \
3435 && 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 @@
77 * agent.mjs --sandbox AI-agent sandbox HTTP server on :8080
88 * agent.mjs --sandbox-pool many sandboxes in one process, for local development and
99 * end-to-end tests (see sandbox-pool.ts)
10+ * agent.mjs --jail ... run a terminal task's command in its jail (see jail.ts)
1011 * agent.mjs --registry-relay HTTPS proxy for the Docker daemon that splits big
1112 * image layer uploads to the Diffinery registry (see relay.ts)
1213 *
@@ -18,6 +19,7 @@
1819 import { apiBaseFor, runAgentJob } from "./agent.js";
1920 import { trustEgressCa } from "./egress.js";
2021 import { RunnerApi } from "./api.js";
22+import { runJail } from "./jail.js";
2123 import { runImportAgent } from "./importer.js";
2224 import { startSandbox } from "./sandbox.js";
2325 import { startSandboxPool } from "./sandbox-pool.js";
@@ -57,6 +59,7 @@
5759 }
5860
5961 async function main(): Promise<void> {
62+ if (process.argv[2] === "--jail") process.exit(await runJail(process.argv.slice(3)));
6063 const args = parseArgs(process.argv.slice(2));
6164 const env = process.env;
6265 const workRoot = args.workRoot ?? env.DIFFINERY_WORK_ROOT ?? "/workspace";
modifiedcontainers/runner/src/sandbox.ts+10−3View file
@@ -23,8 +23,12 @@
2323 maskValues?: string[];
2424 maxOutputBytes?: number;
2525 baseEnv?: NodeJS.ProcessEnv;
26+ /** The runner's own script, for `--jail` (default: the one running). */
27+ agentPath?: string;
2628 }
2729
30+const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
31+
2832 export interface SandboxHandle {
2933 server: Server;
3034 port: number;
@@ -132,8 +136,12 @@
132136 });
133137
134138 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 });
135143 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 };
137145 for (const k of SENSITIVE) delete env[k];
138146 return env;
139147 };
@@ -174,8 +182,7 @@
174182 if (typeof cmd !== "string" || !cmd) throw new HttpErr(400, "`cmd` is required");
175183 const cwd = body.cwd ? resolvePath(body.cwd) : repoDir;
176184 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();
179186 if (body.env && typeof body.env === "object") for (const [k, v] of Object.entries(body.env)) env[k] = String(v);
180187 const timeoutMs = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? body.timeoutMs : 10 * 60_000;
181188 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+});