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
+40 -1
View File
@@ -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