/** * Client-side secrets defence: every string is redacted BEFORE it enters the * tale, so nothing sensitive ever leaves the machine. */ interface Rule { kind: string; pattern: RegExp; /** replacement; defaults to the bare `[REDACTED:]` marker */ replace?: string; } const RULES: readonly Rule[] = [ // PEM first: multi-line blocks would otherwise be shredded by the line-level rules. { kind: "private-key", pattern: /-{5}BEGIN [A-Z0-9 ]*PRIVATE KEY-{5}[\s\S]*?-{5}END [A-Z0-9 ]*PRIVATE KEY-{5}/g, }, { kind: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g }, { kind: "github-token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g }, { kind: "github-token", pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g }, { kind: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g }, { kind: "api-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g }, // our own publish keys — a bare tk_… in a pasted command has no assignment to match on { kind: "taleseal-key", pattern: /\btk_[A-Za-z0-9_-]{20,}\b/g }, // the claim secret of an anonymous tale — the OWN capability for a live page; an agent // quoting its own claim link into a tale would hand ownership to every reader { kind: "taleseal-claim", pattern: /\btc_[A-Za-z0-9_-]{20,}\b/g }, { kind: "jwt", pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g }, { kind: "bearer-token", pattern: /\bBearer\s+(?!\[REDACTED)[\w.~+/=-]{8,}/g, replace: "Bearer [REDACTED:bearer-token]", }, // Connection strings carry their password inline: scheme://user:PASSWORD@host. { kind: "connection-password", pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):(?!\[REDACTED)[^\s@/]+@/gi, replace: "$1:[REDACTED:connection-password]@", }, // Generic assignments last: earlier rules have already replaced recognisable values, // so the lookahead stops this one re-wrapping their markers. The name may be prefixed // (TALESEAL_API_KEY, MY_DB_PASSWORD) — an underscore is a word char, so a bare \b before // the keyword never fires on those. { kind: "credential", pattern: /\b([A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|credential))(\s*[:=]\s*)(?!\[REDACTED)\S+/gi, replace: "$1$2[REDACTED:credential]", }, // Home directories last (values above are already markers by now): the head of a home // path names the machine's owner, not the file — `~` keeps the path meaningful. The // lookbehind skips URL path segments (…example.com/Users/123) and deeper /foo/Users/… // segments, which are someone else's namespace, not this machine's home. { kind: "home-path", pattern: /(?]` marker. */ export function redact(text: string): string { let result = text; for (const rule of RULES) { result = result.replace(rule.pattern, rule.replace ?? `[REDACTED:${rule.kind}]`); } return result; } /** * A deep copy of any JSON-shaped value with every string redacted. The draft path uses * this on agent-authored content: prose written by the composing agent can quote a * secret from its own context — defence in depth means the scrubber sees that too. */ export function redactDeep(value: T): T { if (typeof value === "string") return redact(value) as T; if (Array.isArray(value)) return value.map((item) => redactDeep(item)) as T; if (typeof value === "object" && value !== null) { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactDeep(item)])) as T; } return value; }