diff --git a/server/delegations.test.ts b/server/delegations.test.ts index a1eee2577..7996ccfc6 100644 --- a/server/delegations.test.ts +++ b/server/delegations.test.ts @@ -242,7 +242,7 @@ describe("drainDelegations", () => { }, ); - await waitFor(() => runTargetCalls.length === 1); + await waitFor(() => runTargetCalls.length === 1 && _pendingCount(routineTask.threadId) === 0); expect(_pendingCount(routineTask.threadId)).toBe(0); expect(runTargetCalls[0]?.sourceThreadId).toBe(routineTask.threadId); expect( @@ -401,3 +401,116 @@ describe("drainDelegations", () => { expect(runTargetCalls).toEqual([]); }); }); + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { _loadPending, _resetPending, discardDelegations, pendingThreads } from "./delegations.ts"; + +describe("delegations survive a restart", () => { + let store: Store; + let from: BotRecord; + let target: BotRecord; + let buses: BusPair; + const file = () => join(DATA_DIR, "delegations.json"); + + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + _resetPending(); + store = new Store(selection); + from = store.createBot(); + target = store.createBot(); + store.patchBot(target.id, { name: "Helper" }); + buses = setupBuses(store); + }); + afterEach(() => _resetPending()); + + it("writes the queue to disk on queue, and clears it on drain and discard", async () => { + expect(queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "do this", depth: 0 }, 1)).toBe("ok"); + expect(existsSync(file())).toBe(true); + const onDisk = JSON.parse(readFileSync(file(), "utf8")) as Record; + expect(onDisk[from.threadId]).toHaveLength(1); + expect(onDisk[from.threadId][0]).toMatchObject({ toBotId: target.id, message: "do this" }); + + discardDelegations(buses.commsBus, from.threadId); + expect(JSON.parse(readFileSync(file(), "utf8"))[from.threadId]).toBeUndefined(); + + queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "again", depth: 0 }, 1); + const ran: string[] = []; + drainDelegations(buses.commsBus, buses.approvalBus, from.threadId, async (_to, message) => { + ran.push(message); + }); + await waitFor(() => ran.length === 1 && pendingThreads().length === 0); + expect(JSON.parse(readFileSync(file(), "utf8"))[from.threadId]).toBeUndefined(); + }); + + it("keeps a handoff durable until its approval and dispatch path settles", async () => { + queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "wait for dispatch", depth: 0 }, 1); + let release!: () => void; + const dispatchSettled = new Promise((resolve) => { + release = resolve; + }); + let started = false; + drainDelegations(buses.commsBus, buses.approvalBus, from.threadId, async () => { + started = true; + await dispatchSettled; + }); + + await waitFor(() => started); + expect(pendingThreads()).toEqual([from.threadId]); + expect(JSON.parse(readFileSync(file(), "utf8"))[from.threadId]).toHaveLength(1); + + release(); + await waitFor(() => pendingThreads().length === 0); + expect(JSON.parse(readFileSync(file(), "utf8"))[from.threadId]).toBeUndefined(); + }); + + it("drains work queued by a later settled turn while an earlier handoff is waiting", async () => { + queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "first", depth: 0 }, 1); + let release!: () => void; + const firstSettled = new Promise((resolve) => { + release = resolve; + }); + const ran: string[] = []; + const runTarget = async (_to: string, message: string) => { + ran.push(message); + if (message.includes("first")) await firstSettled; + }; + drainDelegations(buses.commsBus, buses.approvalBus, from.threadId, runTarget); + await waitFor(() => ran.length === 1); + + queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "second", depth: 0 }, 1); + drainDelegations(buses.commsBus, buses.approvalBus, from.threadId, runTarget); + expect(ran).toHaveLength(1); + + release(); + await waitFor(() => ran.length === 2 && pendingThreads().length === 0); + expect(ran[1]).toContain("second"); + }); + + it("a fresh process loads what the last one queued, and can drain it", async () => { + queueDelegation(buses.commsBus, from, { toBotId: target.id, message: "left over", depth: 0 }, 1); + // "restart": forget memory, reload from disk + _resetPending(); + expect(pendingThreads()).toEqual([]); + _loadPending(); + expect(pendingThreads()).toEqual([from.threadId]); + const ran: string[] = []; + drainDelegations(buses.commsBus, buses.approvalBus, from.threadId, async (_to, message) => { + ran.push(message); + }); + await waitFor(() => ran.length === 1 && pendingThreads().length === 0); + expect(ran[0]).toContain("left over"); + expect(pendingThreads()).toEqual([]); + }); + + it("tolerates a missing or corrupt file", () => { + _resetPending(); + _loadPending(); // no file + expect(pendingThreads()).toEqual([]); + const { mkdirSync, writeFileSync } = require("node:fs") as typeof import("node:fs"); + mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(file(), "{not json"); + _loadPending(); + expect(pendingThreads()).toEqual([]); + }); +}); diff --git a/server/delegations.ts b/server/delegations.ts index 642fb3f06..2d6b717f8 100644 --- a/server/delegations.ts +++ b/server/delegations.ts @@ -11,7 +11,13 @@ // time, never at queue time, because the user might have just turned // approvePeerComms on between queueing and draining. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { writeFileAtomic } from "./atomic.ts"; import { getOrCreateChannel, mirrorExchange, type CommsBus } from "./comms-visibility.ts"; +import { DATA_DIR } from "./config.ts"; +import { newId } from "./contracts.ts"; import { requestPeerApproval, type ApprovalBus } from "./peer-approval.ts"; import type { BotRecord, GroupRecord } from "./store.ts"; @@ -26,12 +32,64 @@ export interface DelegationItem { depth: number; } +interface PendingDelegationItem extends DelegationItem { + /** Stable acknowledgement key for crash-safe removal from the queue. */ + id: string; +} + export type QueueResult = "ok" | "no_target" | "self" | "too_deep" | "too_many"; -/** Per source-thread queue. Persisted nowhere — a server restart drops - * delegations the same way provider permissions drop, which is honest: - * nobody can answer for an unattended bot. */ -const pendingDelegations = new Map(); +/** Per source-thread queue. Persisted to delegations.json on every change + * and reloaded at boot: a handoff queued right before a restart runs after + * it. (Provider PERMISSIONS still die with the process — nobody can answer + * for an unattended bot — but queued work is not a permission; the target + * and approvePeerComms are re-checked at drain time as always.) */ +const pendingDelegations = new Map(); +const drainingThreads = new Set(); +const DELEGATIONS_FILE = join(DATA_DIR, "delegations.json"); + +function savePending(): void { + try { + writeFileAtomic(DELEGATIONS_FILE, JSON.stringify(Object.fromEntries(pendingDelegations), null, 2), { mode: 0o600 }); + } catch (error) { + console.error("delegations: could not persist queue", error); + } +} + +/** Load what a previous process left queued. Missing or corrupt → empty. */ +export function _loadPending(): void { + pendingDelegations.clear(); + try { + const raw = JSON.parse(readFileSync(DELEGATIONS_FILE, "utf8")) as Record; + for (const [threadId, list] of Object.entries(raw)) { + if (!Array.isArray(list)) continue; + const items = list.flatMap((value): PendingDelegationItem[] => { + if (!value || typeof value !== "object") return []; + const item = value as Partial; + if ( + typeof item.toBotId !== "string" || + typeof item.message !== "string" || + !Number.isFinite(item.depth) + ) return []; + return [{ + id: typeof item.id === "string" && item.id ? item.id : newId(), + toBotId: item.toBotId, + message: item.message, + ...(typeof item.reason === "string" ? { reason: item.reason } : {}), + depth: Math.max(0, Math.trunc(item.depth!)), + }]; + }); + if (items.length) pendingDelegations.set(threadId, items); + } + } catch { + /* fresh install, or unreadable — start empty */ + } +} + +/** Source threads with something queued — what a boot drain iterates. */ +export function pendingThreads(): string[] { + return [...pendingDelegations.keys()]; +} /** How many handoffs one turn may queue. Small on purpose: this is the only * thing standing between a confused bot and a fan-out of real turns. */ @@ -55,8 +113,9 @@ export function queueDelegation( // making the caller wait. Without a cap, one turn can queue unboundedly // and fan out into as many real turns on the next settle. if (list.length >= MAX_QUEUED_PER_THREAD) return "too_many"; - list.push(item); + list.push({ ...item, id: newId() }); pendingDelegations.set(sourceThreadId, list); + savePending(); const label = `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`; bus.store.appendMessage(sourceThreadId, { role: "bot", @@ -84,25 +143,55 @@ export function drainDelegations( channel?: GroupRecord, ) => void | Promise, ): void { + if (drainingThreads.has(threadId)) return; const list = pendingDelegations.get(threadId); if (!list?.length) return; - pendingDelegations.delete(threadId); const from = bus.store.botByThread(threadId); - if (!from) return; - for (const item of list) { - void processOne(bus, approvalBus, from, threadId, item, runTarget).catch((error) => { - const why = error instanceof Error ? error.message : String(error); + if (!from) { + pendingDelegations.delete(threadId); + savePending(); + return; + } + const snapshot = [...list]; + drainingThreads.add(threadId); + void (async () => { + for (const item of snapshot) { try { - bus.store.appendMessage(threadId, { - role: "bot", - kind: "activity", - tool: { name: `error: delegation failed — ${why.slice(0, 120)}`, ok: false }, - }); - } catch (reportError) { - console.error("delegation failed and could not be reported", reportError); + await processOne(bus, approvalBus, from, threadId, item, runTarget); + } catch (error) { + const why = error instanceof Error ? error.message : String(error); + try { + bus.store.appendMessage(threadId, { + role: "bot", + kind: "activity", + tool: { name: `error: delegation failed — ${why.slice(0, 120)}`, ok: false }, + }); + } catch (reportError) { + console.error("delegation failed and could not be reported", reportError); + } + } finally { + acknowledgeDelegation(threadId, item.id); } - }); - } + } + })().finally(() => { + drainingThreads.delete(threadId); + // A later turn may have queued and settled while this thread was + // waiting for approval. Its items were not in our snapshot, so start a + // fresh drain instead of leaving them parked until another restart. + if (pendingDelegations.get(threadId)?.length) { + drainDelegations(bus, approvalBus, threadId, runTarget); + } + }); +} + +/** Remove one terminal handoff only after approval/dispatch has settled. */ +function acknowledgeDelegation(threadId: string, itemId: string): void { + const current = pendingDelegations.get(threadId); + if (!current) return; + const remaining = current.filter((item) => item.id !== itemId); + if (remaining.length) pendingDelegations.set(threadId, remaining); + else pendingDelegations.delete(threadId); + savePending(); } /** Drop a thread's queued handoffs without running them, telling the user @@ -111,6 +200,7 @@ export function discardDelegations(bus: CommsBus, threadId: string): void { const list = pendingDelegations.get(threadId); if (!list?.length) return; pendingDelegations.delete(threadId); + savePending(); const from = bus.store.botByThread(threadId); if (!from) return; bus.store.appendMessage(threadId, { @@ -198,3 +288,9 @@ async function processOne( export function _pendingCount(threadId: string): number { return pendingDelegations.get(threadId)?.length ?? 0; } + +/** Test helper: forget the in-memory queue (a simulated restart). */ +export function _resetPending(): void { + pendingDelegations.clear(); + drainingThreads.clear(); +} diff --git a/server/harness/bus.test.ts b/server/harness/bus.test.ts index b2b138f8b..f3fdb010c 100644 --- a/server/harness/bus.test.ts +++ b/server/harness/bus.test.ts @@ -73,6 +73,20 @@ describe("EventBus", () => { expect(logged[0].type).toBe("turn.started"); }); + it("redacts credential-shaped content before writing the NDJSON log", () => { + const key = `sk-ant-api03-${"abcdefghijklmnopqrstuvwxyz0123456789"}`; + const bus = new EventBus(); + bus.publish(testEvent({ + threadId: "redacted-log", + type: "runtime.error", + message: `provider returned ${key}`, + })); + + const logged = readFileSync(join(EVENTS_DIR, "redacted-log.ndjson"), "utf8"); + expect(logged).not.toContain(key); + expect(logged).toContain("«redacted"); + }); + it("still delivers when the NDJSON log cannot be written", () => { rmSync(EVENTS_DIR, { recursive: true, force: true }); const bus = new EventBus(); diff --git a/server/harness/bus.ts b/server/harness/bus.ts index 70bc8dfd5..1e3809c46 100644 --- a/server/harness/bus.ts +++ b/server/harness/bus.ts @@ -8,6 +8,7 @@ import { appendFileSync } from "node:fs"; import { join } from "node:path"; import { EVENTS_DIR } from "../config.ts"; +import { redactSecrets } from "../redact.ts"; import type { ProviderInstance, RuntimeEvent, RuntimeEventListener } from "../contracts.ts"; export class EventBus { @@ -31,7 +32,14 @@ export class EventBus { publish(event: RuntimeEvent) { try { - appendFileSync(join(EVENTS_DIR, `${event.threadId}.ndjson`), JSON.stringify(event) + "\n"); + // the canonical log is a file people paste into bug reports; scrub + // credential-shaped content (tool titles, request summaries, reply + // text) the same way the native tee does + appendFileSync( + join(EVENTS_DIR, `${event.threadId}.ndjson`), + JSON.stringify(redactSecrets(event)) + "\n", + { mode: 0o600 }, + ); } catch { /* logging must never take down the stream */ } diff --git a/server/index.ts b/server/index.ts index c61f6bdb5..cd3617d68 100644 --- a/server/index.ts +++ b/server/index.ts @@ -39,7 +39,7 @@ import { isEffortLevel, type RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply, type CommsBus } from "./comms-visibility.ts"; import { searchMessages } from "./message-db.ts"; -import { discardDelegations, drainDelegations, queueDelegation, type QueueResult } from "./delegations.ts"; +import { _loadPending, discardDelegations, drainDelegations, pendingThreads, queueDelegation, type QueueResult } from "./delegations.ts"; import { EventBus } from "./harness/bus.ts"; import { ProviderRegistry } from "./harness/registry.ts"; import { cancelPeerApprovalsFor, dismissStalePeerCards, requestPeerApproval, resolvePeerComms, type ApprovalBus } from "./peer-approval.ts"; @@ -749,13 +749,10 @@ function finalizeDelegationWatch( // Run as a separate subscriber so the drain logic stays out of the main // fold (which has its own switch/case noise) and its approval + startTurn // calls never have to share locals with the fold's state machine. -bus.subscribe((event: RuntimeEvent) => { - if (event.type !== "turn.completed") return; - // A turn that failed or was interrupted drops its queue rather than - // firing it later: the user who hit Stop does not expect the delegations - // that turn queued to run anyway, minutes later, on an unrelated turn. - if (!event.ok) return void discardDelegations(commsBus, event.threadId); - drainDelegations(commsBus, approvalBus, event.threadId, (toBotId, text, commsDepth, sourceThreadId, channel) => { +/** How a drained delegation becomes a real turn on the target. Shared by + * the settle-time drain and the boot-time drain of what a previous process + * left queued. */ +const runDelegatedTurn: Parameters[3] = (toBotId, text, commsDepth, sourceThreadId, channel) => { // startTurn REJECTS on an ordinary condition — busy target, deleted bot, // unavailable provider. Unhandled, that rejection is fatal to the // harness (Node's default), which in the packaged app kills the server @@ -794,9 +791,18 @@ bus.subscribe((event: RuntimeEvent) => { }).catch((err) => { reportStartFailure(err); }); - }); +}; + +bus.subscribe((event: RuntimeEvent) => { + if (event.type !== "turn.completed") return; + // A turn that failed or was interrupted drops its queue rather than + // firing it later: the user who hit Stop does not expect the delegations + // that turn queued to run anyway, minutes later, on an unrelated turn. + if (!event.ok) return void discardDelegations(commsBus, event.threadId); + drainDelegations(commsBus, approvalBus, event.threadId, runDelegatedTurn); }); + // ── live screen: poll the bot's box while it works ──────────────────── // Frames stream to clients as SSE {kind:'screen'} (the "Bot's screen" // panel); the final frame is folded into the transcript on turn end. @@ -1323,6 +1329,17 @@ const approvalBus: ApprovalBus = { store, broadcast }; if (stale) console.log(`peer approvals: dismissed ${stale} card(s) left by a previous run`); } +// Handoffs a previous process queued but never ran: the source turn is +// dead (no turn survives a restart) so they would otherwise wait forever. +// Run them now, through the same drain — target and approvePeerComms are +// re-checked there as always; a source bot that no longer exists is skipped. +_loadPending(); +{ + const leftover = pendingThreads(); + if (leftover.length) console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`); + for (const threadId of leftover) drainDelegations(commsBus, approvalBus, threadId, runDelegatedTurn); +} + async function runGroupMemberTurn( groupId: string, botId: string, diff --git a/server/redact.test.ts b/server/redact.test.ts index de89980e2..7146aca9c 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -101,3 +101,68 @@ describe("redactSecrets", () => { expect(() => redactSecrets(deep)).not.toThrow(); }); }); + +import { redactSecretsInText } from "./redact.ts"; + +// Content-shaped secrets: what a bot's own reply, a tool title, or a +// permission card can carry. High precision on purpose — a false positive +// here rewrites real code in the transcript. +describe("redactSecretsInText", () => { + it("masks known key prefixes wherever they appear", () => { + // fixtures are assembled at runtime so no token-shaped literal sits in + // the source — GitHub's push protection (rightly) flags those + const alpha = "abcdefghijklmnopqrstuvwxyz0123456789"; + const cases: Array<[string, RegExp]> = [ + [`set ANTHROPIC_API_KEY=sk-ant-api03-${alpha}`, /sk-ant/], + [`OpenAI: sk-proj-${alpha}ABCD`, /sk-proj/], + [`gh token ${"gh" + "p_"}${alpha}`, /ghp_/], + [`fine-grained ${"github_" + "pat_"}11ABCDEFG0${alpha}`, /github_pat_/], + [`slack ${"xox" + "b-"}${"123456789012"}-${"1234567890123"}-${alpha.slice(0, 24)}`, /xoxb-/], + [`aws ${"AKIA" + "IOSFODNN7EXAMPLE"} and more`, /IOSFODNN7EXAMPLE/], + [`google ${"AIza" + "SyA-"}${alpha.slice(0, 32)}`, /AIza/], + [`npm ${"npm" + "_"}${alpha}`, /npm_[a-z]/], + ]; + for (const [input, leak] of cases) { + const out = redactSecretsInText(input); + expect(out, input).not.toMatch(leak); + expect(out).toMatch(/«redacted \d+ chars»/); + } + }); + + it("masks JWTs, PEM private key blocks, and bearer tokens", () => { + const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + expect(redactSecretsInText(`token ${jwt} ok`)).toBe(`token «redacted ${jwt.length} chars» ok`); + const pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n-----END OPENSSH PRIVATE KEY-----"; + const out = redactSecretsInText(`here:\n${pem}\ndone`); + expect(out).not.toContain("b3BlbnNzaC1r"); + expect(out).toMatch(/BEGIN OPENSSH PRIVATE KEY[\s\S]*«redacted \d+ chars»[\s\S]*END OPENSSH PRIVATE KEY/); + expect(redactSecretsInText('curl -H "Authorization: Bearer abc.def-ghi_jkl123456789"')).toBe('curl -H "Authorization: Bearer «redacted 24 chars»"'); + }); + + it("masks the value of a secret-shaped key=value or key: value, keeping the key", () => { + expect(redactSecretsInText("export DATABASE_PASSWORD=hunter2hunter2")).toBe("export DATABASE_PASSWORD=«redacted 14 chars»"); + expect(redactSecretsInText('{"api_key": "abcd1234efgh5678"}')).toBe('{"api_key": "«redacted 16 chars»"}'); + expect(redactSecretsInText("client_secret: 'zzzz-yyyy-xxxx-1'")).toBe("client_secret: '«redacted 16 chars»'"); + expect(redactSecretsInText("--token=abc123def456")).toBe("--token=«redacted 12 chars»"); + }); + + it("leaves ordinary text, code, hashes and URLs alone", () => { + for (const s of [ + "the keyboard shortcut is cmd-k", + "git commit 3f2a9c1e7b4d5a6f8e9c0b1a2d3e4f5a6b7c8d9e", + "https://example.com/path?page=2&sort=asc", + "const token = await getToken(); // fetches later", + "password: (leave blank to keep the current one)", + "Bearer tokens are sent in the Authorization header", + "sk-8", // too short to be a key + ]) { + expect(redactSecretsInText(s), s).toBe(s); + } + }); + + it("is applied to string values inside redactSecrets too", () => { + const out = redactSecrets({ command: "curl -H 'Authorization: Bearer abcdefghijklmnop'", note: "fine" }) as Record; + expect(out.command).toContain("«redacted"); + expect(out.note).toBe("fine"); + }); +}); diff --git a/server/redact.ts b/server/redact.ts index 54a47a5e9..dbfebadfc 100644 --- a/server/redact.ts +++ b/server/redact.ts @@ -26,10 +26,46 @@ function isSecretName(name: string): boolean { const mask = (value: string) => `«redacted ${value.length} chars»`; +// ── content-shaped secrets ──────────────────────────────────────────── +// What a bot's own reply, a tool title, or a permission card can carry — +// and, since the rebuild replays activity into every handed-over context, +// what would otherwise become permanent. High precision on purpose: a +// generic "long hex/base64" heuristic would rewrite real code in the +// transcript, so only shapes that are unmistakably credentials match. + +const KEY_PREFIXES: RegExp[] = [ + /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, // anthropic / openai / stripe + /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, // github classic + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // github fine-grained + /\bxox[abposr]-[A-Za-z0-9-]{20,}/g, // slack + /\bAKIA[0-9A-Z]{16}\b/g, // aws access key id + /\bAIza[0-9A-Za-z_-]{30,}/g, // google api key + /\bnpm_[A-Za-z0-9]{20,}/g, // npm + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // jwt +]; +const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{12,})/g; +const PEM_BLOCK = /(-----BEGIN [A-Z ]*PRIVATE KEY-----)([\s\S]*?)(-----END [A-Z ]*PRIVATE KEY-----)/g; +/** key=value / key: value / key="value" where the key is secret-shaped. + * The value must be a single token of some length; prose after a colon + * ("password: leave blank…") has spaces and does not match. */ +const KEY_VALUE = + /\b((?:[A-Za-z0-9_-]*_)?(?:api[_-]?key|apikey|secret|token|password|passwd|authorization|auth[_-]?token|access[_-]?key|private[_-]?key)s?)(["']?\s*[=:]\s*)(["']?)([A-Za-z0-9._~+/=-]{8,})\3/gi; + +export function redactSecretsInText(text: string): string { + if (!text || text.length < 8) return text; + let out = text; + out = out.replace(PEM_BLOCK, (_m, open: string, body: string, close: string) => `${open}\n${mask(body.trim())}\n${close}`); + for (const re of KEY_PREFIXES) out = out.replace(re, (m) => mask(m)); + out = out.replace(BEARER, (_m, lead: string, tok: string) => `${lead}${mask(tok)}`); + out = out.replace(KEY_VALUE, (_m, key: string, sep: string, quote: string, value: string) => `${key}${sep}${quote}${mask(value)}${quote}`); + return out; +} + /** Deep copy with credential VALUES replaced. Handles the two shapes that * actually carry them: a plain object of env vars ({KEY: "v"}) and the ACP * wire shape (env: [{name, value}]). Anything unrecognised is copied as-is. */ export function redactSecrets(input: unknown, depth = 0): unknown { + if (typeof input === "string") return redactSecretsInText(input); if (depth > 12 || input === null || typeof input !== "object") return input; if (Array.isArray(input)) { @@ -55,6 +91,8 @@ export function redactSecrets(input: unknown, depth = 0): unknown { out[key] = mask(value); continue; } + // any other string may still CONTAIN a credential (a command line, a + // header value, a bot's reply) — the content pass catches those out[key] = redactSecrets(value, depth + 1); } return out; diff --git a/server/store.test.ts b/server/store.test.ts index 443f3dfd6..1dffa231a 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -451,6 +451,35 @@ describe("Store bot activity state", () => { }); }); +describe("Store redacts bot-authored secrets on write", () => { + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("masks a key in a bot reply, a tool title and a card summary — but never in what the user typed", () => { + const store = new Store(selection); + const bot = store.createBot(); + const key = `sk-ant-api03-${"abcdefghijklmnopqrstuvwxyz0123456789"}`; + const reply = store.appendMessage(bot.threadId, { role: "bot", kind: "text", text: `Your key is ${key}` }); + expect(reply.text).not.toContain(key); + expect(reply.text).toContain("«redacted"); + const chip = store.appendMessage(bot.threadId, { role: "bot", kind: "activity", tool: { name: `Bash: export TOKEN=${key}`, ok: true } }); + expect(chip.tool?.name).not.toContain(key); + const card = store.appendMessage(bot.threadId, { + role: "bot", + kind: "options", + card: { title: "Run this?", summary: `curl -H "Authorization: Bearer ${key}"`, options: [], requestId: "r1", tool: "Bash" } as never, + }); + expect((card.card as { summary?: string }).summary).not.toContain(key); + // the user's own words are theirs + const mine = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: `use ${key} for the api` }); + expect(mine.text).toContain(key); + // and the stored copy is what was masked, not just the returned one + const again = new Store(selection); + expect(again.messagesFor(bot.threadId).find((m) => m.id === reply.id)?.text).not.toContain(key); + }); +}); + describe("Store task working folder", () => { beforeEach(() => { rmSync(DATA_DIR, { recursive: true, force: true }); diff --git a/server/store.ts b/server/store.ts index 54a1be2eb..dbfd8e09d 100644 --- a/server/store.ts +++ b/server/store.ts @@ -12,6 +12,7 @@ import * as mdb from "./message-db.ts"; import { workspaceDir } from "./workspace.ts"; import { newId, type ModelSelection, type ThreadId } from "./contracts.ts"; import { pickBotName } from "./names.ts"; +import { redactSecretsInText } from "./redact.ts"; export type MausColor = | "green" @@ -129,6 +130,27 @@ export interface TaskRecord { cwd?: string | null; } +/** Everything the BOT authored is scrubbed of content-shaped secrets before + * it is stored: its reply text, a tool title (an ACP engine's title can be + * the whole command line), a permission card's summary. What the user typed + * is theirs and stays as typed. Stored, not just displayed: the transcript + * is replayed into every rebuild, and a leaked key would otherwise be + * permanent. */ +function redactBotAuthored & { at?: number }>(message: T): T { + if (message.role !== "bot") return message; + const out = { ...message }; + if (typeof out.text === "string") out.text = redactSecretsInText(out.text); + if (out.tool?.name) out.tool = { ...out.tool, name: redactSecretsInText(out.tool.name) }; + if (out.card) { + const card = { ...out.card } as OptionCardData & { summary?: string }; + card.title = redactSecretsInText(card.title); + if (typeof card.subtitle === "string") card.subtitle = redactSecretsInText(card.subtitle); + if (typeof card.summary === "string") card.summary = redactSecretsInText(card.summary); + out.card = card; + } + return out; +} + /** What changed, emitted by the store itself right after each write. The * server maps these onto its SSE frames in ONE place, so no mutation path * can persist without the app hearing about it — the two-write-paths bug @@ -569,7 +591,7 @@ export class Store { appendMessage(threadId: string, message: Omit & { at?: number }): Message { const t = this.thread(threadId); - const full: Message = { id: newId(), at: Date.now(), parentId: t.activeLeafId, ...message }; + const full: Message = { id: newId(), at: Date.now(), parentId: t.activeLeafId, ...redactBotAuthored(message) }; t.messages.push(full); t.activeLeafId = full.id; mdb.appendMessage(threadId, full);