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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/336-a-run-gets-exactly-the-secrets-it-declar → main 4 commits fork
12 files
changed +638
−23
docs/APPS.md +19
| @@ -265,6 +265,25 @@ and `lastReadAt`. The only read path is `resolveSecrets` in | ||
| 265 | 265 | sealed under AAD `secret:<scope>:<name>`. Like every sealed row, they sit |
| 266 | 266 | beside the key in the data directory and so in a snapshot. |
| 267 | 267 | |
| 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 | ||
| 268 | 287 | A process reads its environment once, at start, so setting or removing a |
| 269 | 288 | variable redeploys what reads it, at its current commit, and nothing else: |
| 270 | 289 | a variable for everywhere redeploys production and every open preview, one |
src/core/events.ts +18 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { id } from "../kernel/id.ts"; |
| 10 | import { createLog } from "../kernel/log.ts"; | |
| 10 | import { anyHeld, createLog, redactValues } from "../kernel/log.ts"; | |
| 11 | 11 | import { repoVisible } from "./visibility.ts"; |
| 12 | 12 | |
| 13 | 13 | const log = createLog("events"); |
| @@ -23,6 +23,22 @@ export interface ForgeEvent { | ||
| 23 | 23 | |
| 24 | 24 | export type EventListener = (event: ForgeEvent) => void; |
| 25 | 25 | |
| 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 | ||
| 26 | 42 | export class EventBus { |
| 27 | 43 | private listeners = new Set<EventListener>(); |
| 28 | 44 | |
| @@ -40,7 +56,7 @@ export class EventBus { | ||
| 40 | 56 | kind: input.kind, |
| 41 | 57 | repoId: input.repoId ?? null, |
| 42 | 58 | actorId: input.actorId ?? null, |
| 43 | payload: input.payload ?? {}, | |
| 59 | payload: scrubPayload(input.payload ?? {}), | |
| 44 | 60 | createdAt: new Date().toISOString(), |
| 45 | 61 | }; |
| 46 | 62 | 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( | ||
| 153 | 153 | names: readonly string[], |
| 154 | 154 | who: SecretReader = {}, |
| 155 | 155 | ): 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 }> = {}; | |
| 157 | 171 | const ladder: [SecretScope, string][] = []; |
| 158 | 172 | if (who.repo) ladder.push(["repo", who.repo]); |
| 159 | 173 | if (who.org) ladder.push(["org", who.org]); |
| @@ -187,7 +201,7 @@ export function resolveSecrets( | ||
| 187 | 201 | actorId: who.actor, |
| 188 | 202 | payload: { name, scope, run: who.run ?? null }, |
| 189 | 203 | }); |
| 190 | out[name] = opened.value; | |
| 204 | out[name] = { value: opened.value, scope }; | |
| 191 | 205 | break; |
| 192 | 206 | } |
| 193 | 207 | } |
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 { | ||
| 29 | 29 | type Issue, |
| 30 | 30 | } from "../core/issues.ts"; |
| 31 | 31 | 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"; | |
| 33 | 39 | import { listReviewComments, listReviews } from "../core/reviews.ts"; |
| 34 | 40 | import { |
| 35 | 41 | cancelRun, |
| @@ -47,7 +53,7 @@ import { runGitCapture } from "../git/env.ts"; | ||
| 47 | 53 | import { looksForEnv } from "../core/looks.ts"; |
| 48 | 54 | import { aheadCount, resolveOid } from "../git/merge.ts"; |
| 49 | 55 | import { repoDir } from "../git/repos.ts"; |
| 50 | import { createLog } from "../kernel/log.ts"; | |
| 56 | import { createLog, redactValues } from "../kernel/log.ts"; | |
| 51 | 57 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 52 | 58 | import { holdLease } from "../runs/lease.ts"; |
| 53 | 59 | import { appendRunLog, readRunLog } from "../runs/log.ts"; |
| @@ -69,6 +75,8 @@ import { | ||
| 69 | 75 | } from "../core/subscriptions.ts"; |
| 70 | 76 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; |
| 71 | 77 | import { sandboxPolicy } from "./sandbox.ts"; |
| 78 | import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts"; | |
| 79 | import { historyHolds, scrubTree } from "./leak.ts"; | |
| 72 | 80 | import { workdirFor } from "./workdir.ts"; |
| 73 | 81 | |
| 74 | 82 | const log = createLog("crew"); |
| @@ -432,7 +440,27 @@ async function work( | ||
| 432 | 440 | GIT_COMMITTER_EMAIL: agent.user.email, |
| 433 | 441 | }; |
| 434 | 442 | |
| 443 | let grant: Grant | undefined; | |
| 435 | 444 | 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`); | |
| 436 | 464 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 437 | 465 | const forkBare = repoDir( |
| 438 | 466 | app.reposRoot, |
| @@ -584,6 +612,7 @@ async function work( | ||
| 584 | 612 | FORGE_URL: options.forgeUrl, |
| 585 | 613 | FORGE_TOKEN: token.token, |
| 586 | 614 | ...credentialEnv(picked), |
| 615 | ...grant.env, | |
| 587 | 616 | ...(options.configDir === undefined |
| 588 | 617 | ? {} |
| 589 | 618 | : { CLAUDE_CONFIG_DIR: options.configDir }), |
| @@ -597,18 +626,18 @@ async function work( | ||
| 597 | 626 | dataDir: app.config.dataDir, |
| 598 | 627 | agentName: agent.user.username, |
| 599 | 628 | urls: [options.localUrl, options.forgeUrl], |
| 600 | extraHosts: options.allowHosts, | |
| 629 | extraHosts: [...(options.allowHosts ?? []), ...grant.hosts], | |
| 601 | 630 | enforced: options.sandboxEnforced, |
| 602 | 631 | }); |
| 603 | 632 | // One deadline for the run, whatever it takes to get there: the token |
| 604 | 633 | // dies ten minutes after it, so a second invocation gets what is left. |
| 605 | 634 | const deadline = Date.now() + options.runTimeoutMs; |
| 606 | const invoke = ( | |
| 635 | const invoke = async ( | |
| 607 | 636 | prompt: string, |
| 608 | 637 | budgetUsd: number, |
| 609 | 638 | resume: string | undefined, |
| 610 | 639 | ) => |
| 611 | runClaude({ | |
| 640 | redactResult(await runClaude({ | |
| 612 | 641 | binary: options.binary, |
| 613 | 642 | cwd: dir, |
| 614 | 643 | prompt, |
| @@ -622,7 +651,7 @@ async function work( | ||
| 622 | 651 | log: write, |
| 623 | 652 | signal, |
| 624 | 653 | onSteer: (send) => steering.set(run.id, send), |
| 625 | }); | |
| 654 | })); | |
| 626 | 655 | write( |
| 627 | 656 | `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`, |
| 628 | 657 | ); |
| @@ -781,6 +810,7 @@ async function work( | ||
| 781 | 810 | ? undefined |
| 782 | 811 | : `Budget reached at $${stoppedAt.toFixed(2)}; this is where it stopped.`, |
| 783 | 812 | write, |
| 813 | Object.values(grant.env), | |
| 784 | 814 | ); |
| 785 | 815 | return { |
| 786 | 816 | state: result.ok || landed.pushed ? "ok" : "failed", |
| @@ -790,6 +820,7 @@ async function work( | ||
| 790 | 820 | turns: result.turns, |
| 791 | 821 | }; |
| 792 | 822 | } finally { |
| 823 | grant?.release(); | |
| 793 | 824 | deleteToken(app.db, agent.user.id, token.id); |
| 794 | 825 | } |
| 795 | 826 | } |
| @@ -958,7 +989,14 @@ async function landWork( | ||
| 958 | 989 | message: string, |
| 959 | 990 | note: string | undefined, |
| 960 | 991 | write: (line: string) => void, |
| 992 | granted: readonly string[] = [], | |
| 961 | 993 | ): 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(", ")}`); | |
| 962 | 1000 | // Work left in the tree is committed on the agent's behalf: a proposal a |
| 963 | 1001 | // person can read beats an edit nobody sees. The summary says it happened. |
| 964 | 1002 | const status = await runGit(["status", "--porcelain"], { cwd: dir, env }); |
| @@ -1003,6 +1041,22 @@ async function landWork( | ||
| 1003 | 1041 | const against = remote ?? base; |
| 1004 | 1042 | const ahead = |
| 1005 | 1043 | 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 | } | |
| 1006 | 1060 | if (ahead > 0) { |
| 1007 | 1061 | write( |
| 1008 | 1062 | `pushing ${branch} (${ahead} commit${ahead === 1 ? "" : "s"} the agent did not push)`, |
| @@ -1084,6 +1138,11 @@ async function landWork( | ||
| 1084 | 1138 | }; |
| 1085 | 1139 | } |
| 1086 | 1140 | |
| 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 | ||
| 1087 | 1146 | /** |
| 1088 | 1147 | * The credential a run's environment carries: the picked account's token |
| 1089 | 1148 | * 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 { | ||
| 19 | 19 | return text.replace(SECRETS, "[redacted]"); |
| 20 | 20 | } |
| 21 | 21 | |
| 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 | ||
| 22 | 77 | export interface Log { |
| 23 | 78 | debug(message: string, fields?: LogFields): void; |
| 24 | 79 | info(message: string, fields?: LogFields): void; |
| @@ -114,7 +169,7 @@ export function createLog(scope: string): Log { | ||
| 114 | 169 | line += ` ${key}=${/[\s"=]/.test(text) ? JSON.stringify(text) : text}`; |
| 115 | 170 | } |
| 116 | 171 | } |
| 117 | line = redactSecrets(line); | |
| 172 | line = redactHeld(line); | |
| 118 | 173 | if (level === "error") { |
| 119 | 174 | const body = line.slice(line.indexOf(`[${scope}] `) + scope.length + 3); |
| 120 | 175 | record(scope, body, Date.now()); |
src/main.ts +10 −9
| @@ -19,6 +19,7 @@ import { startLooks } from "./looks/service.ts"; | ||
| 19 | 19 | import { startProbes } from "./contract/probes.ts"; |
| 20 | 20 | import { startSnapshots } from "./snapshot/service.ts"; |
| 21 | 21 | import { resolveDestination } from "./snapshot/destination.ts"; |
| 22 | import { releaseOrphans } from "./core/run_secret.ts"; | |
| 22 | 23 | import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts"; |
| 23 | 24 | import { health } from "./http/health.ts"; |
| 24 | 25 | import { STALE_AFTER_MS } from "./runs/lease.ts"; |
| @@ -253,15 +254,15 @@ if (ssh) | ||
| 253 | 254 | // by renewing their leases, so only a lapsed lease is a death. Deploys and |
| 254 | 255 | // checks are failed; agent runs are the crew's to queue again, unless there |
| 255 | 256 | // 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); | |
| 265 | 266 | |
| 266 | 267 | process.on("SIGINT", shutdown); |
| 267 | 268 | process.on("SIGTERM", shutdown); |
src/runs/log.ts +4 −3
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * it — and the in-process listeners are the optimisation that makes a log |
| 5 | 5 | * line appear the moment it is written, on both surfaces, without a poll. |
| 6 | 6 | * |
| 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 | |
| 8 | 9 | * is in its environment, a curl in the agent's transcript may echo a |
| 9 | 10 | * header, and a log page is read by people the token was never meant for. |
| 10 | 11 | */ |
| @@ -19,7 +20,7 @@ import { | ||
| 19 | 20 | closeSync, |
| 20 | 21 | } from "node:fs"; |
| 21 | 22 | import { join } from "node:path"; |
| 22 | import { redactSecrets } from "../kernel/log.ts"; | |
| 23 | import { redactHeld } from "../kernel/log.ts"; | |
| 23 | 24 | |
| 24 | 25 | export const runsRoot = (dataDir: string): string => join(dataDir, "runs"); |
| 25 | 26 | |
| @@ -39,7 +40,7 @@ export function appendRunLog( | ||
| 39 | 40 | runId: string, |
| 40 | 41 | line: string, |
| 41 | 42 | ): 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); | |
| 43 | 44 | mkdirSync(runsRoot(dataDir), { recursive: true }); |
| 44 | 45 | appendFileSync(runLogPath(dataDir, runId), `${clean}\n`); |
| 45 | 46 | const watching = listeners.get(runId); |
src/web/pages/runs.ts +13
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | } from "../../actions/runs.ts"; |
| 20 | 20 | import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 21 | 21 | import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; |
| 22 | import { listHeldSecrets } from "../../core/run_secret.ts"; | |
| 22 | 23 | import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts"; |
| 23 | 24 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; |
| 24 | 25 | import type { Ctx } from "../../http/context.ts"; |
| @@ -366,6 +367,7 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 366 | 367 | const duration = durationText(run); |
| 367 | 368 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; |
| 368 | 369 | const who = actor?.username ?? "agent"; |
| 370 | const held = listHeldSecrets(ctx.app.db, run.id); | |
| 369 | 371 | |
| 370 | 372 | const body = html`${repoHeader(ctx, repo, "app", { |
| 371 | 373 | compact: true, |
| @@ -423,6 +425,17 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 423 | 425 | </div> |
| 424 | 426 | </div> |
| 425 | 427 | <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 | )} | |
| 426 | 439 | ${when( |
| 427 | 440 | writer && unfinished && run.kind === "agent" && issue === undefined, |
| 428 | 441 | () => |
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 | }); |
new above
offline