HOMELAB-2194: feat(redact): mask secrets in tool output before context, session logs and subagent transcripts #65
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2194-redact"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracking: HOMELAB-2218 (child of HOMELAB-2194, Pre-LD5 W1 Foundations).
Why
On 2026-10-23 a pi subagent probe echoed the in-cluster auditor's Claude OAuth token (
sk-ant-oat01-…) into its transcript. It landed in/tmp/pi-subagents-1005/…/tasks/<id>.outputand in the parent session's context, and it had to be rotated.What
redact/: a new extension. It adds atool_resulthook for every tool. pi runs that hook before it builds the toolResult message, emitstool_execution_endand appends to the session JSONL, so model context, the session file and compaction only ever see the masked form.patterns.ts: the one pattern file. It covers Anthropic keys and OAuth/refresh tokens, OpenAIsk-, GitHub/GitLab PATs, Slack, Stripe, Google, npm, HF, Tailscale, Plane and Outline keys, URL credentials,Authorization: Bearer|Basic|token, JWTs, OpenBao/Vaulthvs./legacys.tokens, AWS key IDs and secrets, age keys, PEM private keys, and credential-named assignments (password=,"password": "…",PASS=,FORGEJO_API_KEY=<40hex>…).structural.ts: k8s Secretdata:/stringData:(YAML and JSON,kind: List, the last-applied annotation),kubectl get secret … -o jsonpath, andbao kv get(table,-field,-format=json).[REDACTED:<type> …last4], with no tail for secrets under 16 chars. Quotes are preserved, so JSON stays valid.[redact] N secrets redacted in this tool result (types). There is also a status-bar counter and a JSONL count log (~/.pi/agent/redact.log).PI_REDACT_DISABLED=1is an operator kill switch.pi-swarm-suite, viaredact-bridge.ts:redactis a required child extension. It wins overextensions:allowlists andexclude_extensions:, and it is force-added by path forisolated: truechildren (most fleet charters are isolated)..outputtranscript entry, including the initial prompt, is written through the redactor.globalThis[Symbol.for("pi-extensions.redact")], so each works without the other.redact, plus README and docs.Tests
redact: 68 tests. They include a two-way corpus: real-looking secrets must be masked, and SHAs, digests, UUIDs, Plane ids, ConfigMaps, Deployments, ExternalSecrets, helm templates, sopsENC[], Go/TS/Python source and prose must come back byte-identical. The extension contract is driven through a fake pi (args untouched, no mutation, notice, log, fail-open, kill switch).pi-swarm-suite: the newredact-bridge.test.ts(required-extension decision, forced path,.outputmasking, fail-open). The full suite passes (1472).git log -p). After tightening, the remaining hits are credential-shaped test fixtures and documented Secret examples.Limitations (also in redact/README.md)
tool_execution_updatepartials (the TUI tail,--mode jsonstdout) are not masked. pi has no hook to rewrite them.fullOutputPath) is not masked, because we never alter files.!cmdoutput, user prompts and assistant text are not rewritten.🤖 Generated with Claude Code
Independent review — round 1 (fresh
pi -p --no-session, omniroute/kimi-pool; brief = PR diff + ticket requirements only)VERDICT: REQUEST_CHANGES
[BLOCKER] Quadratic redaction scan on common large-output shapes —
redact/patterns.ts:269,309,314,321(unbounded classes) +redact/redactor.ts:104(re.lastIndex = m.index + 1retry). A 50 KB single-line dash/dot run — realistic in minified JS, sourcemaps, long slug/log lines — takes 5.3 s ("x-".repeat(25000)) and 2.9 s ("a.".repeat(25000)) per tool result at the committed HEAD (measured; the whole 11-case perf matrix takes 10.6 s vs 0.4 s after bounding). Cause: theKEY=valuekey class[A-Za-z0-9_.-]*and the url-credential scheme class[a-z0-9+.-]*restart matching at every position, and a rejected match retries fromindex+1, giving O(n²) on text with no separator at all. Built-in tools cap at 50 KB (truncate.js), so every affected tool result stalls seconds; extension/MCP-bridged results can be larger (minutes). The fail-open try/catch cannot mitigate a hang — the tool pipeline just blocks. The worktree already contains the correct fix (bounded{1,1024}/{0,63}classes,lastIndex = Math.max(m.index+1, start), plustests/perf.test.ts) but it is uncommitted and not in the PR — commit it into this branch; without it this is an availability defect on a covered path.[CONCERN] Space-separated password assignments are never masked —
redact/patterns.ts:314-326. The assignment rules require=or:separators, so credential files leak in full: repromachine forgejo.example\nlogin aaron\npassword Sup3rSecretValue!x→ count=0, raw value reaches model context and session JSONL..netrcis exactly the kind of file an agentcats; the required category is "password= / "password": "…" / PASS= assignments" andpassword <value>is the canonical assignment form in these files. Add apassword <value>(space-sep, start-of-line/after-whitespace, non-prose-gated) rule.[CONCERN]
kubectl get secret … -o json | jq -r .data.x | base64 -dbypasses command-aware masking —redact/structural.ts:292-296.KUBECTL_SECRET_BAREonly matches-o jsonpath|go-template|template, so the extracted bare Secret value passes through unmasked (repro → count=0 for an opaque value with no vendor prefix). The requirement explicitly covers "k8s Secret data: values when kubectl get secret -o yaml/json output is shown" — here the-o jsonoutput is what is shown, transformed by jq. Broaden detection to-o json|yamlsegments that feed a downstream extractor, or mask whole output forkubectl … secret … -o json|yamlpiped intojq/yq/base64 -dwhen the final segment emits a bare value.[CONCERN]
bao/vault kv get -format=yamlleaks secrets under non-credential key names —redact/structural.ts:299-302. Only table,-fieldand-format=jsonare handled; reprotls_cert: Zm9v…==with commandbao kv get -format=yaml kv/ld/db→ count=0. Vault data keys are arbitrary (tls_cert,conn_str), so the generic key-named rules don't catch them.-format=yamlis a first-class CLI form. The README honestly lists only json, but this leaves a plausible, easily-hit leak on a path the structural module purports to cover.[SUGGESTION] Flow-style YAML Secret data with short values slips through:
data: {password: c2Vj}(4-char value) → count=0 (structural.tsblock regex needsdata:on its own line; the generic rule's 8-char floor rejects short values). Low severity (real base64 data values are longer), but a documented corner.[SUGGESTION] Details-only redactions are invisible: in
redact/index.tsthe[redact] N secrets redactednotice and the status-bar counter only fire oncount(content masks); a result masked solely insidedetails(detailsCount>0) returns new details with no notice and no counter increment. Operators and the model get no signal on that path.[SUGGESTION] README claim tension:
redact/README.md("Outputs only. Tool-call arguments are never touched") vspi-swarm-suite/src/core/redact-bridge.ts:53which deep-masks arguments inside.outputtranscript entries (e.g. a raw token inside a bash command line in the transcript becomes[REDACTED:…]). The transcript masking is desirable and consistent with the ticket ("any session/transcript log") — but tighten the README wording so it doesn't read as a contradiction.[SUGGESTION]
redactDeep(redact/redactor.ts:135-138) silently skips non-plain objects (class instances, Maps) in tooldetails; pi's built-ins use plain objects (verified bash/read/grep details are metadata-only), but an extension/MCP tool returning a class-instance detail with a secret string would land unmasked in the session JSONL. Consider documenting this boundary next to the other limitations.Verified while reviewing (no findings): pi hook ordering claims hold against installed 0.87.1 sources (
finalizeExecutedToolCallrunsafterToolCallbeforeemitToolExecutionEndand before the toolResult message is built/appended; runner catches handler exceptions and merges{content, details}exactly as the extension assumes);DefaultResourceLoaderdoes loadadditionalExtensionPathsundernoExtensionsand dedupes by canonical path, so the forced child-load path inagent-runner.ts:660-666works as designed; required categories (Anthropic sk-ant-*, OpenAI sk-≥32, GH/GL/Forgejo PATs in context, URL creds, Authorization, JWT, hvs./s., AKIA+aws_secret, age, PEM, password=/"password": forms, k8s Secret data incl. List/last-applied/jsonpath, bao table/-field/-format=json) all masked with reproducers; MUST-KEEP corpus (SHAs, digests, UUIDs, Plane ids, ConfigMaps, ExternalSecrets, helm templates, sops ENC, Go/TS/Python source, prose) byte-identical, plus a spot sweep of repo source files clean; fail-open tested both in the extension and the bridge;PI_REDACT_DISABLEDis process-env only (a tool call cannot set it); args never masked on the live tool path and inputs never mutated; CI job valid, package-lock committed,^0.80.2pins match repo convention; full pi-swarm-suite suite passes 1472/1472 at HEAD, matching the PR body; redact suite 68/68 at HEAD.Primary fix needed before merge: finding 1 (the bounded-pattern + lastIndex change and
tests/perf.test.tsalready exist uncommitted in the worktree — commit them). Findings 2-4 are secret-leak gaps on plausible covered paths and should be addressed or explicitly documented as limitations in the same PR.Resolution (
d32b490): 1 fixed (bounded classes + retry-from-secret-start;redact/tests/perf.test.ts). 2 fixed (netrc rule). 3 fixed (kubectl-o json|yaml→ extractor pipelines masked whole). 4 fixed (-format=yaml). 5 fixed (flow-style data). 6 fixed (details-only counted in status/log). 7, 8 documented inredact/README.md. Delta re-review follows.🤖 Generated with Claude Code
Independent review — round 2 (delta re-review, fresh
pi -p --no-session, omniroute/kimi-pool)VERDICT: APPROVE
Round-1 findings:
#1 RESOLVED — bounded classes committed (
patterns.ts:271,278,285,290,306,320,329,337),lastIndex = Math.max(m.index+1, start)(redactor.ts:107),tests/perf.test.tsadded. Independent timing at HEAD:"x-".repeat(25000)20.8 ms,"a.".repeat(25000)19.7 ms, worst case ("a=".repeat(25000)) 105.9 ms — vs 5.3 s/2.9 s at round-1 HEAD. Full 11-case matrix linear. 87/87 tests pass.#2 RESOLVED — new
passwordspace rule (patterns.ts:296-303). Repromachine forgejo.example\nlogin aaron\npassword Sup3rSecretValue!x→ count=1, masked; one-line form also masked. Prose probes (password is stored…,password security is important,passwd: password updated successfully) unchanged; tests added.#3 RESOLVED —
kubectlSecretPipeline+KUBECTL_SECRET_DUMP(structural.ts:331-339, wired at434). Reproskubectl get secret x -o json | jq -r .data.token | base64 -d,-o yaml | grep,--output=json | yqall masked whole (count=1). FP controlkubectl get secrets | grep auditoruntouched (test added).#4 RESOLVED —
redactVaultYaml+VAULT_YAMLbranch (structural.ts:344-365,455-460). Repro KV v2 (data.dataleaves masked,metadata.version/lease_durationintact) and KV v1 (meta keys skipped) both masked; tests added.#5 RESOLVED —
DATA_FLOW_LINE+maskFlowMapping(structural.ts:104,139-157,207-218). Reprodata: {password: c2Vj, token: 'dG9r'}→ count=2, values masked;metadata: {name: x}untouched; flow-style ConfigMap in MUST_KEEP corpus.#6 RESOLVED —
index.ts:159now addsr.count + r.detailsCount; new test asserts details-only mask yieldsredacted: 1and a"detailsCount":1log line.#7 RESOLVED — README reworded ("arguments a tool call actually executes with", transcript-log exception explained); no longer contradicts
redact-bridge.ts.#8 DOCUMENTED — README "Non-plain
detailsobjects" limitation added (plus session-JSONL arguments limitation).New findings:
[CONCERN]
redact/structural.ts:455-460— the extractor-pipeline bypass fixed for kubectl remains for vault. Reproducers:bao kv get -format=yaml kv/ld/db | grep tls_cert→ outputtls_cert: Zm9vYmFy…count=0 (no top-leveldata:line survives the extractor, and no generic rule keys ontls_cert);... | yq '.data.data.tls_cert'→ bare value count=0;bao kv get -format=json ... | jq -r .data.data.tls_cert→ bare value count=0 (JSON.parse fails on the bare scalar, catch returns count=0). Same class as round-1 #3, on a path the README now advertises (-format=yaml). Not a regression (json form pre-dated the delta), but the samekubectlSecretPipeline-style guard should apply toVAULT_CMDsegments feedingEXTRACTOR, or the whole-output mask forvault … -field|json|yaml | jq/yq/….[CONCERN]
redact/index.ts:161— status-bar total double-counts: bash mirrors output intodetails.note, so one token masked in content yieldssessionTotal += 2; the extension test now assertsredacted: 2for a single secret. The log line and per-result notice are accurate (count/detailsCount split); only the operator-facing total inflates. Considerr.count + (r.count ? 0 : r.detailsCount).[SUGGESTION]
redact/structural.ts:139-157— nested flow values are skipped:data: {config: {nested: c2VjcmV0}}→ count=0 (value class[^,{}]stops at{, inner flow not walked). Valid k8s Secret data values are always strings, so input is invalid YAML anyway — document as a corner or walk one level.[SUGGESTION]
redact/patterns.ts:271— bounding gap, theoretical only: URL userinfo >256 chars or password >1024 chars no longer matches. No realistic credential approaches this; no action needed unless the bounding is meant as a hard guarantee.Resolution (
dfd0022): CONCERN 1 fixed (bao/vault → extractor pipelines masked whole; test added). CONCERN 2 fixed (sessionTotal += count || detailsCount). Suggestions 3–4 accepted as documented corners (nested flow values are invalid Secret data; >1 KiB unquoted secrets are out of realistic range).🤖 Generated with Claude Code
Independent review — round 3 (delta re-check of
dfd0022, freshpi -p --no-session, omniroute/kimi-pool)All verification complete. Both round-2 CONCERNs independently re-verified:
CONCERN 1 (vault extractor pipelines) — RESOLVED.
dumpPipedToExtractorgeneralized (structural.ts:328-337) and wired for vault atstructural.ts:442-446. All three reproducers now mask whole with no leak (slice-prefix check included):bao kv get -format=yaml | grep,| yq '.data.data.tls_cert'(bare value),| jq -r .data.data.tls_cert(bare scalar). Regression probes pass: realistic table dump (==== Data ====format), full-format=yamlKV v2/v1, and kubectl grep FP control all intact.CONCERN 2 (status-bar double-count) — RESOLVED.
redact/index.ts:161is nowsessionTotal += r.count || r.detailsCount: content-masked result counts once even when bash mirrors intodetails.note; details-only masks still count. Extension test assertsredacted: 1for one token; log line keeps the accurate"count":1/"detailsCount"split.No new FP/leak found. Over-mask probes on the broadened vault branch all pass through unmasked (count=0):
vault status | grep Sealed,vault list secret/ | grep,vault kv metadata get | grep— the pipeline guard only fires when a vault segment is followed by an extractor, and whole-output masking is fail-closed for genuine secret dumps. Kubectl path is pure parameterization, no behavior change. Full suite: 88/88 pass (was 87 + 1 new round-2 test).VERDICT: APPROVE
Findings:
redact/structural.ts:442-446—vault kv get kv/x | head -1now masks whole output (header line only). Fail-closed and consistent with the kubectl precedent; acceptable, but if operator UX matters the dump predicate could later distinguish-format/get/readfromlist/status/metadata.redact/structural.ts:139-157nested flow values skipped; theoretical, invalid-input corner — README note would suffice.No open blockers; CI 14/14 green on
dfd0022. Suggestions accepted as-is (fail-closed| headmasking is intended; nested flow values are invalid Secret data). Merging.🤖 Generated with Claude Code