/** * secret-guard — keep the values of this process's secret env vars out of what * the model sees, what the session file stores, and what the palace feeder * stages. * * WHY. Measured 2026-10-12 on emb-7kj4vr4g: a fresh session ran an orientation * command: `env | grep -E '^(PI_|MEMPALACE_REMOTE|…)'`, piped through a sed that * rewrites `=.*TOKEN.*` to `=`. * The sed only matches when TOKEN comes AFTER the `=`, so * MEMPALACE_REMOTE_TOKEN= went through verbatim. The raw value of that * token turned out to be in 12 local session files dating back to 2026-08-16, * and each of those sessions had sent it to its model provider. The palace stayed clean * only because mempalace-toolkit's feeder scrubs transcripts before staging * (bin/mempalace_redact.py). That redactor sits at the palace boundary. Nothing * sat at the boundary that matters first: tool output -> model context. An * agent's own ad-hoc redaction is not a guardrail, as that sed shows. * * WHAT. Tier 1 of mempalace_redact.py, ported exactly. Take every env var whose * NAME says secret (…TOKEN, …SECRET, …API_KEY, …PASSWORD, _PAT, …) and whose * VALUE looks like a credential rather than a path, knob or placeholder. Replace * the exact value with ``, the same placeholder the palace * redactor writes. Matching a known value cannot hit anything that is not that * secret, so it is safe to apply to every tool result; shape and entropy * heuristics are not, and are deliberately left out. * 1. tool_result: content (text parts), structuredContent and details. This is * the main path; it keeps the value out of the model's context, the * session file and so the palace feed, all at once. * 2. context: the same pass over everything sent to the model on each * request. It covers what does not arrive as a tool result: output of a * user `!` command, pasted text, and old sessions that already hold a raw * value. It is request-local, so the session file is not changed. * 3. tool_call: block a write/edit whose NEW text contains the placeholder of * a live secret. That is what an agent writes back after reading a file * the guard redacted, such as a decrypted .env, and it would replace the * real secret with the placeholder. The reason says how to write the real * value with bash instead. * Commands keep working: `$NAME` still expands in bash; only the output that * comes back is rewritten. * * NOT COVERED (measured in the end-to-end test unless said otherwise): * - Secrets that are not in this process's environment (a key file, another * host's decrypted .env) need shape rules: tier 2, which the palace redactor * has. * - Live progress while a tool runs (tool_execution_update: the TUI's * streaming view, `--mode json` stdout). Only the FINAL result is * rewritten, and that is what the model and the session file get. * - The stored entry of a user `!` command keeps the raw output; layer 2 * redacts it only on its way to the model. The palace feeder still scrubs * it before staging. * - The raw output pi keeps in a temp file when it truncates bash output * (details.fullOutputPath). Reading it back through a tool is redacted * again. (Not measured.) * - pi-task children, which run with --no-extensions. The parent's `task` * result is redacted like any tool result. (Not measured.) * * OFF SWITCH. PI_SECRET_GUARD=off makes the extension inert; `/ext` * (ext-toggle) disables it entirely. Neither is needed in normal operation. * * Companion: mempalace-toolkit bin/mempalace_redact.py is the reference for * tier 1. test/secret-guard.test.mjs runs it on the same synthetic env and * fails if the two pick different variables or produce different text. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // ── Tier 1 selection: a port of mempalace_redact.env_secrets() ─────────────── // Keep these four in step with mempalace_redact.py (_SECRET_NAME, // _NAME_NOT_SECRET, _TRIVIAL_VALUES, _NOT_A_SECRET_VALUE). The parity test // checks them against the Python module whenever it is installed. const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|APIKEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL|BEARER|SESSION_?KEY|COOKIE|_PAT|^PAT$)/i; // …unless the name says it holds a location or a knob (SSH_KEY_PATH). const NAME_NOT_SECRET = /(_PATH|_FILE|_DIR|_NAME|_ID|_URL|_URI|_HOST|_PORT|_USER|_ENABLED|_TIMEOUT|_MS|_SECS|_SECONDS|_COUNT|_LIMIT|_MODE)$/i; const TRIVIAL_VALUES = new Set([ "", "0", "1", "true", "false", "none", "null", "nil", "unset", "changeme", "change-me", "xxx", "***", "redacted", "your-token-here", "your_token_here", "placeholder", "example", "dummy", "test", "secret", ]); // A value that is a reference, a path, a placeholder or an earlier redaction. const NOT_A_SECRET_VALUE = new RegExp( "^(?:" + [ "\\$\\{[^}\\s]*\\}?", // ${VAR} ${VAR:-default} "\\$[A-Za-z_][A-Za-z0-9_]*", // $VAR "%[A-Za-z_][A-Za-z0-9_]*%?", // %VAR% "\\{\\{[^}\\s]*\\}?\\}?", // {{ template }} "<[^>]*>", // / "(?:/|~/|\\./|\\.\\./)[^\\s]*", // a path "[A-Za-z]:\\\\", // a windows path "\\*+", "x+", "\\.+", "(?:true|false|none|null|nil|unset|changeme|placeholder|example)", ].join("|") + ")$", "i", ); export type Known = Array<[name: string, value: string]>; /** (name, value) pairs worth redacting, longest value first. */ export function envSecrets(env: Record): Known { const out: Known = []; for (const [name, value] of Object.entries(env)) { if (!value || value.length < 12) continue; if (!SECRET_NAME.test(name) || NAME_NOT_SECRET.test(name)) continue; const v = value.trim(); if (TRIVIAL_VALUES.has(v.toLowerCase())) continue; if (NOT_A_SECRET_VALUE.test(v)) continue; if (value.includes("/") && (value.startsWith("/") || value.startsWith("~"))) continue; if (/\s/.test(value)) continue; // credentials have no whitespace; prose does out.push([name, value]); } // Longest first: a secret that contains another must be replaced whole. out.sort((a, b) => b[1].length - a[1].length); return out; } export const placeholder = (name: string) => ``; export type Hits = Map; function addHits(into: Hits, from: Hits) { for (const [k, n] of from) into.set(k, (into.get(k) ?? 0) + n); } // One tool result can carry the same output in several fields: pi's bash returns // it as content text AND structuredContent.output. Count a name by the field that // holds it most often, not by the sum, so a single echo is reported once. function maxHits(into: Hits, from: Hits) { for (const [k, n] of from) into.set(k, Math.max(into.get(k) ?? 0, n)); } /** Replace every known value. Text with no secret comes back as the same string. */ export function redactText(text: string, known: Known): { text: string; hits: Hits } { const hits: Hits = new Map(); let out = text; for (const [name, value] of known) { if (!out.includes(value)) continue; const parts = out.split(value); // split/join: no `$&` surprises in the replacement hits.set(name, (hits.get(name) ?? 0) + parts.length - 1); out = parts.join(placeholder(name)); } return { text: out, hits }; } /** Redact every string in a JSON-ish value. Returns the SAME reference when nothing matched. */ export function redactDeep(value: T, known: Known): { value: T; hits: Hits } { const hits: Hits = new Map(); const walk = (v: unknown): unknown => { if (typeof v === "string") { const r = redactText(v, known); if (r.hits.size === 0) return v; addHits(hits, r.hits); return r.text; } if (Array.isArray(v)) { let changed = false; const next = v.map((x) => { const y = walk(x); if (y !== x) changed = true; return y; }); return changed ? next : v; } if (v && typeof v === "object") { let changed = false; const next: Record = {}; for (const [k, x] of Object.entries(v)) { const y = walk(x); if (y !== x) changed = true; next[k] = y; } return changed ? next : v; } return v; }; return { value: walk(value) as T, hits }; } // ── Handlers (pure; the default export only wires them to pi) ──────────────── interface ToolResultLike { content: Array<{ type: string; text?: string; [k: string]: unknown }>; structuredContent?: unknown; details?: unknown; } /** The tool_result change to return, or undefined when the result holds no secret. */ export function toolResultChange(event: ToolResultLike, known: Known) { if (known.length === 0) return undefined; const hits: Hits = new Map(); const contentHits: Hits = new Map(); const result: Record = {}; let contentChanged = false; const content = event.content.map((part) => { if (part.type !== "text" || typeof part.text !== "string") return part; // images: base64, not text const r = redactText(part.text, known); if (r.hits.size === 0) return part; contentChanged = true; addHits(contentHits, r.hits); return { ...part, text: r.text }; }); if (contentChanged) result.content = content; maxHits(hits, contentHits); if (event.structuredContent !== undefined) { const r = redactDeep(event.structuredContent, known); maxHits(hits, r.hits); // Replacing content without returning structuredContent DROPS it (types.d.ts), // so return it whenever content changed, redacted or not. if (r.value !== event.structuredContent || contentChanged) result.structuredContent = r.value; } if (event.details !== undefined && event.details !== null) { const r = redactDeep(event.details, known); if (r.value !== event.details) { maxHits(hits, r.hits); result.details = r.value; } } return hits.size ? { result, hits } : undefined; } /** The context change to return, or undefined when nothing bound for the model holds a secret. */ export function contextChange(messages: M[], known: Known) { if (known.length === 0) return undefined; const r = redactDeep(messages, known); return r.value === messages ? undefined : { messages: r.value, hits: r.hits }; } /** Block a write/edit whose new text would put a live secret's placeholder into a file. */ export function writeGuard(event: { toolName: string; input: unknown }, known: Known) { if (event.toolName !== "write" && event.toolName !== "edit") return undefined; const input = (event.input ?? {}) as { path?: unknown; content?: unknown; newText?: unknown; edits?: Array<{ newText?: unknown }>; }; const written = [input.content, input.newText, ...(Array.isArray(input.edits) ? input.edits.map((e) => e?.newText) : [])].filter( (s): s is string => typeof s === "string", ); const names = known.map(([n]) => n).filter((n) => written.some((s) => s.includes(placeholder(n)))); if (names.length === 0) return undefined; const file = typeof input.path === "string" ? input.path : "the file"; const list = names.map((n) => placeholder(n)).join(", "); const example = names[0]; return { block: true, reason: `secret-guard: this ${event.toolName} would put ${list} into ${file}. That text is the placeholder ` + `secret-guard showed you instead of the real value, so writing it would replace the secret with the ` + `placeholder. To write the real value, write the file with bash and let the shell expand "$${example}" ` + `(e.g. a heredoc); the output you see stays redacted. If you really mean the literal placeholder text ` + `(docs about the format), write it with bash.`, names, }; } export function guardMode(env: Record): "on" | "off" { return (env.PI_SECRET_GUARD ?? "").trim().toLowerCase() === "off" ? "off" : "on"; } const describe = (hits: Hits) => [...hits].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(", "); export default function (pi: ExtensionAPI) { if (guardMode(process.env) === "off") return; // Recomputed per event: cheap (one pass over env), and right if an extension // sets a credential in process.env during the session. const known = () => envSecrets(process.env); const announcedContext = new Set(); pi.on("tool_result", (event, ctx) => { const change = toolResultChange(event, known()); if (!change) return; if (ctx.hasUI) ctx.ui.notify(`secret-guard: redacted ${describe(change.hits)} from ${event.toolName} output`, "warning"); return change.result; }); pi.on("context", (event, ctx) => { const change = contextChange(event.messages, known()); if (!change) return; const fresh = [...change.hits.keys()].filter((n) => !announcedContext.has(n)); if (fresh.length && ctx.hasUI) { for (const n of fresh) announcedContext.add(n); ctx.ui.notify(`secret-guard: ${fresh.join(", ")} found in the conversation; redacted from what is sent to the model`, "warning"); } return { messages: change.messages }; }); pi.on("tool_call", (event, ctx) => { const verdict = writeGuard(event, known()); if (!verdict) return; if (ctx.hasUI) ctx.ui.notify(`secret-guard: blocked a ${event.toolName} that would write ${verdict.names.join(", ")} placeholder(s)`, "warning"); return { block: true, reason: verdict.reason }; }); }