import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from "node:crypto"; import { appendFile, chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { PrivacyClass } from "../types.js"; const SECRET_RE = new RegExp([ // Vendor keys keep separators inside the body: `sk_live_51H8x...` broke a pattern that // stopped at the second underscore, which is the shape Stripe and most others actually use. String.raw`\b(?:sk|pk|api)[_-][A-Za-z0-9_-]{16,}`, String.raw`\b(?:sk|pk|api)[A-Za-z0-9]{16,}\b`, String.raw`\bAKIA[0-9A-Z]{16}\b`, // A bearer token need not be a JWT; the opaque ones carry no structure to recognise, // so the `Bearer` prefix is the only signal available. String.raw`\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`, String.raw`\beyJ[A-Za-z0-9._-]{20,}\b`, // Any assignment whose *name ends* in a secret word. Requiring the keyword to sit // immediately before the `=` missed every real-world `STRIPE_SECRET_KEY=` form. String.raw`\b[A-Za-z0-9_.-]*(?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|private[_-]?key)\s*[:=]\s*[^\s,;]+`, ].join("|"), "gi"); const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; // `BLOCK` suffix covers PGP; the `|$` alternative catches a key pasted without its END // line, which is what a truncated log excerpt of `id_rsa` looks like. const PRIVATE_KEY_RE = /-----BEGIN[A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END[A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g; // Credentials in a URI matter whatever the scheme is, so this matches on the `user:pass@` // shape rather than on an enumeration nobody can keep complete. const CREDENTIAL_URI_RE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@`'"\])}]*:[^\s@`'"\])}]*@[^\s`'"\])}]+/gi; const CONNECTION_RE = /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|mssql|sqlserver|amqps?|clickhouse|ldaps?|cassandra|kafka):\/\/[^\s`'"\])}]+/gi; const IP_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; const PHONE_RE = /\+?\d[\d(). -]{7,}\d/g; // A path is no less sensitive for sitting inside quotes, parentheses or after an `=`. // Anchoring on whitespace meant `'/Users/alice/app.ts'` was published verbatim. export const PATH_RE = /(?`; } interface EncryptedRecord { iv: string; tag: string; ciphertext: string } function encryptRecord(key: Buffer, text: string): EncryptedRecord { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const ciphertext = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); return { iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), ciphertext: ciphertext.toString("base64") }; } function decryptRecord(key: Buffer, encrypted: EncryptedRecord): string { const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(encrypted.iv, "base64")); decipher.setAuthTag(Buffer.from(encrypted.tag, "base64")); return Buffer.concat([decipher.update(Buffer.from(encrypted.ciphertext, "base64")), decipher.final()]).toString("utf8"); } export function sanitizeText(raw: string, privacyClass: PrivacyClass, secret: Buffer | string): string { if (privacyClass === "secret") return ""; if (privacyClass === "restricted") return `restricted task ${hmacId(secret, raw, "TASK")}`; let text = raw.replace(CODE_RE, (_all, lang: string, code: string) => `\n`); // Order is load-bearing. PHONE_RE is the greediest pattern here -- it swallowed // `192.168.1.44` as a phone number and bit the tail off every UUID, so neither IP_RE nor // UUID_RE ever fired. It now runs last, after the specific shapes have claimed their text. text = text.replace(PRIVATE_KEY_RE, "").replace(CREDENTIAL_URI_RE, "").replace(CONNECTION_RE, "").replace(SECRET_RE, "").replace(EMAIL_RE, "").replace(IP_RE, "").replace(UUID_RE, (value) => hmacId(secret, value, "ID")).replace(PATH_RE, (value) => hmacId(secret, value.trim(), "PATH")).replace(URL_RE, (value) => { try { return hmacId(secret, new URL(value).host, "URL_HOST"); } catch { return ""; } }).replace(PHONE_RE, ""); return privacyClass === "internal" ? text.replace(/\b[A-Z][\w-]{2,}\/[\w.-]+\b/g, "") : text; } /** * Read-then-create is a race, and this one loses data permanently. Concurrent callers -- the * `Promise.all` over shards in the controller reaches this on a first run -- all miss, all * generate a key, and the last write wins. Every other caller then holds a key that never * reached disk: what it encrypted cannot be decrypted again by anyone, and what it HMAC'd * joins against nothing. Silent, and unrecoverable once it has happened. * * The in-process cache collapses the concurrent case to a single generation, and the * exclusive create handles the cross-process case by reading whichever writer won rather * than overwriting a key that existing records may already depend on. */ const keyCache = new Map>(); export function loadOrCreateKey(file: string, bytes = 32): Promise { const cached = keyCache.get(file); if (cached) return cached; const pending = readOrCreateKey(file, bytes).catch((error: unknown) => { keyCache.delete(file); throw error; }); keyCache.set(file, pending); return pending; } async function readKey(file: string, bytes: number): Promise { const key = Buffer.from((await readFile(file, "utf8")).trim(), "base64"); if (key.length !== bytes) throw new Error(`Invalid key length for ${file}`); await chmod(file, 0o600); return key; } async function readOrCreateKey(file: string, bytes: number): Promise { try { return await readKey(file, bytes); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const key = randomBytes(bytes); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); try { // `wx` fails rather than truncating: a key that already exists may already have // records depending on it, and overwriting it destroys them. Note the mode argument // only applies when the file is created, which is exactly the case `wx` guarantees. await writeFile(file, `${key.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); await chmod(file, 0o600); return key; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; return await readKey(file, bytes); } } export async function pruneRawVault(vaultDir: string, retentionDays: number, now = Date.now()): Promise { const cutoff = now - retentionDays * 86_400_000; let deleted = 0; for (const [directory, suffix] of [[vaultDir, ".json"], [join(vaultDir, "traces"), ".jsonl"]] as const) { try { for (const entry of await readdir(directory)) { if (!entry.endsWith(suffix)) continue; const file = join(directory, entry); if ((await stat(file)).mtimeMs < cutoff) { await rm(file); deleted += 1; } } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } return deleted; } export async function writeRawVault(vaultDir: string, keyFile: string, text: string): Promise { const key = await loadOrCreateKey(keyFile); const id = randomUUID(); await mkdir(vaultDir, { recursive: true, mode: 0o700 }); await writeFile(join(vaultDir, `${id}.json`), JSON.stringify(encryptRecord(key, text)), { encoding: "utf8", mode: 0o600 }); return id; } export async function readRawVault(vaultDir: string, keyFile: string, id: string): Promise { const key = await loadOrCreateKey(keyFile); return decryptRecord(key, JSON.parse(await readFile(join(vaultDir, `${id}.json`), "utf8")) as EncryptedRecord); } export interface RawTraceRecord { timestamp: string; kind: "task" | "result" | "diagnostic" | "verification" | "workspace" | "feedback"; runId: string; fromNodeId: string; toNodeId: string; content: string; } export async function appendRawTrace(vaultDir: string, keyFile: string, runId: string, record: RawTraceRecord): Promise { const key = await loadOrCreateKey(keyFile); const directory = join(vaultDir, "traces"); await mkdir(directory, { recursive: true, mode: 0o700 }); await appendFile(join(directory, `${runId}.jsonl`), `${JSON.stringify(encryptRecord(key, JSON.stringify(record)))}\n`, { encoding: "utf8", mode: 0o600 }); } export async function readRawTrace(vaultDir: string, keyFile: string, runId: string): Promise { let contents: string; try { contents = await readFile(join(vaultDir, "traces", `${runId}.jsonl`), "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } const key = await loadOrCreateKey(keyFile); return contents.split("\n").filter(Boolean).map((line) => JSON.parse(decryptRecord(key, JSON.parse(line) as EncryptedRecord)) as RawTraceRecord); }