16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 647 | pi-permission-system: permission review logs may persist secrets with inherited file modes |
Retro: #647 — pi-permission-system: permission review logs may persist secrets with inherited file modes
Stage: Planning (2026-07-25T19:52:14Z)
Session summary
Planned the response to a third-party security report from marcoscale98 claiming the permission review log persists secrets with umask-inherited file modes.
The direction gate ran twice: an initial ask_user settled the file-mode half and the mode scope, and the operator deferred the redaction half to free-form discussion, which converged on key-name masking plus ADR 0010.
Produced packages/pi-permission-system/docs/plans/0647-owner-only-log-modes-key-name-redaction.md with six TDD cycles.
Observations
- Measuring the live corpus changed the design.
The operator's own review log (6.7 MB, 8380 lines, mode 0644) was probed for the shapes a value-based redactor would target.
sk-had 403 hits, of which 356 were the tail oftask-approvaland 275 oftask-user;xoxmatched inside a tool-use id. True positives: zero. That evidence, not an argument from principle, is what retired the provider-prefix list — and it belongs in the ADR so the next reporter is triaged against data. - A structural fact invalidated the obvious design.
The first instinct was a single redaction choke point at
writeLineinlogging.ts. Readingpermission-prompter.ts:135showedtoolInputPreviewarrives there already flattened to a string byserializeToolInputPreview, so that choke point would have missed the reporter's literal repro (authorization: "Bearer TEST_VALUE"as a tool-input field). The plan therefore redacts at two points, and the reason is written down rather than left to be rediscovered. - Ecosystem precedent settled a question the operator flagged as outside their experience.
Pino's
redact, Winston's formats, and Serilog's destructuring policies are all declarative key-path masking; secret detection is a separate product category (gitleaks, trufflehog). Naming that precedent turned "I have no experience engineering this" into a bounded, fifteen-line decision. - A downstream
registerLogRedactorseam was considered and declined. It would have mirrored the three existing registries exactly, so novelty was low — but it would ship with zero consumers, which the package skill's maintenance-trap rule explicitly targets, and the operator confirmed they would not consume it. - Grammar-anchored bash redaction was costed, not filed.
Masking the value side of a
variable_assignmentin the existing tree-sitter parse would extend coverage toFOO_TOKEN=abc deploywith near-zero false positives, reusing #481/#645 machinery. Recorded in the ADR as the option a future report reopens; deliberately not filed as an issue, to avoid a speculative backlog entry. - Two grep findings would have bitten implementation.
test/tool-input-preview.test.tsandtest/tool-preview-formatter.test.tsmocksafeJsonStringifyby relative specifier (../src/logging.js), not the#src/alias — an alias-only grep misses both, and a missed retarget fails at run time rather than undertsc. Separately,safeJsonStringify's cycle /Error/biginthandling has no test at all, so the step-1 move needs characterization tests written first. - Scope held.
config-store.ts's config write was offered in the mode-scope gate and not selected; forwarding request/response files were. Those files get modes but not redaction, since the parent reads them to render the ask-prompt — the same reason the prompt path itself stays unredacted. - Classified non-breaking (
fix:): the review log is a diagnostic artifact with no documented consumer contract, and the docs never guaranteed verbatim payloads.
Stage: Implementation — TDD (2026-07-25T20:25:08Z)
Session summary
Executed all six planned TDD cycles plus one Tidy-First preparatory commit, landing owner-only file modes for both JSONL logs and the permission-forwarding artifacts, key-name redaction at two application points, and ADR 0010. Eight commits total; test count went from 2603 to 2665 (+62) across 127 → 130 files. Pre-completion reviewer returned PASS on every section.
Observations
- The reds were real measurements, not ceremony.
Step 4's red reported
expected 420 to be 384andexpected 493 to be 448— that is0o644and0o755, reproducing the reporter's exact claim about umask-inherited modes before a line of the fix existed. Step 3's red reproduced the literal repro from the issue body. - The two-application-point design was load-bearing, and the plan was right to insist on it.
Redaction at
writeLinealone would have left the reporter's own repro unfixed, becausegetToolInputPreviewForLogflattens tool input to a string before the writer ever sees its keys. The reviewer independently confirmed both points are covered and that no log write path bypasseswriteLine. - The
vi.mockpartial-module trap fired exactly where the testing skill warns.test/tool-preview-formatter.test.tsreplaced#src/json-safe-stringifywith a literal factory exporting onlysafeJsonStringify, which would have blanked out thecreateJsonSafeReplacerthatlog-redaction.tsbuilds on. Fixed with animportActualspread. Three existingformatGenericToolInputForLogtests also had to move to real serialization, since the log path no longer routes through the mocked prompt-path serializer — a net improvement, as they now assert real behavior. - Characterization tests before the move paid off immediately.
safeJsonStringify's cycle /Error/biginthandling had zero coverage because both consumers mocked it away. Writing the eight tests first surfaced an undocumented quirk worth pinning: a repeated non-cyclic reference is also marked[Circular], becauseseenentries are never released. - Deviation:
test/extension-config.test.tswas touched but not named in the plan. The logs-directory mode assertions had to live there, becauseensurePermissionSystemLogsDirectoryis insrc/extension-config.tsandtest/logging.test.tssupplies its ownensureLogsDirectorycallback, so it cannot exercise the real one. The plan's Module-Level Changes should have caught this. - Deviation: an extra
docs:commit for a distribution gap the plan missed.configuration.mdandtroubleshooting.mdship in the npm tarball;docs/decisions/does not, so the ADR links would have been dead for anyone reading the installed package. Resolved by following the absolute-GitHub-URL precedent already set indocs/subagent-integration.mdrather than addingdocs/decisionsto thefilesallowlist, which would ship ten internal design records to serve one user-facing reference. Verified withpnpm pack+tar tzf. - The permission gate caught an agent mistake mid-session.
An
Editcall dropped thepi-packages/packages/prefix from a path; theexternal_directorygate blocked it and named the correct location. A live demonstration of the thing being hardened. - Tidy-First assessor was well-scoped.
One recommendation (extract a shared temp-dir fixture in
test/logging.test.ts, which was about to gain two new scenarios), and its rejected list correctly declined three in-scope-but-unobstructive modules. It also recognized that the plan's own step-1 sequencing already was the tidy-first move for the riskiest friction rather than re-proposing it. - Pre-completion reviewer: PASS, no warnings.
It verified
isSensitiveLogKeyagainst every real key name the package logs and found no false positive — including confirming that the bash parser's internaltokenfield is never logged directly.
Stage: Final Retrospective (2026-07-26T00:58:10Z)
Session summary
Single session carrying #647 from a third-party security report through planning, six TDD cycles, and release as pi-permission-system-v23.0.2.
Nine implementation commits landed owner-only file modes, key-name log redaction at two application points, and ADR 0010; the pre-completion reviewer returned PASS and both CI runs were green.
The decisive moment was not in the code but in the design gate, where the operator declined a four-option menu and asked a question that produced a better answer than any option on it.
Observations
What went well
- Measurement replaced argument at the design gate.
Rather than reasoning about whether a secret-shape redactor would work, the planning stage probed the operator's live 6.7 MB review log: 403
sk-hits of which 356 were the tail oftask-approval,xoxinside a tool-use id, and zero true positives. That single command retired an entire design direction and became the ADR's evidence table. The same instinct carried into the reds —expected 420 to be 384is0o644versus0o600, so the failing test was the bug report. - A structural reading of the code invalidated the obvious design before it was built.
Reading
permission-prompter.ts:135showedtoolInputPreviewarrives at the writer already flattened to a string, so the natural single-choke-point design would have shipped without fixing the reporter's own repro. Catching this at plan time rather than at review time is what made the two-application-point design deliberate instead of a patch. pnpm packcaught a distribution bug that every other gate missed.check,lint,test,fallow, and the pre-completion reviewer were all green with two dead documentation links in the shipped tarball. Only unpacking the artifact surfaced it.- The permission gate under test blocked a real agent mistake.
An
Editdropped thepi-packages/packages/prefix and theexternal_directorygate refused it, naming the correct path. A live demonstration of the subject matter, mid-implementation.
What caused friction (agent side)
missing-context— the first redactionask_useroffered four options (no redaction, grammar-anchored bash redaction, grammar-plus-shape-list, metadata-only logging), all constructed from first principles. None of them was key-name masking — the boring, fifteen-line, zero-maintenance technique that pino'sredact, Winston's formats, and Serilog's destructuring policies all implement, and the one that actually shipped. The ecosystem precedent was never checked before the option set was built. Impact: one extraask_userround-trip, and the correct answer arrived only because the operator asked "is there a low-hanging technical implementation that is very common?" No rework — but the design gate was one question away from converging on a worse option.instruction-violation(self-identified, at retro) —/plan-issuedirects loading thecolgrepskill before code exploration and thedesign-reviewskill before finalizing any design that extracts or changes shared interfaces. Neither was loaded; the plan extractedsafeJsonStringifyinto a new module, which is squarely adesign-reviewtrigger. Impact: no rework. Exploration was mostly exact-symbol tracing wheregrepwas the right tool, and thetidy-first-assessorplus the pre-completion reviewer independently covered the structural grounddesign-reviewwould have. The gates were skipped without consequence, which is precisely why it is worth noting.other— anEditcall constructed the path/Users/chris/development/pi/pi-permission-system/test/extension-config.test.ts, dropping thepi-packages/packages/segment. Impact: one rejected tool call, corrected immediately; zero rework. Theexternal_directorygate caught it, so the blast radius was a single retry rather than a file written outside the repo.other— the plan's Module-Level Changes did not nametest/extension-config.test.ts, which had to absorb the logs-directory mode assertions becausetest/logging.test.tsstubsensureLogsDirectoryand cannot exercise the real one. Impact: a noted deviation, no rework. The plan reasoned about whichsrc/module changed but not about which test file could actually reach it.
What caused friction (user side)
- Nothing that cost time — and one intervention worth naming as a model.
When the redaction
ask_userpresented four options, the reply was "I don't know yet, let's discuss more free-form for now," followed by a question about what a downstream package could do and whether a common low-hanging implementation existed. That refusal to pick from a bad menu is what surfaced key-name masking. The generalizable lesson is agent-side, not user-side: an option set is itself a design artifact and can be wrong in ways none of its options reveal.
Diagnostic details
- Model-performance correlation — planning and TDD ran on
anthropic/claude-opus-5, ship onanthropic/claude-sonnet-5, retro onanthropic/claude-opus-5; that split matches task weight (judgment-heavy design and implementation on the stronger model, deterministic push/CI/merge choreography on the cheaper one). Two subagents:tidy-first-assessorandpre-completion-reviewer, both judgment tasks, both appropriately modelled. The session's model-change log also records switches toopencode-go/deepseek-v4-flash,anthropic/claude-fable-5, andanthropic/claude-haiku-4-5, which fall in the session's earliest segment — outside the readable transcript window, so which turns they ran could not be confirmed. Worth checking if planning quality ever regresses: the initial issue triage is judgment-heavy and is exactly where a flash-tier model would hurt. - Escalation-delay tracking — no
rabbit-holefriction points, so no escalation delay to measure. The longest same-topic sequence was five calls polling the release PR'sstatusCheckRollup; that was correct blocking behavior on a genuinely in-progress check, not a stall. - Unused-tool detection —
colgrepwas available and never invoked. Most exploration was exact-symbol tracing (safeJsonStringify,appendFileSync,writeJsonFileAtomic) wheregrepis the correct choice per thecolgrepskill's own decision table. The exception is the question that drove the whole design — "where does tool input get serialized on the way to the log?" — which is intent-shaped and was answered by manually reading four files. - Feedback-loop gap analysis — no gap.
pnpm run checkplus the full package suite ran after every one of the five code-bearing TDD steps, not just at the end; each cycle confirmed a red before implementing;pnpm run lintran at baseline, after the extraction, after the docs commit, and again pre-push. The one gate that ran only once waspnpm pack, and it was the one that found a bug — an argument for running it whenever a shipped doc gains a link.
Changes made
.pi/prompts/plan-issue.md— appended a sentence to "Gather context" step 6: when a change introduces a mechanism a mature ecosystem already standardizes, check what established libraries do before building theask_useroption set. Step 6 already covered internal convention discovery (sibling packages, SDK terms); this is its external analog.AGENTS.md— added a sentence to the docs-in-distribution convention: a link from a shipped doc into a non-shipped path resolves to nothing in the tarball, so use an absolute GitHub URL or add the target tofiles.packages/pi-permission-system/docs/retro/0647-owner-only-log-modes-key-name-redaction.md— this Final Retrospective stage entry.
Considered and deliberately not landed: a prompt nudge to actually load the six skills /plan-issue lists (compliance failure, not a clarity failure), another "when a step changes X, grep Y" rule for the missed test file (that section already carries ~20), and a rule about running pnpm pack routinely (AGENTS.md already directs it; the real gap was link direction, which change 2 covers).