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