Public: anyone can read this; the crew can change it.
The image the forge ships has no bubblewrap, so every agent run on it is unsandboxed — and only a boot warning says so #405
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/298-the-image-the-forge-ships-has-no-bubblew → main 1 commit fork
11 files
changed +142
−4
Dockerfile +1 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | # nothing to install. |
| 8 | 8 | FROM oven/bun:1.3-debian |
| 9 | 9 | RUN apt-get update \ |
| 10 | && apt-get install -y --no-install-recommends git ca-certificates curl nodejs npm tini chromium fonts-liberation \ | |
| 10 | && apt-get install -y --no-install-recommends git ca-certificates curl bubblewrap socat nodejs npm tini chromium fonts-liberation \ | |
| 11 | 11 | && rm -rf /var/lib/apt/lists/* \ |
| 12 | 12 | && npm install -g @anthropic-ai/claude-code \ |
| 13 | 13 | && npm cache clean --force |
README.md +11
| @@ -295,6 +295,17 @@ Subscriptions, sealed, and grants each to a repository (Settings → Crew) or | ||
| 295 | 295 | an organization (its page), down to one crew member: a work plan pays for |
| 296 | 296 | the work repositories and nothing else. Two on one grant take turns, and |
| 297 | 297 | one the provider rests hands its runs to the other until the reset. |
| 298 | **Sandbox.** An agent's shell is held to a policy — the network is the forge | |
| 299 | and the package registry, writes stay in the clone, the forge's data and the | |
| 300 | operator's credentials are unreadable — by bubblewrap and socat on Linux | |
| 301 | (both are in the image). A host that forbids unprivileged user namespaces | |
| 302 | (many container platforms' seccomp profiles) cannot hold it, and the policy is | |
| 303 | then switched off rather than faked. To tell which a deployment is in: the | |
| 304 | boot log carries `agent sandbox not available here` with the reason, Settings → | |
| 305 | Crew shows **Unsandboxed** with it, and every agent run's page says | |
| 306 | "Sandboxed" or "Unsandboxed" from what it was actually started with (runs | |
| 307 | from before this record say nothing). | |
| 308 | ||
| 298 | 309 | `docs/APPS.md` is the contract: the vocabulary, and the rules that are |
| 299 | 310 | mechanical rather than advisory. |
| 300 | 311 |
src/core/runs.ts +16 −1
| @@ -71,6 +71,10 @@ export interface Run { | ||
| 71 | 71 | readonly heartbeatAt: string | null; |
| 72 | 72 | /** For an agent run: how many turns the model took. */ |
| 73 | 73 | readonly turns: number; |
| 74 | /** For an agent run: whether its shell was held to the sandbox; null when unrecorded. */ | |
| 75 | readonly sandboxEnforced: boolean | null; | |
| 76 | /** Why it was not, when it was not. */ | |
| 77 | readonly sandboxNote: string; | |
| 74 | 78 | /** A failed check's own detail, when its output was recognised; null otherwise. */ |
| 75 | 79 | readonly checkDetail: CheckDetail | null; |
| 76 | 80 | readonly createdAt: string; |
| @@ -98,6 +102,8 @@ interface RunRow { | ||
| 98 | 102 | attempt: number; |
| 99 | 103 | heartbeat_at: string | null; |
| 100 | 104 | turns: number; |
| 105 | sandbox_enforced: number | null; | |
| 106 | sandbox_note: string; | |
| 101 | 107 | check_detail: string; |
| 102 | 108 | created_at: string; |
| 103 | 109 | started_at: string | null; |
| @@ -123,6 +129,9 @@ const toRun = (r: RunRow): Run => ({ | ||
| 123 | 129 | attempt: r.attempt, |
| 124 | 130 | heartbeatAt: r.heartbeat_at, |
| 125 | 131 | turns: r.turns, |
| 132 | sandboxEnforced: | |
| 133 | r.sandbox_enforced === null ? null : r.sandbox_enforced !== 0, | |
| 134 | sandboxNote: r.sandbox_note, | |
| 126 | 135 | checkDetail: r.check_detail ? (JSON.parse(r.check_detail) as CheckDetail) : null, |
| 127 | 136 | createdAt: r.created_at, |
| 128 | 137 | startedAt: r.started_at, |
| @@ -295,11 +304,15 @@ export function noteRunProgress( | ||
| 295 | 304 | branch?: string | undefined; |
| 296 | 305 | /** The subscription the run works on, once picked. */ |
| 297 | 306 | subscriptionId?: string | undefined; |
| 307 | /** Whether the shell is held to the sandbox, and why not when it is not. */ | |
| 308 | sandbox?: { enforced: boolean; note: string } | undefined; | |
| 298 | 309 | }, |
| 299 | 310 | ): void { |
| 300 | 311 | db.query( |
| 301 | 312 | `UPDATE run SET session_id = COALESCE(?2, session_id), cost_usd = COALESCE(?3, cost_usd), |
| 302 | branch = COALESCE(?4, branch), subscription_id = COALESCE(?5, subscription_id) | |
| 313 | branch = COALESCE(?4, branch), subscription_id = COALESCE(?5, subscription_id), | |
| 314 | sandbox_enforced = COALESCE(?6, sandbox_enforced), | |
| 315 | sandbox_note = COALESCE(?7, sandbox_note) | |
| 303 | 316 | WHERE id = ?1`, |
| 304 | 317 | ).run( |
| 305 | 318 | runId, |
| @@ -307,6 +320,8 @@ export function noteRunProgress( | ||
| 307 | 320 | progress.costUsd ?? null, |
| 308 | 321 | progress.branch ?? null, |
| 309 | 322 | progress.subscriptionId ?? null, |
| 323 | progress.sandbox === undefined ? null : progress.sandbox.enforced ? 1 : 0, | |
| 324 | progress.sandbox?.note.slice(0, 300) ?? null, | |
| 310 | 325 | ); |
| 311 | 326 | } |
| 312 | 327 |
src/core/schema.ts +7
| @@ -1041,4 +1041,11 @@ export const migrations: readonly string[] = [ | ||
| 1041 | 1041 | ALTER TABLE subscription ADD COLUMN headroom_pct INTEGER NOT NULL DEFAULT 0 |
| 1042 | 1042 | CHECK (headroom_pct BETWEEN 0 AND 90); |
| 1043 | 1043 | `, |
| 1044 | // 39 — whether the agent's shell was held to the sandbox policy, kept on | |
| 1045 | // the run so an old one can be read honestly. NULL: the run predates this, | |
| 1046 | // or is not an agent's. The note is why it was not, when it was not. | |
| 1047 | ` | |
| 1048 | ALTER TABLE run ADD COLUMN sandbox_enforced INTEGER; | |
| 1049 | ALTER TABLE run ADD COLUMN sandbox_note TEXT NOT NULL DEFAULT ''; | |
| 1050 | `, | |
| 1044 | 1051 | ]; |
src/crew/dispatch.ts +3
| @@ -74,6 +74,8 @@ export interface CrewHandle { | ||
| 74 | 74 | /** Whether a usable Claude Code was found at start. */ |
| 75 | 75 | readonly ready: boolean; |
| 76 | 76 | readonly detail: string; |
| 77 | /** Whether agents' shells are held to the sandbox here, and why not when they are not. */ | |
| 78 | readonly sandbox: { readonly enforced: boolean; readonly reason: string }; | |
| 77 | 79 | /** |
| 78 | 80 | * Stop. Plain, what is in flight is cut short and its task queued again. |
| 79 | 81 | * Draining — a release's old process, with the new one already taking |
| @@ -162,6 +164,7 @@ export async function startCrew( | ||
| 162 | 164 | |
| 163 | 165 | const handle: CrewHandle = { |
| 164 | 166 | ready: detected.ok, |
| 167 | sandbox: { enforced: sandbox.ok, reason: sandbox.ok ? "" : sandbox.reason }, | |
| 165 | 168 | detail: detected.ok ? `Claude Code ${detected.version}` : detected.error, |
| 166 | 169 | stop: async (options = {}) => { |
| 167 | 170 | stopped = true; |
src/crew/runner.ts +6
| @@ -725,6 +725,12 @@ async function work( | ||
| 725 | 725 | write( |
| 726 | 726 | `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`, |
| 727 | 727 | ); |
| 728 | noteRunProgress(app.db, run.id, { | |
| 729 | sandbox: { | |
| 730 | enforced: sandbox.enforced, | |
| 731 | note: options.sandboxNote ?? "", | |
| 732 | }, | |
| 733 | }); | |
| 728 | 734 | if (!sandbox.enforced) |
| 729 | 735 | write( |
| 730 | 736 | `sandbox: off here${options.sandboxNote === undefined ? "" : ` — ${options.sandboxNote}`}; the shell runs unrestricted`, |
src/web/pages/crew.ts +9 −1
| @@ -93,7 +93,15 @@ export function crewReadiness(ctx: Ctx): Html { | ||
| 93 | 93 | </p>`; |
| 94 | 94 | return html`<p class="muted small mb"> |
| 95 | 95 | ${icon("check")} ${crew.detail} is ready to run the crew. |
| 96 | </p>`; | |
| 96 | </p> | |
| 97 | ${when( | |
| 98 | !crew.sandbox.enforced, | |
| 99 | () => html`<p class="error-note mb" id="crew-unsandboxed"> | |
| 100 | ${icon("alert")} Unsandboxed: agents' shells run unrestricted here | |
| 101 | (${crew.sandbox.reason}). They can read this forge's data and reach any | |
| 102 | host. | |
| 103 | </p>`, | |
| 104 | )}`; | |
| 97 | 105 | } |
| 98 | 106 | |
| 99 | 107 | // ---- the form ---- |
src/web/pages/runs.ts +2
| @@ -39,6 +39,7 @@ import { flashTo, page, redirect } from "../layout.ts"; | ||
| 39 | 39 | import { liveClientJs, liveMount } from "../live.ts"; |
| 40 | 40 | import { studioRuns } from "../studio/lists.ts"; |
| 41 | 41 | import { pageTitle, studioRecord } from "../studio/nouns.ts"; |
| 42 | import { sandboxLine } from "../studio/sandbox.ts"; | |
| 42 | 43 | import { studioRun } from "../studio/run.ts"; |
| 43 | 44 | import { |
| 44 | 45 | btn, |
| @@ -425,6 +426,7 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 425 | 426 | </div> |
| 426 | 427 | </div> |
| 427 | 428 | <p class="run-summary" id="run-summary">${run.summary}</p> |
| 429 | ${sandboxLine(run)} | |
| 428 | 430 | ${when( |
| 429 | 431 | held.length > 0, |
| 430 | 432 | () => |
src/web/studio/run.ts +2
| @@ -28,6 +28,7 @@ import { page } from "../layout.ts"; | ||
| 28 | 28 | import { btn, icon } from "../ui.ts"; |
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; |
| 31 | import { sandboxLine } from "./sandbox.ts"; | |
| 31 | 32 | import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 32 | 33 | import { changeHref, tellHref } from "./nouns.ts"; |
| 33 | 34 | import { unnamedRun, voiceFor, type Actor } from "./voice.ts"; |
| @@ -155,6 +156,7 @@ export async function studioRun( | ||
| 155 | 156 | )} |
| 156 | 157 | </div> |
| 157 | 158 | <p class="run-summary" id="run-summary">${run.summary}</p> |
| 159 | ${sandboxLine(run)} | |
| 158 | 160 | </div>`; |
| 159 | 161 | |
| 160 | 162 | const body = html`${repoHeader(ctx, repo, "app", { |
src/web/studio/sandbox.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | /** | |
| 2 | * The one honest line about an agent run's shell: held to the sandbox policy, | |
| 3 | * or not, and why not. Runs that predate the record say nothing. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Run } from "../../core/runs.ts"; | |
| 7 | import { html, type Html } from "../html.ts"; | |
| 8 | ||
| 9 | export function sandboxLine(run: Run): Html | null { | |
| 10 | if (run.kind !== "agent" || run.sandboxEnforced === null) return null; | |
| 11 | if (run.sandboxEnforced) | |
| 12 | return html`<p class="muted small" id="run-sandbox">Sandboxed: the shell was held to the policy.</p>`; | |
| 13 | return html`<p class="error-note" id="run-sandbox"> | |
| 14 | Unsandboxed: the shell ran unrestricted${run.sandboxNote === "" ? "" : html` (${run.sandboxNote})`}. | |
| 15 | </p>`; | |
| 16 | } |
test/crew-sandbox.test.ts +69 −1
| @@ -4,7 +4,15 @@ | ||
| 4 | 4 | * plainly, so a run reads "unsandboxed" once instead of warning on every |
| 5 | 5 | * command and running anyway. |
| 6 | 6 | */ |
| 7 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createRun, getRun, noteRunProgress } from "../src/core/runs.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { sandboxLine } from "../src/web/studio/sandbox.ts"; | |
| 8 | 16 | import { claudeSettings } from "../src/crew/claude.ts"; |
| 9 | 17 | import { sandboxAvailable, sandboxPolicy } from "../src/crew/sandbox.ts"; |
| 10 | 18 | |
| @@ -36,3 +44,63 @@ describe("the shell sandbox", () => { | ||
| 36 | 44 | else if (!probe.ok) expect(probe.reason.length).toBeGreaterThan(0); |
| 37 | 45 | }); |
| 38 | 46 | }); |
| 47 | ||
| 48 | describe("the sandbox on a run's record", () => { | |
| 49 | let dataDir: string; | |
| 50 | let app: App; | |
| 51 | let repoId: string; | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-sandbox-run-")); | |
| 55 | app = createApp({ dataDir, port: 0 }); | |
| 56 | const a = await createUser(app.db, { | |
| 57 | username: "alice", | |
| 58 | email: "[email protected]", | |
| 59 | password: "a-good-passphrase", | |
| 60 | adminIfFirst: true, | |
| 61 | }); | |
| 62 | if (!a.ok) throw new Error(a.error); | |
| 63 | const made = await createRepo(app, a.value, { name: "r" }); | |
| 64 | if (!made.ok) throw new Error(String(made.error)); | |
| 65 | repoId = made.value.id; | |
| 66 | }); | |
| 67 | afterAll(async () => { | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | const agentRun = () => createRun(app.db, { repoId, kind: "agent" }); | |
| 73 | ||
| 74 | test("a run starts unrecorded and says nothing", () => { | |
| 75 | const run = agentRun(); | |
| 76 | expect(run.sandboxEnforced).toBeNull(); | |
| 77 | expect(sandboxLine(run)).toBeNull(); | |
| 78 | }); | |
| 79 | ||
| 80 | test("keeps enforced, and says unsandboxed with an escaped reason", () => { | |
| 81 | const off = agentRun(); | |
| 82 | noteRunProgress(app.db, off.id, { | |
| 83 | sandbox: { enforced: false, note: "<b>bwrap</b> is not installed" }, | |
| 84 | }); | |
| 85 | const offRun = getRun(app.db, off.id)!; | |
| 86 | expect(offRun.sandboxEnforced).toBe(false); | |
| 87 | const text = String(sandboxLine(offRun)); | |
| 88 | expect(text).toContain("Unsandboxed"); | |
| 89 | expect(text).toContain("<b>bwrap</b> is not installed"); | |
| 90 | expect(text).not.toContain("<b>"); | |
| 91 | ||
| 92 | const on = agentRun(); | |
| 93 | noteRunProgress(app.db, on.id, { sandbox: { enforced: true, note: "" } }); | |
| 94 | const onRun = getRun(app.db, on.id)!; | |
| 95 | expect(onRun.sandboxEnforced).toBe(true); | |
| 96 | expect(String(sandboxLine(onRun))).not.toContain("Unsandboxed"); | |
| 97 | expect(String(sandboxLine(onRun))).toContain("Sandboxed"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a later progress note does not erase it", () => { | |
| 101 | const run = agentRun(); | |
| 102 | noteRunProgress(app.db, run.id, { sandbox: { enforced: false, note: "x" } }); | |
| 103 | noteRunProgress(app.db, run.id, { costUsd: 1 }); | |
| 104 | expect(getRun(app.db, run.id)!.sandboxEnforced).toBe(false); | |
| 105 | }); | |
| 106 | }); |
new above
offline