secret-guard: keep secret env values out of model context, session files and the palace feed

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=<value> 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 <redacted:NAME>.
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.
This commit is contained in:
Joakim Persson
2026-10-12 14:35:49 +02:00
parent f885199fb5
commit b3dc979aba
4 changed files with 618 additions and 1 deletions
+46
View File
@@ -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
`<redacted:NAME>`, 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=<value>`. 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.