diff --git a/AGENTS.md b/AGENTS.md index e22b975..761b664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ extensions/ task.ts # `task` tool — pi-task (isolated child, PASS/FAIL envelope, boundary diff) as a tool fork-gate.ts # tool_call hook: block forks whose brief needs `task`; reason = the task(...) to make aws-sso.ts # /aws-sso: renew the AWS SSO session when it ends (local: host browser; remote: device code + ntfy push) -test/ # node --test 'test/**/*.test.mjs' — two-sided tests for the pure parts (classifier, root validation) + secret-guard.ts # tool_result/context/tool_call hooks: secret env values never reach the model, session file or palace feed +test/ # node --test 'test/**/*.test.mjs' — two-sided tests for the pure parts (classifier, root validation, secret selection/redaction) install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative README.md # User-facing docs. @@ -278,6 +279,44 @@ session. No flags, no `agent_*` event handlers — fully passive until `/ext` is invoked. +### `secret-guard.ts` + +Tier 1 of mempalace-toolkit's `bin/mempalace_redact.py`, ported to TypeScript and +applied at the step that redactor cannot reach: tool output on its way to the model. +Read the module header for the incident and for what is not covered. + +- **The selection rules are a copy, and must stay one.** `SECRET_NAME`, + `NAME_NOT_SECRET`, `TRIVIAL_VALUES` and `NOT_A_SECRET_VALUE` mirror the Python + `_SECRET_NAME`, `_NAME_NOT_SECRET`, `_TRIVIAL_VALUES` and `_NOT_A_SECRET_VALUE`. + Change both together. The parity tests run the Python module on the same + synthetic env and fail on any difference in selected names or output text; they + SKIP (not pass) when `mempalace_redact.py` is absent, so run them where it is + installed (`/opt/mempalace-toolkit/bin`, or point `MEMPALACE_REDACT_PY` at it). +- **Tier 1 only, on purpose.** Matching a known value cannot hit anything that is + not that secret, so it is safe on every tool result. Shape rules (tier 2) would + catch secrets that are not in env, but they rewrite text by pattern. In tool + output that means a misfire changes what the agent reads and may write back. + Add them only with the same two-sided tests. +- **`tool_result` returning `content` drops `structuredContent` unless it is + returned too** (pi `types.d.ts`). `toolResultChange` always returns it when + content changed; a test pins that. +- **Handlers return `undefined` when nothing matched.** `redactDeep` keeps object + identity for untouched values so this is cheap to detect. A handler that + always returned a copy would rewrite every tool result and every request + context, for nothing. +- **The `context` pass walks the whole conversation on every request.** Measured: + 36 ms on a 53 MB session, under 1 ms on a typical one. +- **End-to-end check** (2026-10-12, real pi on erdc/deepseek-v4.1-flash; the secret + was synthetic, and a probe extension on `before_provider_request` counted it in + each payload): + - control without the guard: 1 raw copy sent to the provider, 1 in the session file; + - with the guard: 0 sent, 0 in the session file, placeholder in both; + - resumed control session: 1 sent without the guard, 0 with it; + - user `!echo $SECRET`: 0 sent, plus a TUI warning; + - write of the placeholder: blocked, file sha unchanged. + The raw value still showed in `--mode json` stdout, in `tool_execution_update` + (live progress), which is not covered. + ### `todo.ts` Unchanged copy of upstream `examples/extensions/todo.ts` from diff --git a/README.md b/README.md index edb2817..b00bced 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,52 @@ update on…" — verbs a naive list would misfire on). `PI_FORK_GATE=off` makes it log to stderr instead of blocking; `/ext` disables it. +### `secret-guard.ts` + +Keeps 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. A secret +var is one whose name says secret (`…TOKEN`, `…SECRET`, `…API_KEY`, +`…PASSWORD`, `_PAT`, …) and whose value looks like a credential rather than a +path, knob or placeholder. Each exact value is replaced with +``, the same placeholder mempalace-toolkit's palace redactor +writes. + +Measured motivation, 2026-10-12: a new session ran an `env | grep …` with its own +`sed` redaction, and the `sed` missed `MEMPALACE_REMOTE_TOKEN=`. That +value turned out to be in 12 local session files going back two months, and each +of those sessions had sent it to its model provider. The palace stayed clean only +because the feeder scrubs transcripts before upload; nothing guarded the step from +tool output to model context. + +Three hooks: + +| hook | what it does | +|---|---| +| `tool_result` | rewrites `content` (text parts), `structuredContent` and `details` of every tool result, MCP tools included. The model, the session file and therefore the palace feed only ever get the placeholder. | +| `context` | the same pass over everything sent to the model, per request. Covers user `!` command output, pasted text, and resumed sessions that already hold a raw value. Request-local: the session file is not changed. | +| `tool_call` | blocks a `write`/`edit` whose **new** text contains the placeholder of a live secret. That is what an agent writes back after reading a redacted `.env`, and it would replace the secret with the placeholder. The reason says to write the file with bash and let the shell expand `"$NAME"`. | + +Commands keep working: `$NAME` still expands in bash; only the output that comes +back is rewritten. Matching a known value cannot hit anything that is not that +secret, so there is no shape or entropy heuristic to misfire. The cost is 36 ms +per request on a 53 MB session and under 1 ms on a normal one. + +Not covered: +- secrets that are not in pi's environment (key files, another host's decrypted + `.env`); +- the live streaming view while a tool runs (only the final result is + rewritten); +- the stored entry of a user `!` command; +- pi-task children, which run `--no-extensions`. + +Tests: `test/secret-guard.test.mjs` is two-sided. It also runs +`mempalace_redact.py` (from `/opt/mempalace-toolkit/bin`, or +`$MEMPALACE_REDACT_PY`) on the same synthetic environment, and fails if the two +pick different variables or produce different text. Every rule was +mutation-checked. + +`PI_SECRET_GUARD=off` makes it inert; `/ext` disables it. + ### `todo.ts` Gives the agent a `todo` tool (actions: `list` / `add` / `toggle` / `clear`) so it can externalize a multi-step plan and tick items off as it works. Also registers `/todos` so you can inspect the current list at any time. diff --git a/extensions/secret-guard.ts b/extensions/secret-guard.ts new file mode 100644 index 0000000..22b0ea9 --- /dev/null +++ b/extensions/secret-guard.ts @@ -0,0 +1,291 @@ +/** + * 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); +} + +/** 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 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(hits, r.hits); + return { ...part, text: r.text }; + }); + if (contentChanged) result.content = content; + + if (event.structuredContent !== undefined) { + const r = redactDeep(event.structuredContent, known); + addHits(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) { + addHits(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 }; + }); +} diff --git a/test/secret-guard.test.mjs b/test/secret-guard.test.mjs new file mode 100644 index 0000000..bdde7d4 --- /dev/null +++ b/test/secret-guard.test.mjs @@ -0,0 +1,241 @@ +// Two-sided tests for secret-guard: what MUST be treated as a secret and what +// MUST NOT, what MUST be rewritten and what MUST come back untouched (by +// reference, so the handlers can return "no change"). A guard that only ever +// redacts has not been shown to discriminate. Runs the shipped .ts directly +// (node >= 23 strips types natively; the only import is type-only). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { + envSecrets, + placeholder, + redactText, + redactDeep, + toolResultChange, + contextChange, + writeGuard, + guardMode, +} from "../extensions/secret-guard.ts"; + +// Values are shaped like nothing in particular (no ghp_/sk-/AKIA prefix), so the +// Python parity check exercises tier 1 only and tier-2 shape rules stay silent. +const V = { + a: "Zq7mWc2Lr9Kp4Tn8Vx3Bd6Hs1Fj5Gy0Qe7Ua2Ic9Op4", // 43, like MEMPALACE_REMOTE_TOKEN + b: "r2D9xK7pQm4LwZ8vT1cB6nH3sF5jG0yE", // 32 + c: "Mx8Rt3Lq9Wz2Kc7Vb4Nd1Hp6Js5Fg0Ya1Ue9Io3Pl8Ak2Sd7", // 49 + d: "uV6tR1eW8qY3iO7pA2sD9fG4", // 24 + shortish: "abcdefghijkl", // exactly 12: still a secret by length +}; + +// name -> value. MUST_SELECT: tier-1 secrets. MUST_SKIP: secret-sounding names +// that hold a location, a knob or a placeholder, and plain names. +const MUST_SELECT = { + MEMPALACE_REMOTE_TOKEN: V.a, + ERDC_INFERENCE_API_KEY: V.c, + GITEA_ACCESS_TOKEN: V.b, + CLIENT_SECRET: V.d, + DB_PASSWORD: V.shortish, + GITHUB_PAT: V.b + "x", + PAT: V.d + "y", + OAUTH_BEARER: V.a + "z", + SESSION_KEY: V.c + "w", + api_key: V.d + "q", // lower case: the name rule is case-insensitive + AWS_SECRET_ACCESS_KEY: V.b + "AB", + SOME_CREDENTIALS: V.d + "cr", +}; +const MUST_SKIP = { + SSH_KEY_PATH: "/home/developer/.ssh/id_ed25519", // _PATH suffix + GITEA_TOKEN_FILE: "/run/secrets/gitea_token_file_x", // _FILE suffix + GITHUB_TOKEN_URL: "https://github.example/token/endpoint", // _URL suffix + PI_OM_MAX_TOKENS: "100000", // too short to be a credential + SHORT_TOKEN: "abcdefghijk", // 11 chars + SPACED_SECRET: "this has spaces in it ok", // credentials have no whitespace + PLACEHOLDER_TOKEN: "", + REF_TOKEN: "${MEMPALACE_REMOTE_TOKEN}", + DOLLAR_TOKEN: "$MEMPALACE_REMOTE_TOKEN", + HOME_TOKEN: "~/secrets/token-file-here", + TRIVIAL_SECRET: "changeme", + STARS_PASSWORD: "****************", + HOME: "/home/developer", + PATH: "/usr/local/bin:/usr/bin:/bin", + MEMPALACE_REMOTE_URL: "https://mempalace.example/mcp", + RANDOM_STRING: V.a + "notsecret", // a random value under a plain name is NOT tier 1 + DEPLOY_PRIVATE_KEY: "~deploy/.ssh/id_ed25519", // ~user/ path: only the path rule catches it +}; +const ENV = { ...MUST_SELECT, ...MUST_SKIP }; + +test("envSecrets selects every secret-named credential (positive side)", () => { + const names = new Set(envSecrets(ENV).map(([n]) => n)); + for (const n of Object.keys(MUST_SELECT)) assert.ok(names.has(n), `should select ${n}`); +}); + +test("envSecrets skips locations, knobs, placeholders and plain names (negative side)", () => { + const names = new Set(envSecrets(ENV).map(([n]) => n)); + for (const n of Object.keys(MUST_SKIP)) assert.ok(!names.has(n), `should skip ${n}`); +}); + +test("envSecrets orders longest value first so a contained secret cannot fragment a longer one", () => { + const lens = envSecrets(ENV).map(([, v]) => v.length); + assert.deepEqual(lens, [...lens].sort((x, y) => y - x)); +}); + +// The Python redactor (mempalace-toolkit) is the reference for tier 1. If the two +// ever disagree, the palace and the model see different things, so pin them. +const REDACT_PY = + process.env.MEMPALACE_REDACT_PY ?? + ["/opt/mempalace-toolkit/bin/mempalace_redact.py", path.join(process.env.HOME ?? "", "src/mempalace-toolkit/bin/mempalace_redact.py")].find( + (p) => existsSync(p), + ); + +function runPython(code, input) { + return execFileSync("python3", ["-c", code], { + input: JSON.stringify(input), + env: { PATH: process.env.PATH, PYTHONPATH: path.dirname(REDACT_PY) }, + encoding: "utf8", + }); +} + +test("envSecrets selects exactly what mempalace_redact.env_secrets() selects", { skip: !REDACT_PY && "mempalace_redact.py not found" }, () => { + const py = JSON.parse( + runPython( + "import json,sys,mempalace_redact as r; print(json.dumps([n for n,_ in r.env_secrets(json.load(sys.stdin))]))", + ENV, + ), + ); + assert.deepEqual(new Set(envSecrets(ENV).map(([n]) => n)), new Set(py)); +}); + +test("redactText output equals the Python redactor's on tier-1 material", { skip: !REDACT_PY && "mempalace_redact.py not found" }, () => { + const known = envSecrets(ENV); + const text = [ + `MEMPALACE_REMOTE_TOKEN=${V.a}`, + `{"env": {"ERDC_INFERENCE_API_KEY": "${V.c}"}}`, + `curl -H "Authorization: token ${V.b}" https://gitea.example/api`, + `twice: ${V.d} and ${V.d}`, + "nothing secret on this line", + ].join("\n"); + const py = runPython( + "import json,sys,mempalace_redact as r; d=json.load(sys.stdin); print(r.scrub(d['t'], [tuple(k) for k in d['k']], strict=False)[0], end='')", + { t: text, k: known }, + ); + assert.equal(redactText(text, known).text, py); +}); + +test("redactText: env-dump line, JSON value, header, repeats; counts per name", () => { + const known = envSecrets(ENV); + const r = redactText(`MEMPALACE_REMOTE_TOKEN=${V.a}\n{"k":"${V.a}"}\nBearer ${V.b} ${V.b}`, known); + assert.ok(!r.text.includes(V.a) && !r.text.includes(V.b)); + assert.match(r.text, /^MEMPALACE_REMOTE_TOKEN=$/m); + assert.equal(r.hits.get("MEMPALACE_REMOTE_TOKEN"), 2); + assert.equal(r.hits.get("GITEA_ACCESS_TOKEN"), 2); +}); + +test("redactText: a longer secret containing a shorter one is replaced whole", () => { + const known = envSecrets({ LONG_TOKEN: V.a + V.b, SHORT_TOKEN: V.b }); + const r = redactText(`x ${V.a + V.b} y ${V.b}`, known); + assert.equal(r.text, "x y "); +}); + +test("redactText: idempotent, and text without secrets is returned as the same string", () => { + const known = envSecrets(ENV); + const once = redactText(`t=${V.a}`, known).text; + assert.equal(redactText(once, known).text, once); + const clean = "drawer_pi-devbox_security_85ae3387c82c8211ff30512c and a sha 3760d90998bf269625c97b15061ab1c966bd61b7"; + const r = redactText(clean, known); + assert.equal(r.text, clean); + assert.equal(r.hits.size, 0); +}); + +test("redactDeep rewrites nested strings and returns the SAME reference when nothing matched", () => { + const known = envSecrets(ENV); + const clean = { a: [1, "x", { b: null, c: true }], d: "y" }; + assert.equal(redactDeep(clean, known).value, clean); + const dirty = { a: [1, `k=${V.a}`, { b: null, c: V.c }], d: "y" }; + const r = redactDeep(dirty, known); + assert.notEqual(r.value, dirty); + assert.deepEqual(r.value, { a: [1, "k=", { b: null, c: "" }], d: "y" }); + assert.equal(dirty.a[1], `k=${V.a}`, "input must not be mutated"); +}); + +const bashEvent = (text, extra = {}) => ({ + type: "tool_result", + toolName: "bash", + toolCallId: "c1", + input: { command: "env" }, + content: [{ type: "text", text }, { type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + isError: false, + details: { fullOutputPath: "/tmp/pi-bash-1.log" }, + ...extra, +}); + +test("toolResultChange: redacts content, keeps images and structuredContent, reports names", () => { + const known = envSecrets(ENV); + const sc = { rows: 3 }; + const ch = toolResultChange(bashEvent(`MEMPALACE_REMOTE_TOKEN=${V.a}\nok`, { structuredContent: sc }), known); + assert.ok(ch); + assert.equal(ch.result.content[0].text, "MEMPALACE_REMOTE_TOKEN=\nok"); + assert.deepEqual(ch.result.content[1], { type: "image", data: "aGVsbG8=", mimeType: "image/png" }); + // replacing content without returning structuredContent would DROP it (types.d.ts) + assert.equal(ch.result.structuredContent, sc); + assert.deepEqual([...ch.hits.keys()], ["MEMPALACE_REMOTE_TOKEN"]); + assert.ok(!("details" in ch.result), "unchanged details are not returned"); +}); + +test("toolResultChange: secrets in details or structuredContent alone are redacted too", () => { + const known = envSecrets(ENV); + const ch = toolResultChange(bashEvent("clean", { details: { patch: `+TOKEN=${V.b}` }, structuredContent: { v: V.c } }), known); + assert.equal(ch.result.details.patch, "+TOKEN="); + assert.deepEqual(ch.result.structuredContent, { v: "" }); + assert.ok(!("content" in ch.result), "unchanged content is not returned"); +}); + +test("toolResultChange: no secret anywhere -> undefined (handler returns nothing)", () => { + assert.equal(toolResultChange(bashEvent("total 12\ndrwx------ 2 developer"), envSecrets(ENV)), undefined); + assert.equal(toolResultChange(bashEvent(`x ${V.a}`), []), undefined, "no known secrets -> nothing to do"); +}); + +test("contextChange: redacts any message shape, e.g. a user ! command's output", () => { + const known = envSecrets(ENV); + const msgs = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "bashExecution", command: "env", output: `ERDC_INFERENCE_API_KEY=${V.c}`, exitCode: 0 }, + ]; + const ch = contextChange(msgs, known); + assert.ok(ch); + assert.equal(ch.messages[1].output, "ERDC_INFERENCE_API_KEY="); + assert.equal(ch.messages[0], msgs[0], "untouched messages keep their identity"); + assert.equal(contextChange([msgs[0]], known), undefined); +}); + +test("writeGuard blocks a write/edit that would put a placeholder where a live secret was", () => { + const known = envSecrets(ENV); + const ph = placeholder("ERDC_INFERENCE_API_KEY"); + const w = writeGuard({ toolName: "write", input: { path: ".env", content: `ERDC_INFERENCE_API_KEY=${ph}\n` } }, known); + assert.ok(w?.block); + assert.match(w.reason, /ERDC_INFERENCE_API_KEY/); + assert.match(w.reason, /\$ERDC_INFERENCE_API_KEY/, "reason says how to write the real value"); + const e = writeGuard({ toolName: "edit", input: { path: "a", edits: [{ oldText: "x", newText: `k=${ph}` }] } }, known); + assert.ok(e?.block); +}); + +test("writeGuard lets through what is not that mistake", () => { + const known = envSecrets(ENV); + const ph = placeholder("ERDC_INFERENCE_API_KEY"); + // a placeholder for a name that is NOT a live secret here (e.g. docs about the format) + assert.equal(writeGuard({ toolName: "write", input: { path: "d.md", content: "" } }, known), undefined); + // the placeholder only in oldText: the edit cannot write it + assert.equal(writeGuard({ toolName: "edit", input: { path: "a", edits: [{ oldText: ph, newText: "y" }] } }, known), undefined); + // bash is not guarded: grepping for markers is legitimate + assert.equal(writeGuard({ toolName: "bash", input: { command: `grep -c '${ph}' f` } }, known), undefined); + // plain writes + assert.equal(writeGuard({ toolName: "write", input: { path: "a", content: "hello" } }, known), undefined); +}); + +test("guardMode: on by default, PI_SECRET_GUARD=off disables", () => { + assert.equal(guardMode({}), "on"); + assert.equal(guardMode({ PI_SECRET_GUARD: "OFF" }), "off"); + assert.equal(guardMode({ PI_SECRET_GUARD: "on" }), "on"); + assert.equal(guardMode({ PI_SECRET_GUARD: "bogus" }), "on", "an unknown value must not disable a guard"); +});