From b3dc979abadd2f44d2ff59aa27ac8643aa65b752 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Mon, 12 Oct 2026 14:35:49 +0200 Subject: [PATCH] secret-guard: keep secret env values out of model context, session files and the palace feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured 2026-10-12 on emb-7kj4vr4g. A new session ran `env | grep …` and tried to redact the output itself with a sed. The sed only matched when TOKEN came after the `=`, so MEMPALACE_REMOTE_TOKEN= went through. That raw value was found in 12 local session files 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. Nothing guarded the step from tool output to model context. The new extension ports tier 1 of mempalace_redact.py exactly: the exact values of env vars whose name says secret are replaced with . Matching a known value cannot hit anything that is not that secret. - tool_result: content text, structuredContent (always returned when content changes, so it is not dropped) and details. - context: the same pass over every request, for user `!` output, pasted text and resumed sessions that already hold a raw value. - tool_call: block a write/edit whose new text contains a live secret's placeholder, i.e. writing a redacted .env back. The reason says to use bash and let the shell expand "$NAME". PI_SECRET_GUARD=off makes it inert. Tests (16, two-sided): selection, ordering, redaction, deep walk with identity, every handler, the off switch. Two parity tests run mempalace_redact.py on the same synthetic env and compare the selected names and the output text. Thirteen mutations, one per rule, are each caught. Two mutations initially survived and exposed fixture gaps, which are now fixed. Full suite: 45/45. End-to-end in real pi (erdc/deepseek-v4.1-flash). The secret was synthetic, and a test-only probe 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 and 0 stored, placeholder in both; - resumed control session: 1 sent without the guard, 0 with it; - interactive `!echo $SECRET`: 0 sent, plus a TUI warning; - a write of the placeholder: blocked, file sha unchanged. Not covered, as documented: live progress (tool_execution_update), the stored entry of a user `!` command, secrets not in env (no shape rules), and pi-task children, which run --no-extensions. --- AGENTS.md | 41 +++++- README.md | 46 ++++++ extensions/secret-guard.ts | 291 +++++++++++++++++++++++++++++++++++++ test/secret-guard.test.mjs | 241 ++++++++++++++++++++++++++++++ 4 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 extensions/secret-guard.ts create mode 100644 test/secret-guard.test.mjs 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"); +});