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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user