--- issue: 647 issue_title: "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 of `task-approval` and 275 of `task-user`; `xox` matched 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 `writeLine` in `logging.ts`. Reading `permission-prompter.ts:135` showed `toolInputPreview` arrives there already flattened to a string by `serializeToolInputPreview`, 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 `registerLogRedactor` seam 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_assignment` in the existing tree-sitter parse would extend coverage to `FOO_TOKEN=abc deploy` with 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.ts` and `test/tool-preview-formatter.test.ts` mock `safeJsonStringify` by **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 under `tsc`. Separately, `safeJsonStringify`'s cycle / `Error` / `bigint` handling 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 384` and `expected 493 to be 448` — that is `0o644` and `0o755`, 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 `writeLine` alone would have left the reporter's own repro unfixed, because `getToolInputPreviewForLog` flattens 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 bypasses `writeLine`. - **The `vi.mock` partial-module trap fired exactly where the testing skill warns.** `test/tool-preview-formatter.test.ts` replaced `#src/json-safe-stringify` with a literal factory exporting only `safeJsonStringify`, which would have blanked out the `createJsonSafeReplacer` that `log-redaction.ts` builds on. Fixed with an `importActual` spread. Three existing `formatGenericToolInputForLog` tests 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` / `bigint` handling 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]`, because `seen` entries are never released. - **Deviation: `test/extension-config.test.ts` was touched but not named in the plan.** The logs-directory mode assertions had to live there, because `ensurePermissionSystemLogsDirectory` is in `src/extension-config.ts` and `test/logging.test.ts` supplies its own `ensureLogsDirectory` callback, 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.md` and `troubleshooting.md` ship 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 in `docs/subagent-integration.md` rather than adding `docs/decisions` to the `files` allowlist, which would ship ten internal design records to serve one user-facing reference. Verified with `pnpm pack` + `tar tzf`. - **The permission gate caught an agent mistake mid-session.** An `Edit` call dropped the `pi-packages/packages/` prefix from a path; the `external_directory` gate 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 `isSensitiveLogKey` against every real key name the package logs and found no false positive — including confirming that the bash parser's internal `token` field 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 of `task-approval`, `xox` inside 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 384` is `0o644` versus `0o600`, 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:135` showed `toolInputPreview` arrives 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 pack` caught 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 `Edit` dropped the `pi-packages/packages/` prefix and the `external_directory` gate refused it, naming the correct path. A live demonstration of the subject matter, mid-implementation. #### What caused friction (agent side) - `missing-context` — the first redaction `ask_user` offered 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's `redact`, 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 extra `ask_user` round-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-issue` directs loading the `colgrep` skill before code exploration and the `design-review` skill before finalizing any design that extracts or changes shared interfaces. Neither was loaded; the plan extracted `safeJsonStringify` into a new module, which is squarely a `design-review` trigger. Impact: no rework. Exploration was mostly exact-symbol tracing where `grep` was the right tool, and the `tidy-first-assessor` plus the pre-completion reviewer independently covered the structural ground `design-review` would have. The gates were skipped without consequence, which is precisely why it is worth noting. - `other` — an `Edit` call constructed the path `/Users/chris/development/pi/pi-permission-system/test/extension-config.test.ts`, dropping the `pi-packages/packages/` segment. Impact: one rejected tool call, corrected immediately; zero rework. The `external_directory` gate 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 name `test/extension-config.test.ts`, which had to absorb the logs-directory mode assertions because `test/logging.test.ts` stubs `ensureLogsDirectory` and cannot exercise the real one. Impact: a noted deviation, no rework. The plan reasoned about which `src/` 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_user` presented 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 on `anthropic/claude-sonnet-5`, retro on `anthropic/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-assessor` and `pre-completion-reviewer`, both judgment tasks, both appropriately modelled. The session's model-change log also records switches to `opencode-go/deepseek-v4-flash`, `anthropic/claude-fable-5`, and `anthropic/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-hole` friction points, so no escalation delay to measure. The longest same-topic sequence was five calls polling the release PR's `statusCheckRollup`; that was correct blocking behavior on a genuinely in-progress check, not a stall. - **Unused-tool detection** — `colgrep` was available and never invoked. Most exploration was exact-symbol tracing (`safeJsonStringify`, `appendFileSync`, `writeJsonFileAtomic`) where `grep` is the correct choice per the `colgrep` skill'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 check` plus 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 lint` ran at baseline, after the extraction, after the docs commit, and again pre-push. The one gate that ran only once was `pnpm pack`, and it was the one that found a bug — an argument for running it whenever a shipped doc gains a link. ### Changes made 1. `.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 the `ask_user` option set. Step 6 already covered internal convention discovery (sibling packages, SDK terms); this is its external analog. 2. `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 to `files`. 3. `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).