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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/298-the-image-the-forge-ships-has-no-bubblew → main 1 commit fork

  • Merged. scout/298-the-image-the-forge-ships-has-no-bubblew was merged into main as bc947db.

Review

Sign in to review this change.
11 files changed +142 −4
Dockerfile +1 −1
@@ -7,7 +7,7 @@
77 # nothing to install.
88 FROM oven/bun:1.3-debian
99 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 \
1111 && rm -rf /var/lib/apt/lists/* \
1212 && npm install -g @anthropic-ai/claude-code \
1313 && npm cache clean --force
README.md +11
@@ -295,6 +295,17 @@ Subscriptions, sealed, and grants each to a repository (Settings → Crew) or
295295 an organization (its page), down to one crew member: a work plan pays for
296296 the work repositories and nothing else. Two on one grant take turns, and
297297 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
298309 `docs/APPS.md` is the contract: the vocabulary, and the rules that are
299310 mechanical rather than advisory.
300311
src/core/runs.ts +16 −1
@@ -71,6 +71,10 @@ export interface Run {
7171 readonly heartbeatAt: string | null;
7272 /** For an agent run: how many turns the model took. */
7373 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;
7478 /** A failed check's own detail, when its output was recognised; null otherwise. */
7579 readonly checkDetail: CheckDetail | null;
7680 readonly createdAt: string;
@@ -98,6 +102,8 @@ interface RunRow {
98102 attempt: number;
99103 heartbeat_at: string | null;
100104 turns: number;
105 sandbox_enforced: number | null;
106 sandbox_note: string;
101107 check_detail: string;
102108 created_at: string;
103109 started_at: string | null;
@@ -123,6 +129,9 @@ const toRun = (r: RunRow): Run => ({
123129 attempt: r.attempt,
124130 heartbeatAt: r.heartbeat_at,
125131 turns: r.turns,
132 sandboxEnforced:
133 r.sandbox_enforced === null ? null : r.sandbox_enforced !== 0,
134 sandboxNote: r.sandbox_note,
126135 checkDetail: r.check_detail ? (JSON.parse(r.check_detail) as CheckDetail) : null,
127136 createdAt: r.created_at,
128137 startedAt: r.started_at,
@@ -295,11 +304,15 @@ export function noteRunProgress(
295304 branch?: string | undefined;
296305 /** The subscription the run works on, once picked. */
297306 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;
298309 },
299310 ): void {
300311 db.query(
301312 `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)
303316 WHERE id = ?1`,
304317 ).run(
305318 runId,
@@ -307,6 +320,8 @@ export function noteRunProgress(
307320 progress.costUsd ?? null,
308321 progress.branch ?? null,
309322 progress.subscriptionId ?? null,
323 progress.sandbox === undefined ? null : progress.sandbox.enforced ? 1 : 0,
324 progress.sandbox?.note.slice(0, 300) ?? null,
310325 );
311326 }
312327
src/core/schema.ts +7
@@ -1041,4 +1041,11 @@ export const migrations: readonly string[] = [
10411041 ALTER TABLE subscription ADD COLUMN headroom_pct INTEGER NOT NULL DEFAULT 0
10421042 CHECK (headroom_pct BETWEEN 0 AND 90);
10431043 `,
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 `,
10441051 ];
src/crew/dispatch.ts +3
@@ -74,6 +74,8 @@ export interface CrewHandle {
7474 /** Whether a usable Claude Code was found at start. */
7575 readonly ready: boolean;
7676 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 };
7779 /**
7880 * Stop. Plain, what is in flight is cut short and its task queued again.
7981 * Draining — a release's old process, with the new one already taking
@@ -162,6 +164,7 @@ export async function startCrew(
162164
163165 const handle: CrewHandle = {
164166 ready: detected.ok,
167 sandbox: { enforced: sandbox.ok, reason: sandbox.ok ? "" : sandbox.reason },
165168 detail: detected.ok ? `Claude Code ${detected.version}` : detected.error,
166169 stop: async (options = {}) => {
167170 stopped = true;
src/crew/runner.ts +6
@@ -725,6 +725,12 @@ async function work(
725725 write(
726726 `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`,
727727 );
728 noteRunProgress(app.db, run.id, {
729 sandbox: {
730 enforced: sandbox.enforced,
731 note: options.sandboxNote ?? "",
732 },
733 });
728734 if (!sandbox.enforced)
729735 write(
730736 `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 {
9393 </p>`;
9494 return html`<p class="muted small mb">
9595 ${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 )}`;
97105 }
98106
99107 // ---- the form ----
src/web/pages/runs.ts +2
@@ -39,6 +39,7 @@ import { flashTo, page, redirect } from "../layout.ts";
3939 import { liveClientJs, liveMount } from "../live.ts";
4040 import { studioRuns } from "../studio/lists.ts";
4141 import { pageTitle, studioRecord } from "../studio/nouns.ts";
42 import { sandboxLine } from "../studio/sandbox.ts";
4243 import { studioRun } from "../studio/run.ts";
4344 import {
4445 btn,
@@ -425,6 +426,7 @@ export async function runPage(ctx: Ctx): Promise<Response> {
425426 </div>
426427 </div>
427428 <p class="run-summary" id="run-summary">${run.summary}</p>
429 ${sandboxLine(run)}
428430 ${when(
429431 held.length > 0,
430432 () =>
src/web/studio/run.ts +2
@@ -28,6 +28,7 @@ import { page } from "../layout.ts";
2828 import { btn, icon } from "../ui.ts";
2929 import { previewCard } from "../pages/apps.ts";
3030 import { costText, durationText, runTitle } from "../pages/runs.ts";
31 import { sandboxLine } from "./sandbox.ts";
3132 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3233 import { changeHref, tellHref } from "./nouns.ts";
3334 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
@@ -155,6 +156,7 @@ export async function studioRun(
155156 )}
156157 </div>
157158 <p class="run-summary" id="run-summary">${run.summary}</p>
159 ${sandboxLine(run)}
158160 </div>`;
159161
160162 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 @@
44 * plainly, so a run reads "unsandboxed" once instead of warning on every
55 * command and running anyway.
66 */
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";
816 import { claudeSettings } from "../src/crew/claude.ts";
917 import { sandboxAvailable, sandboxPolicy } from "../src/crew/sandbox.ts";
1018
@@ -36,3 +44,63 @@ describe("the shell sandbox", () => {
3644 else if (!probe.ok) expect(probe.reason.length).toBeGreaterThan(0);
3745 });
3846 });
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("&lt;b&gt;bwrap&lt;/b&gt; 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs