Files
pi-extensions/extensions/secret-guard.ts
T
Joakim Persson a88fa99d39 secret-guard: count a secret once when bash returns the same output in content and structuredContent
Measured in a full-extension session: one `echo "$SECRET"` was reported as
"redacted ... x2". pi's bash tool returns its output both as content text and
as structuredContent.output, and both are (correctly) redacted. The count
now takes, per name, the highest count from any one field instead of the
sum. A test pins it: it failed before the change and passes after. Re-run
in the full-extension TUI: the warning now reads "redacted SG_TEST_TOKEN
from bash output"; 0 raw copies sent, 0 in the session file. Suite: 46/46.
2026-10-12 14:38:41 +02:00

301 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `=<redacted>`.
* The sed only matches when TOKEN comes AFTER the `=`, so
* MEMPALACE_REMOTE_TOKEN=<value> 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 `<redacted:NAME>`, 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 }}
"<[^>]*>", // <redacted:…> / <your-token>
"(?:/|~/|\\./|\\.\\./)[^\\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<string, string | undefined>): 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) => `<redacted:${name}>`;
export type Hits = Map<string, number>;
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<T>(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<string, unknown> = {};
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<string, unknown> = {};
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<M>(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<string, string | undefined>): "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<string>();
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 };
});
}