--- issue: 350 issue_title: "~ and $HOME patterns footgun" --- # Retro: #350 — ~ and $HOME patterns footgun ## Stage: Planning (2026-06-08T19:40:13Z) ### Session summary Diagnosed the reported footgun: path **patterns** are home-expanded by `compileWildcardPattern` (via `expandHomePath`), but tool-call and bash path **values** flow through `normalizeInput` raw, so a `~/.ssh/config` value never matches a `~/.ssh/*` deny rule — a silent permission bypass. Produced a numbered plan (`docs/plans/0350-home-expand-path-values.md`) with two coordinated fixes that both reuse the existing `expandHomePath`, plus TDD cycles and doc updates. ### Observations - Root cause is asymmetry, not a missing feature: expansion happens on one side of the match only. The fix is to home-expand path **values** symmetrically at the single choke point, `normalizeInput`. - Both `describePathGate` and `bash-path.ts` route through `permissionManager.checkPermission` → `normalizeInput`, so one change in `normalizeInput` fixes the cross-cutting `path` surface for tool calls **and** bash, plus per-tool path patterns. - Decision (`ask_user`): code fix, not docs-only — this is an under-matching `deny` bypass, the worst failure mode for a least-privilege gate; the docs example (`~/.ssh/*`) is correct intent. - Decision (`ask_user`): home-expand values **only**, not full cwd-canonicalization. Patterns are not cwd-resolved today (so glob patterns like `*.env` match anywhere); home-expand-only keeps that and avoids regressing relative patterns. - Secondary fix included: `normalizePathForComparison` currently expands `~` but not `$HOME`; routing it through `expandHomePath` brings the `external_directory` surface (and bash external-path / skill-read) to `$HOME` parity. Flagged in Open Questions as splittable if review wants tighter scope. - Existing tests stay green: current `input-normalizer.test.ts` and `external_directory` integration cases use non-home or already-absolute values, which `expandHomePath` leaves untouched. No existing assertion needs flipping; the change only adds previously-missing matches. - Home-expansion tests must mock `node:os` (`vi.hoisted` + `vi.mock` with a `default` key) as in `expand-home.test.ts`. ## Stage: Implementation — TDD (2026-06-08T19:53:29Z) ### Session summary Executed all 4 TDD cycles from the plan in a single session, then added a fifth `refactor:` commit (out of plan) consolidating path-surface value normalization. Two production files changed (`src/path-utils.ts` and `src/input-normalizer.ts`), adding 24 new tests across 5 test files. Test count grew from 1813 to 1837 (+24). ### Observations - **Step 1 deviation** — After dropping the inline `~/` expansion block from `normalizePathForComparison`, the unused `homedir` import was correctly dropped, but `join` was accidentally removed from the same `node:path` import line. Caught immediately by the red run (4 `ReferenceError: join is not defined` failures) and fixed before the green commit. - The `SPECIAL_PERMISSION_KEYS` branch in `normalizeInput` already used `pathValue ?? "*"` (nullish coalescing), so the null guard required by the plan (`pathValue === null ? "*" : expandHomePath(pathValue)`) was a natural replacement; no logic change was needed beyond adding the expansion call. - Integration tests in `permission-manager-unified.test.ts` confirmed that 3 of the 6 new home-expansion cases were already passing before Fix 2 (the ones that used `homedir()` directly as an already-absolute path). Only 3 tests were red before the production change: raw `~/...`, raw `$HOME/...`, and per-tool `~/...` — exactly the reported bug surface. - The bash parser's `resolveNodeText` returns `$HOME` as the literal text of a `simple_expansion` node, so `cat $HOME/.ssh/config` produces the token `"$HOME/.ssh/config"` — the gate characterization test for that token is valid. - **Out-of-plan refactor (user-requested)** — After the plan steps, review surfaced near-duplicate path-value handling in `normalizeInput` (the two path branches each did `extract → home-expand → fallback to "*"`). Per a `full consolidation` `ask_user` decision, extracted a private `normalizePathSurfaceValue(input)` helper owning that shared concern. This unified extraction on `getNonEmptyString` (was a raw `typeof === "string"` check in the special-keys branch), a deliberate small behavior change: the `path` / `external_directory` surfaces now coerce empty/whitespace-only paths to `"*"` and trim before matching — matching the path-bearing tools' prior behavior. Covered by 3 new tests; `getPathBearingToolPath` import dropped from `input-normalizer.ts` (still has 3 live gate consumers, so no dead-code regression). - Pre-completion reviewer: **PASS** (re-dispatched after the refactor) — no warnings issued in either run. ## Stage: Final Retrospective (2026-06-08T20:18:44Z) ### Session summary Shipped issue #350 end-to-end across four stages (plan → TDD → ship → retro) in one continuous session, releasing `@gotgenes/pi-permission-system` v10.5.2. The fix home-expands path *values* (`~/…`, `$HOME/…`) before matching, closing a silent `deny`-bypass; a mid-implementation user question prompted an in-scope `refactor:` consolidation (`normalizePathSurfaceValue`) that was correctly re-reviewed. Clean execution overall — three minor self-caught slips, one of which (a fabricated CI SHA) cost ~125s. ### Observations #### What went well - **Mid-stream scope expansion handled with discipline** — when the user asked "is there a broader improvement?" after the pre-completion reviewer had already passed, the response separated the right-sized consolidation from gold-plating (explicitly rejected table/registry dispatch citing the `code-design` skill), used `ask_user` for the scope decision, ran the refactor as its own red→green TDD cycle, and re-dispatched the `pre-completion-reviewer` because the refactor carried a behavior change. This is the intended way to absorb a late design request without abandoning workflow rigor. - **Root-cause analysis validated by the red phase** — the plan predicted exactly which integration cases were already passing; the TDD red run confirmed precisely 3 of 6 home-expansion cases red (raw `~/…`, raw `$HOME/…`, per-tool `~/…`), matching the asymmetry diagnosis. - **Incremental verification caught a bug at the cheapest point** — the `join` import slip surfaced immediately from the per-file `vitest` run after the green edit, not at the end-of-step full suite. #### What caused friction (agent side) - `instruction-violation` (self-identified) — in ship step 4, `ci_find` was called with a fabricated full SHA (`37f52fdd8e5d…`) expanded by guess from the 8-char short SHA in the `git push` output, instead of running `git rev-parse HEAD` first as the prompt's parenthetical instructed. The real HEAD was `37f52fddd458…` (diverges after the shared 8-char prefix). Impact: one `ci_find` timed out after ~125s before the SHA was corrected and the run was found. - `other` (self-identified) — in TDD step 1, removing the inline `~` block from `normalizePathForComparison` also dropped `join` from the shared `node:path` import, though `isPiInfrastructureRead` still uses it. Impact: 4 `ReferenceError` failures on the next per-file run, fixed in 2 extra tool calls before the green commit; no follow-up commit needed. - `other` (self-identified) — appending the TDD stage notes to this retro duplicated the bash-parser observation line (the `Edit` was anchored on a content line, not the file's last line as the prompt advises). Impact: 2 extra tool calls to detect and remove; caught before the commit. #### What caused friction (user side) - The near-duplicate path-value handling was visible in the plan's Design Overview (Fix 1 showed both branches with identical `… ? "*" : expandHomePath(…)` logic), but the duplication question surfaced only after TDD and the first pre-completion PASS. Raising it at plan review would have folded `normalizePathSurfaceValue` into the original TDD cycles and avoided a second reviewer dispatch — an opportunity for earlier signal, not a fault. ### Diagnostic details - **Model-performance correlation** — model selection tracked task complexity cleanly: planning and the two judgment-heavy interludes (the design conversation, this retro) ran on `claude-opus-4-8`; the mechanical TDD and ship stages ran on `claude-sonnet-4-6`. Both `pre-completion-reviewer` subagent dispatches returned PASS. No reasoning-weak-on-judgment or high-cost-on-mechanical mismatch. - **Feedback-loop gap analysis** — verification ran incrementally throughout, not just at the end: `pnpm run check` immediately after the shared-normalizer change (as the plan required), per-file `vitest` after every red/green, and full `test` + `check` + `lint` + `fallow dead-code` after both the last TDD step and the refactor. No gap; this is the pattern that caught the `join` slip early. - **Escalation-delay tracking** and **unused-tool detection** — nothing notable; no `rabbit-hole` friction, no error exceeded 2 consecutive tool calls, and no `Explore`/`colgrep`/`web_search` gap (exact-symbol searches correctly used `grep` per the `colgrep` decision table). ### Changes made 1. `.pi/prompts/ship-issue.md` — step 4 now leads with an explicit `git rev-parse HEAD` action and a caution to never hand-expand the short SHA from `git push` output or type a SHA from memory; subsequent items renumbered (1–5). 2. `packages/pi-permission-system/docs/retro/0350-home-expand-path-values.md` — added this Final Retrospective stage entry.