Public: anyone can read this; the crew can change it.

A run gets exactly the secrets it declares, for exactly that run, and never in a transcript #376

Merged opened this in answer to

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

Merged

scout/forge:scout/336-a-run-gets-exactly-the-secrets-it-declar → main 4 commits fork

  • Merged. scout/336-a-run-gets-exactly-the-secrets-it-declar was merged into main as acee41b.

Review

Sign in to review this change.
12 files changed +638 −23
docs/APPS.md +19
@@ -265,6 +265,25 @@ and `lastReadAt`. The only read path is `resolveSecrets` in
265265 sealed under AAD `secret:<scope>:<name>`. Like every sealed row, they sit
266266 beside the key in the data directory and so in a snapshot.
267267
268 **A crew run's secrets.** An agent's brief, or an issue body written by someone
269 with write access, declares what a run may hold in front-matter at the top:
270
271 ---
272 secrets: [email protected], TF_BUCKET_KEY
273 ---
274
275 Only declared names are resolved (`crew/grant.ts`); malformed names and names
276 that would shadow the run's environment (`FORGE_*`, `GIT_*`, `PATH`, …) are
277 refused in the run's log. Values go into the run's env beside `FORGE_TOKEN`,
278 and `@host` widens the shell sandbox's egress for that run only. From the
279 moment they are opened the values are redacted (plain, base64, URL-encoded)
280 from the run log, the agent's last message and every event payload; before
281 the forge commits for the agent it scrubs them from the tree, and a branch
282 whose commits hold one is not pushed (`crew/leak.ts`). The names held are
283 recorded in `run_secret` and shown on the run page; the grant is dropped when
284 the run ends, and a sweep closes a crash's. A model that pushes by itself
285 cannot be stopped from pushing a value it transformed beyond those encodings.
286
268287 A process reads its environment once, at start, so setting or removing a
269288 variable redeploys what reads it, at its current commit, and nothing else:
270289 a variable for everywhere redeploys production and every open preview, one
src/core/events.ts +18 −2
@@ -7,7 +7,7 @@
77
88 import type { Database } from "bun:sqlite";
99 import { id } from "../kernel/id.ts";
10 import { createLog } from "../kernel/log.ts";
10 import { anyHeld, createLog, redactValues } from "../kernel/log.ts";
1111 import { repoVisible } from "./visibility.ts";
1212
1313 const log = createLog("events");
@@ -23,6 +23,22 @@ export interface ForgeEvent {
2323
2424 export type EventListener = (event: ForgeEvent) => void;
2525
26 /** A payload with every granted secret value taken out of its strings. */
27 function scrubPayload(payload: Record<string, unknown>): Record<string, unknown> {
28 if (!anyHeld()) return payload;
29 const walk = (v: unknown): unknown =>
30 typeof v === "string"
31 ? redactValues(v)
32 : Array.isArray(v)
33 ? v.map(walk)
34 : v !== null && typeof v === "object"
35 ? Object.fromEntries(
36 Object.entries(v).map(([k, x]) => [k, walk(x)]),
37 )
38 : v;
39 return walk(payload) as Record<string, unknown>;
40 }
41
2642 export class EventBus {
2743 private listeners = new Set<EventListener>();
2844
@@ -40,7 +56,7 @@ export class EventBus {
4056 kind: input.kind,
4157 repoId: input.repoId ?? null,
4258 actorId: input.actorId ?? null,
43 payload: input.payload ?? {},
59 payload: scrubPayload(input.payload ?? {}),
4460 createdAt: new Date().toISOString(),
4561 };
4662 this.db
src/core/run_secret.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * Which secret names a run held. Names and scopes only — the value never
3 * touches this table. A grant is recorded when the run starts and stamped
4 * released when it ends; `releaseOrphans` closes the ones a crash left open.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { SecretScope } from "./secret_store.ts";
9
10 /**
11 * The table is made here, on first use, rather than in `schema.ts`: it is
12 * this module's alone, and the shared migration list is where concurrent
13 * changes collide.
14 */
15 const made = new WeakSet<Database>();
16 function table(db: Database): void {
17 if (made.has(db)) return;
18 db.run(`CREATE TABLE IF NOT EXISTS run_secret (
19 run_id TEXT NOT NULL,
20 name TEXT NOT NULL,
21 scope TEXT NOT NULL,
22 granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
23 released_at TEXT,
24 PRIMARY KEY (run_id, name)
25 )`);
26 made.add(db);
27 }
28
29 export interface HeldSecret {
30 readonly name: string;
31 readonly scope: SecretScope;
32 readonly releasedAt: string | null;
33 }
34
35 export function recordGrant(
36 db: Database,
37 runId: string,
38 held: readonly { name: string; scope: SecretScope }[],
39 ): void {
40 table(db);
41 for (const h of held)
42 db.query(
43 `INSERT INTO run_secret (run_id, name, scope) VALUES (?1, ?2, ?3)
44 ON CONFLICT(run_id, name) DO UPDATE SET scope = excluded.scope, released_at = NULL`,
45 ).run(runId, h.name, h.scope);
46 }
47
48 export function releaseGrant(db: Database, runId: string): void {
49 table(db);
50 db.query(
51 `UPDATE run_secret SET released_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
52 WHERE run_id = ?1 AND released_at IS NULL`,
53 ).run(runId);
54 }
55
56 /** Close grants whose run is no longer running: a crash, a restart. */
57 export function releaseOrphans(db: Database): number {
58 table(db);
59 return db
60 .query(
61 `UPDATE run_secret SET released_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
62 WHERE released_at IS NULL
63 AND run_id NOT IN (SELECT id FROM run WHERE state = 'running')`,
64 )
65 .run().changes;
66 }
67
68 export function listHeldSecrets(db: Database, runId: string): HeldSecret[] {
69 table(db);
70 return db
71 .query<
72 { name: string; scope: string; released_at: string | null },
73 [string]
74 >(
75 "SELECT name, scope, released_at FROM run_secret WHERE run_id = ?1 ORDER BY name",
76 )
77 .all(runId)
78 .map((r) => ({
79 name: r.name,
80 scope: r.scope as SecretScope,
81 releasedAt: r.released_at,
82 }));
83 }
src/core/secret_store.ts +16 −2
@@ -153,7 +153,21 @@ export function resolveSecrets(
153153 names: readonly string[],
154154 who: SecretReader = {},
155155 ): Record<string, string> {
156 const out: Record<string, string> = {};
156 const scoped = resolveScoped(db, dataDir, bus, names, who);
157 return Object.fromEntries(
158 Object.entries(scoped).map(([name, s]) => [name, s.value]),
159 );
160 }
161
162 /** `resolveSecrets`, with the scope each value was found at. */
163 export function resolveScoped(
164 db: Database,
165 dataDir: string,
166 bus: EventBus,
167 names: readonly string[],
168 who: SecretReader = {},
169 ): Record<string, { value: string; scope: SecretScope }> {
170 const out: Record<string, { value: string; scope: SecretScope }> = {};
157171 const ladder: [SecretScope, string][] = [];
158172 if (who.repo) ladder.push(["repo", who.repo]);
159173 if (who.org) ladder.push(["org", who.org]);
@@ -187,7 +201,7 @@ export function resolveSecrets(
187201 actorId: who.actor,
188202 payload: { name, scope, run: who.run ?? null },
189203 });
190 out[name] = opened.value;
204 out[name] = { value: opened.value, scope };
191205 break;
192206 }
193207 }
src/crew/grant.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The secrets one run may hold. An issue or an agent's brief declares them
3 * in front-matter at the top of its text:
4 *
5 * ---
6 * secrets: [email protected], TF_BUCKET_KEY
7 * ---
8 *
9 * `NAME@host` also names the host the secret is for; the run's sandbox may
10 * reach exactly those hosts, for that run only. Whatever is not declared is
11 * not resolved, whatever is malformed or would shadow the run's own
12 * environment is refused by name, and the values are held for redaction from
13 * the moment they are opened until the grant is dropped.
14 */
15
16 import type { Database } from "bun:sqlite";
17 import type { EventBus } from "../core/events.ts";
18 import { recordGrant, releaseGrant } from "../core/run_secret.ts";
19 import { isSecretName, resolveScoped } from "../core/secret_store.ts";
20 import { holdValues, releaseValues } from "../kernel/log.ts";
21
22 export interface Declaration {
23 readonly names: readonly string[];
24 /** Hosts the declared secrets are for. */
25 readonly hosts: readonly string[];
26 /** The host each name is for, where it said. */
27 readonly hostOf: Readonly<Record<string, string>>;
28 /** Entries that were not accepted, with why — said in the run's log. */
29 readonly refused: readonly string[];
30 }
31
32 /** Names a secret may never take: the run's own environment. */
33 const RESERVED =
34 /^(FORGE_|GIT_|CLAUDE_|ANTHROPIC_|LD_|DYLD_|BUN_|NODE_)|^(PATH|HOME|USER|SHELL|PWD|TMPDIR|IFS)$/;
35
36 const HOST = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
37 const MAX_DECLARED = 16;
38
39 export function parseDeclaration(...texts: readonly string[]): Declaration {
40 const names: string[] = [];
41 const hosts = new Set<string>();
42 const hostOf: Record<string, string> = {};
43 const refused: string[] = [];
44 for (const text of texts) {
45 const m = /^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
46 if (m === null) continue;
47 for (const line of m[1]!.split(/\r?\n/)) {
48 const kv = /^secrets\s*:\s*(.*)$/i.exec(line.trim());
49 if (kv === null) continue;
50 for (const entry of kv[1]!.split(",")) {
51 const item = entry.trim();
52 if (item === "") continue;
53 const [name = "", host] = item.split("@") as [string, string?];
54 if (!isSecretName(name) || RESERVED.test(name)) {
55 refused.push(`${name.slice(0, 40)}: not a usable name`);
56 continue;
57 }
58 if (host !== undefined && !HOST.test(host.toLowerCase())) {
59 refused.push(`${name}: not a host name`);
60 continue;
61 }
62 if (names.includes(name)) continue;
63 if (names.length >= MAX_DECLARED) {
64 refused.push(`${name}: more than ${MAX_DECLARED} declared`);
65 continue;
66 }
67 names.push(name);
68 if (host !== undefined) {
69 hosts.add(host.toLowerCase());
70 hostOf[name] = host.toLowerCase();
71 }
72 }
73 }
74 }
75 return { names, hosts: [...hosts], hostOf, refused };
76 }
77
78 export interface Grant {
79 /** Env to merge beside `FORGE_TOKEN`. */
80 readonly env: Record<string, string>;
81 /** Names held, for the log. */
82 readonly names: readonly string[];
83 /** Declared but set at no scope. */
84 readonly missing: readonly string[];
85 /** Extra egress hosts for this run only. */
86 readonly hosts: readonly string[];
87 /** Drop the grant: values forgotten, record closed. Safe to call twice. */
88 readonly release: () => void;
89 }
90
91 export function grantSecrets(
92 db: Database,
93 dataDir: string,
94 bus: EventBus,
95 declared: Declaration,
96 run: { id: string; repoId: string; ownerId: string; actorId: string },
97 ): Grant {
98 const found =
99 declared.names.length === 0
100 ? {}
101 : resolveScoped(db, dataDir, bus, declared.names, {
102 repo: run.repoId,
103 org: run.ownerId,
104 actor: run.actorId,
105 run: run.id,
106 });
107 const names = Object.keys(found);
108 const env = Object.fromEntries(names.map((n) => [n, found[n]!.value]));
109 // Held before anything can log: a value is redacted from the first line.
110 if (names.length > 0) {
111 holdValues(`run:${run.id}`, Object.values(env));
112 recordGrant(
113 db,
114 run.id,
115 names.map((name) => ({ name, scope: found[name]!.scope })),
116 );
117 }
118 return {
119 env,
120 names,
121 missing: declared.names.filter((n) => !(n in found)),
122 // Only a secret that was actually granted widens the egress.
123 hosts: [...new Set(names.flatMap((n) => declared.hostOf[n] ?? []))],
124 release: () => {
125 releaseValues(`run:${run.id}`);
126 releaseGrant(db, run.id);
127 },
128 };
129 }
src/crew/leak.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * A model can echo a granted value into a file and commit it. Before the
3 * forge commits or pushes for an agent, the clone is read for the values
4 * the run held: files in the tree are scrubbed in place, and commits the
5 * agent already made are searched, because history cannot be scrubbed
6 * quietly — a branch that holds a value is not pushed.
7 */
8
9 import { readFileSync, statSync, writeFileSync } from "node:fs";
10 import { join } from "node:path";
11 import { runGit, runGitCapture } from "../git/env.ts";
12 import { formsOf, redactValues } from "../kernel/log.ts";
13
14 const MAX_FILE = 2 * 1024 * 1024;
15
16 /** Rewrite working-tree files that hold a value; returns their paths. */
17 export async function scrubTree(
18 dir: string,
19 values: readonly string[],
20 env: Record<string, string>,
21 ): Promise<string[]> {
22 const forms = [...new Set(values.flatMap(formsOf))];
23 if (forms.length === 0) return [];
24 const listed = await runGit(
25 ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
26 { cwd: dir, env },
27 );
28 if (!listed.ok) return [];
29 const touched: string[] = [];
30 for (const rel of listed.value.split("\0")) {
31 if (rel === "") continue;
32 const path = join(dir, rel);
33 try {
34 const st = statSync(path);
35 if (!st.isFile() || st.size > MAX_FILE) continue;
36 const text = readFileSync(path, "utf8");
37 const clean = redactValues(text, forms);
38 if (clean === text) continue;
39 writeFileSync(path, clean);
40 touched.push(rel);
41 } catch {
42 // Unreadable or vanished: nothing to scrub.
43 }
44 }
45 return touched;
46 }
47
48 /** Whether any commit in `range` carries a value in its patch or message. */
49 export async function historyHolds(
50 dir: string,
51 range: string,
52 values: readonly string[],
53 env: Record<string, string>,
54 ): Promise<boolean> {
55 const forms = [...new Set(values.flatMap(formsOf))];
56 if (forms.length === 0) return false;
57 const out = await runGitCapture(["log", "-p", "--no-color", range], {
58 cwd: dir,
59 env,
60 });
61 return forms.some((f) => out.stdout.includes(f));
62 }
src/crew/runner.ts +65 −6
@@ -29,7 +29,13 @@ import {
2929 type Issue,
3030 } from "../core/issues.ts";
3131 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
32 import { getForkOf, getRepoById, type Repo } from "../core/repos.ts";
32 import {
33 canWrite,
34 getForkOf,
35 getRepoById,
36 type Repo,
37 } from "../core/repos.ts";
38 import { getUserById } from "../core/users.ts";
3339 import { listReviewComments, listReviews } from "../core/reviews.ts";
3440 import {
3541 cancelRun,
@@ -47,7 +53,7 @@ import { runGitCapture } from "../git/env.ts";
4753 import { looksForEnv } from "../core/looks.ts";
4854 import { aheadCount, resolveOid } from "../git/merge.ts";
4955 import { repoDir } from "../git/repos.ts";
50 import { createLog } from "../kernel/log.ts";
56 import { createLog, redactValues } from "../kernel/log.ts";
5157 import { err, ok, type Result } from "../kernel/result.ts";
5258 import { holdLease } from "../runs/lease.ts";
5359 import { appendRunLog, readRunLog } from "../runs/log.ts";
@@ -69,6 +75,8 @@ import {
6975 } from "../core/subscriptions.ts";
7076 import { trackAgentRun, untrackAgentRun } from "./running.ts";
7177 import { sandboxPolicy } from "./sandbox.ts";
78 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
79 import { historyHolds, scrubTree } from "./leak.ts";
7280 import { workdirFor } from "./workdir.ts";
7381
7482 const log = createLog("crew");
@@ -432,7 +440,27 @@ async function work(
432440 GIT_COMMITTER_EMAIL: agent.user.email,
433441 };
434442
443 let grant: Grant | undefined;
435444 try {
445 // The secrets this run declared, for this run only: opened now, held for
446 // redaction from this moment, dropped in the `finally` below — a crash
447 // included.
448 const author = getUserById(app.db, issue.authorId);
449 const declared = parseDeclaration(
450 agent.brief,
451 author !== undefined && canWrite(app.db, repo, author) ? issue.body : "",
452 );
453 grant = grantSecrets(app.db, app.config.dataDir, app.bus, declared, {
454 id: run.id,
455 repoId: repo.id,
456 ownerId: repo.ownerId,
457 actorId: agent.user.id,
458 });
459 for (const why of declared.refused) write(`secret refused: ${why}`);
460 if (grant.names.length > 0)
461 write(`secrets: holding ${grant.names.join(", ")} for this run`);
462 for (const name of grant.missing) write(`secret not set: ${name}`);
463 for (const host of grant.hosts) write(`egress: +${host} for this run`);
436464 const bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
437465 const forkBare = repoDir(
438466 app.reposRoot,
@@ -584,6 +612,7 @@ async function work(
584612 FORGE_URL: options.forgeUrl,
585613 FORGE_TOKEN: token.token,
586614 ...credentialEnv(picked),
615 ...grant.env,
587616 ...(options.configDir === undefined
588617 ? {}
589618 : { CLAUDE_CONFIG_DIR: options.configDir }),
@@ -597,18 +626,18 @@ async function work(
597626 dataDir: app.config.dataDir,
598627 agentName: agent.user.username,
599628 urls: [options.localUrl, options.forgeUrl],
600 extraHosts: options.allowHosts,
629 extraHosts: [...(options.allowHosts ?? []), ...grant.hosts],
601630 enforced: options.sandboxEnforced,
602631 });
603632 // One deadline for the run, whatever it takes to get there: the token
604633 // dies ten minutes after it, so a second invocation gets what is left.
605634 const deadline = Date.now() + options.runTimeoutMs;
606 const invoke = (
635 const invoke = async (
607636 prompt: string,
608637 budgetUsd: number,
609638 resume: string | undefined,
610639 ) =>
611 runClaude({
640 redactResult(await runClaude({
612641 binary: options.binary,
613642 cwd: dir,
614643 prompt,
@@ -622,7 +651,7 @@ async function work(
622651 log: write,
623652 signal,
624653 onSteer: (send) => steering.set(run.id, send),
625 });
654 }));
626655 write(
627656 `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`,
628657 );
@@ -781,6 +810,7 @@ async function work(
781810 ? undefined
782811 : `Budget reached at $${stoppedAt.toFixed(2)}; this is where it stopped.`,
783812 write,
813 Object.values(grant.env),
784814 );
785815 return {
786816 state: result.ok || landed.pushed ? "ok" : "failed",
@@ -790,6 +820,7 @@ async function work(
790820 turns: result.turns,
791821 };
792822 } finally {
823 grant?.release();
793824 deleteToken(app.db, agent.user.id, token.id);
794825 }
795826 }
@@ -958,7 +989,14 @@ async function landWork(
958989 message: string,
959990 note: string | undefined,
960991 write: (line: string) => void,
992 granted: readonly string[] = [],
961993 ): Promise<{ pushed: boolean; summary: string }> {
994 // A model may echo a granted value into a file; the tree is scrubbed
995 // before the forge commits it, and the agent's own commits are searched
996 // before anything is pushed.
997 const scrubbed = await scrubTree(dir, granted, env);
998 if (scrubbed.length > 0)
999 write(`scrubbed a granted secret value from ${scrubbed.join(", ")}`);
9621000 // Work left in the tree is committed on the agent's behalf: a proposal a
9631001 // person can read beats an edit nobody sees. The summary says it happened.
9641002 const status = await runGit(["status", "--porcelain"], { cwd: dir, env });
@@ -1003,6 +1041,22 @@ async function landWork(
10031041 const against = remote ?? base;
10041042 const ahead =
10051043 against === undefined ? 1 : await aheadCount(dir, against, local);
1044 if (
1045 ahead > 0 &&
1046 (await historyHolds(
1047 dir,
1048 against === undefined ? "HEAD" : `${against}..HEAD`,
1049 granted,
1050 env,
1051 ))
1052 ) {
1053 write("refused: a granted secret value is in the commits; not pushed");
1054 return {
1055 pushed: false,
1056 summary:
1057 "refused to push: a secret this run held is in its commits; nothing pushed",
1058 };
1059 }
10061060 if (ahead > 0) {
10071061 write(
10081062 `pushing ${branch} (${ahead} commit${ahead === 1 ? "" : "s"} the agent did not push)`,
@@ -1084,6 +1138,11 @@ async function landWork(
10841138 };
10851139 }
10861140
1141 /** What the agent said, with every held secret value taken out of it. */
1142 function redactResult<T extends { result: string }>(r: T): T {
1143 return { ...r, result: redactValues(r.result) };
1144 }
1145
10871146 /**
10881147 * The credential a run's environment carries: the picked account's token
10891148 * under the variable Claude Code reads it from, or — with nothing granted —
src/kernel/log.ts +56 −1
@@ -19,6 +19,61 @@ export function redactSecrets(text: string): string {
1919 return text.replace(SECRETS, "[redacted]");
2020 }
2121
22 /**
23 * Values that are secret because the forge handed them out, not because
24 * of their shape: a run's granted credentials. Held under a key (the run's
25 * id) for as long as the grant lives; every log line and event payload
26 * passes through `redactHeld` while any are. A value is also matched in
27 * its base64 and URL-encoded forms, which is how a tool most often echoes it.
28 */
29 const held = new Map<string, string[]>();
30 let heldForms: string[] = [];
31 const MIN_HELD = 4;
32
33 export const formsOf = (value: string): string[] => [
34 value,
35 Buffer.from(value).toString("base64").replace(/=+$/, ""),
36 encodeURIComponent(value),
37 ];
38
39 const rebuild = (): void => {
40 heldForms = [...new Set([...held.values()].flat())].sort(
41 (a, b) => b.length - a.length,
42 );
43 };
44
45 /** Start redacting these values everywhere, until `releaseValues(key)`. */
46 export function holdValues(key: string, values: readonly string[]): void {
47 held.set(
48 key,
49 values.filter((v) => v.length >= MIN_HELD).flatMap(formsOf),
50 );
51 rebuild();
52 }
53
54 export function releaseValues(key: string): void {
55 if (held.delete(key)) rebuild();
56 }
57
58 /** Replace every held value in `text` with a marker. */
59 export function redactValues(
60 text: string,
61 values: readonly string[] = heldForms,
62 ): string {
63 let out = text;
64 for (const value of values)
65 if (value !== "" && out.includes(value)) out = out.split(value).join("[redacted]");
66 return out;
67 }
68
69 /** Credential shapes and every held value. */
70 export function redactHeld(text: string): string {
71 return redactValues(redactSecrets(text));
72 }
73
74 /** Whether any value is held: the cheap test before walking a payload. */
75 export const anyHeld = (): boolean => heldForms.length > 0;
76
2277 export interface Log {
2378 debug(message: string, fields?: LogFields): void;
2479 info(message: string, fields?: LogFields): void;
@@ -114,7 +169,7 @@ export function createLog(scope: string): Log {
114169 line += ` ${key}=${/[\s"=]/.test(text) ? JSON.stringify(text) : text}`;
115170 }
116171 }
117 line = redactSecrets(line);
172 line = redactHeld(line);
118173 if (level === "error") {
119174 const body = line.slice(line.indexOf(`[${scope}] `) + scope.length + 3);
120175 record(scope, body, Date.now());
src/main.ts +10 −9
@@ -19,6 +19,7 @@ import { startLooks } from "./looks/service.ts";
1919 import { startProbes } from "./contract/probes.ts";
2020 import { startSnapshots } from "./snapshot/service.ts";
2121 import { resolveDestination } from "./snapshot/destination.ts";
22 import { releaseOrphans } from "./core/run_secret.ts";
2223 import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts";
2324 import { health } from "./http/health.ts";
2425 import { STALE_AFTER_MS } from "./runs/lease.ts";
@@ -253,15 +254,15 @@ if (ssh)
253254 // by renewing their leases, so only a lapsed lease is a death. Deploys and
254255 // checks are failed; agent runs are the crew's to queue again, unless there
255256 // is no crew here to do it.
256 const leaseTimer = setInterval(
257 () =>
258 failAbandonedRuns(
259 app.db,
260 STALE_AFTER_MS,
261 crew === undefined ? undefined : CHECK_KINDS,
262 ),
263 30_000,
264 );
257 const leaseTimer = setInterval(() => {
258 failAbandonedRuns(
259 app.db,
260 STALE_AFTER_MS,
261 crew === undefined ? undefined : CHECK_KINDS,
262 );
263 // A grant whose run is no longer running is a crash's leftover: close it.
264 releaseOrphans(app.db);
265 }, 30_000);
265266
266267 process.on("SIGINT", shutdown);
267268 process.on("SIGTERM", shutdown);
src/runs/log.ts +4 −3
@@ -4,7 +4,8 @@
44 * it — and the in-process listeners are the optimisation that makes a log
55 * line appear the moment it is written, on both surfaces, without a poll.
66 *
7 * Every line passes through `redactSecrets` on the way in. A run's token
7 * Every line passes through `redactHeld` on the way in: credential shapes,
8 * and the exact values a run was granted. A run's token
89 * is in its environment, a curl in the agent's transcript may echo a
910 * header, and a log page is read by people the token was never meant for.
1011 */
@@ -19,7 +20,7 @@ import {
1920 closeSync,
2021 } from "node:fs";
2122 import { join } from "node:path";
22 import { redactSecrets } from "../kernel/log.ts";
23 import { redactHeld } from "../kernel/log.ts";
2324
2425 export const runsRoot = (dataDir: string): string => join(dataDir, "runs");
2526
@@ -39,7 +40,7 @@ export function appendRunLog(
3940 runId: string,
4041 line: string,
4142 ): void {
42 const clean = redactSecrets(line.replace(/\r?\n/g, " ")).slice(0, MAX_LINE);
43 const clean = redactHeld(line.replace(/\r?\n/g, " ")).slice(0, MAX_LINE);
4344 mkdirSync(runsRoot(dataDir), { recursive: true });
4445 appendFileSync(runLogPath(dataDir, runId), `${clean}\n`);
4546 const watching = listeners.get(runId);
src/web/pages/runs.ts +13
@@ -19,6 +19,7 @@ import {
1919 } from "../../actions/runs.ts";
2020 import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts";
2121 import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts";
22 import { listHeldSecrets } from "../../core/run_secret.ts";
2223 import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts";
2324 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
2425 import type { Ctx } from "../../http/context.ts";
@@ -366,6 +367,7 @@ export async function runPage(ctx: Ctx): Promise<Response> {
366367 const duration = durationText(run);
367368 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
368369 const who = actor?.username ?? "agent";
370 const held = listHeldSecrets(ctx.app.db, run.id);
369371
370372 const body = html`${repoHeader(ctx, repo, "app", {
371373 compact: true,
@@ -423,6 +425,17 @@ export async function runPage(ctx: Ctx): Promise<Response> {
423425 </div>
424426 </div>
425427 <p class="run-summary" id="run-summary">${run.summary}</p>
428 ${when(
429 held.length > 0,
430 () =>
431 html`<p class="muted small" id="run-secrets">
432 Secrets held: ${held.map((h) => h.name).join(", ")}${held.every(
433 (h) => h.releasedAt !== null,
434 )
435 ? " (released)"
436 : ""}. Values are never shown.
437 </p>`,
438 )}
426439 ${when(
427440 writer && unfinished && run.kind === "agent" && issue === undefined,
428441 () =>
test/run-secrets.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * A run gets exactly the secrets it declares, for that run only, and a
3 * value never reaches a log, an event, or a commit.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
14 import { historyHolds, scrubTree } from "../src/crew/leak.ts";
15 import { listHeldSecrets, releaseOrphans } from "../src/core/run_secret.ts";
16 import { setSecret } from "../src/core/secret_store.ts";
17 import { sandboxPolicy } from "../src/crew/sandbox.ts";
18 import { runGit } from "../src/git/env.ts";
19 import { redactHeld } from "../src/kernel/log.ts";
20 import { appendRunLog, readRunLog } from "../src/runs/log.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 const VALUE = "cf-live-7Hq2xZp9LmN4vB8w";
26 let run = { id: "run1", repoId: "", ownerId: "", actorId: "" };
27
28 describe("declaring secrets", () => {
29 test("names and hosts are read from front-matter", () => {
30 const d = parseDeclaration(
31 "---\nsecrets: [email protected], BUCKET_KEY\n---\nbody",
32 );
33 expect(d.names).toEqual(["CF_TOKEN", "BUCKET_KEY"]);
34 expect(d.hosts).toEqual(["api.cloudflare.com"]);
35 expect(d.refused).toEqual([]);
36 });
37
38 test("text without front-matter at the top declares nothing", () => {
39 expect(parseDeclaration("hello\n---\nsecrets: CF_TOKEN\n---").names).toEqual([]);
40 });
41
42 test("bad names, reserved names and bad hosts are refused", () => {
43 const d = parseDeclaration(
44 "---\nsecrets: lower, FORGE_TOKEN, PATH, GIT_ASKPASS, OK@evil host, [email protected]/x\n---",
45 );
46 expect(d.names).toEqual([]);
47 expect(d.refused.length).toBe(6);
48 });
49 });
50
51 describe("a grant", () => {
52 let dataDir = "";
53 let app: App;
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-run-secrets-"));
56 app = createApp({ dataDir, port: 0 });
57 const u = await createUser(app.db, {
58 username: "alice",
59 email: "[email protected]",
60 password: "a-good-passphrase",
61 adminIfFirst: true,
62 });
63 if (!u.ok) throw new Error(u.error);
64 const r = await createRepo(app, u.value, { name: "site", visibility: "private" });
65 if (!r.ok) throw new Error(String(r.error));
66 run = { id: "run1", repoId: r.value.id, ownerId: r.value.ownerId, actorId: u.value.id };
67 setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", VALUE);
68 setSecret(app.db, dataDir, "forge", "", "UNDECLARED", "other-value-1234");
69 });
70 afterAll(async () => {
71 app.close();
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 test("carries only declared names, widens egress by their hosts, and ends", () => {
76 const d = parseDeclaration(
77 "---\nsecrets: [email protected], [email protected]\n---",
78 );
79 const grant = grantSecrets(app.db, dataDir, app.bus, d, run);
80 expect(grant.env).toEqual({ CF_TOKEN: VALUE });
81 expect(grant.missing).toEqual(["MISSING"]);
82 expect(grant.hosts).toEqual(["api.cloudflare.com"]);
83 expect(listHeldSecrets(app.db, "run1")).toEqual([
84 { name: "CF_TOKEN", scope: "forge", releasedAt: null },
85 ]);
86
87 const policy = sandboxPolicy({
88 dataDir,
89 agentName: "scout",
90 urls: [],
91 extraHosts: grant.hosts,
92 });
93 expect(policy.allowHosts).toContain("api.cloudflare.com");
94 expect(sandboxPolicy({ dataDir, agentName: "scout", urls: [] }).allowHosts).not.toContain(
95 "api.cloudflare.com",
96 );
97
98 // While held: never in a log line, in any common encoding, or an event.
99 appendRunLog(dataDir, "run1", `curl said ${VALUE} and ${btoa(VALUE)}`);
100 appendRunLog(dataDir, "run1", `encoded ${encodeURIComponent(VALUE)}`);
101 const logged = readRunLog(dataDir, "run1").lines.join("\n");
102 expect(logged).not.toContain(VALUE);
103 expect(logged).not.toContain(btoa(VALUE));
104 const ev = app.bus.append({ kind: "run.note", payload: { nested: [`x ${VALUE}`] } });
105 expect(JSON.stringify(ev.payload)).not.toContain(VALUE);
106 const row = app.db
107 .query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'run.note'")
108 .get()!;
109 expect(row.payload).not.toContain(VALUE);
110 // The audit of the open names the secret, never the value.
111 const reads = app.db
112 .query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'secret.read'")
113 .all();
114 expect(reads.length).toBe(1);
115 expect(reads[0]!.payload).toContain("CF_TOKEN");
116 expect(reads[0]!.payload).not.toContain(VALUE);
117
118 grant.release();
119 expect(listHeldSecrets(app.db, "run1")[0]!.releasedAt).not.toBeNull();
120 expect(redactHeld(`plain ${VALUE}`)).toContain(VALUE);
121 });
122
123 test("a crash's open grant is closed by the sweep", () => {
124 const d = parseDeclaration("---\nsecrets: CF_TOKEN\n---");
125 grantSecrets(app.db, dataDir, app.bus, d, { ...run, id: "run2" });
126 expect(listHeldSecrets(app.db, "run2")[0]!.releasedAt).toBeNull();
127 expect(releaseOrphans(app.db)).toBe(1);
128 expect(listHeldSecrets(app.db, "run2")[0]!.releasedAt).not.toBeNull();
129 });
130 });
131
132 describe("a value echoed into a file", () => {
133 let dir = "";
134 beforeAll(async () => {
135 dir = await mkdtemp(join(tmpdir(), "forge-leak-"));
136 for (const args of [
137 ["init", "-q", "-b", "main"],
138 ["config", "user.email", "[email protected]"],
139 ["config", "user.name", "a"],
140 ["commit", "-q", "--allow-empty", "-m", "base"],
141 ])
142 await runGit(args, { cwd: dir });
143 });
144 afterAll(() => rm(dir, { recursive: true, force: true }));
145
146 test("is scrubbed from the tree before the forge commits it", async () => {
147 await writeFile(join(dir, "main.tf"), `token = "${VALUE}"\n`);
148 const touched = await scrubTree(dir, [VALUE], {});
149 expect(touched).toEqual(["main.tf"]);
150 expect(await readFile(join(dir, "main.tf"), "utf8")).not.toContain(VALUE);
151 await runGit(["add", "-A"], { cwd: dir });
152 await runGit(["commit", "-q", "-m", "clean"], { cwd: dir });
153 expect(await historyHolds(dir, "HEAD~1..HEAD", [VALUE], {})).toBe(false);
154 });
155
156 test("is found in a commit the agent made itself", async () => {
157 await writeFile(join(dir, "leak.txt"), `${btoa(VALUE)}\n`);
158 await runGit(["add", "-A"], { cwd: dir });
159 await runGit(["commit", "-q", "-m", "oops"], { cwd: dir });
160 expect(await historyHolds(dir, "HEAD~1..HEAD", [VALUE], {})).toBe(true);
161 expect(await historyHolds(dir, "HEAD~1..HEAD", ["unrelated-value"], {})).toBe(false);
162 });
163 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs