12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 712 | pi-permission-system: yolo mode prompts for wrapper-floored and unparseable bash asks |
Retro: #712 — pi-permission-system: yolo mode prompts for wrapper-floored and unparseable bash asks
Stage: Planning (2026-08-14T21:50:48Z)
Session summary
Traced the reported bug (yolo prompting for wrapper-floored and unparseable bash asks) through resolveBashCommandCheck → GateRunner, then reproduced it live with a throwaway composition-root spike that ran the real factory under yoloMode: true and captured ui.select titles.
The spike confirmed both reported cases and surfaced a third, yolo-independent defect: the <unparseable-bash-command> branch never consults the resolver, so an explicit bash deny is masked into an approvable prompt.
Wrote docs/plans/0712-yolo-residual-synthetic-asks.md — four TDD cycles (deny consult, gate-level yolo grant, end-to-end repro pin, docs) shipping independently.
Observations
- The issue is third-party (
maertayn) and re-files #570, which was closed NOT_PLANNED for provenance, not merit. Theask_usergate confirmed all three open decisions at once: fix it, place the reconciliation at theGateRunnerchoke point, fold the deny-masking fix into the same plan. - Measurement beat argument: the spike (
makeFakePi+ real factory + a UI ctx that records prompt titles) produced the exact prompt string from the issue, and probing for a genuinely unparseable command showedcat <<'EOF'parses fine while> out.txtand2>&1hit the sentinel. Both facts are in the plan as measured rows, not inferences. - Design tension named in the plan:
docs/architecture/architecture.md§ "yolo is recorded authority" claims the decision path loses all yolo knowledge, andPermissionPrompter's docstring claims noaskreaches it under yolo. Both are false today; the floors are per-parse, not per-pattern, so no rules-only fix exists and the doc claims must be amended. - Blast radius of the runner-level catch-all was enumerated rather than assumed: every
preChecksource already flows through the yolo-rewritten resolver,synthesizeDefaultsguarantees theevaluate()builtin fallback never surfaces, and onlydescribeSkillReadGate'spreResolvedcan carry a non-rulesetask(a stale skill entry after a mid-session yolo toggle). - Rejected alternatives recorded: reconciling inside
resolveBashCommandCheck(three-layer parameter relay, contract still unenforced) and selecting an auto-approvingTerminalAuthorizerunder yolo (breaks the singleauto_approvedreview-entry parity from #526). - Deferred without filing: yolo parity on the advisory path (
resolveBashAdvisoryCheck). The discrepancy is in the safe direction — advisory stricter than the gate — and no known consumer depends on it. - Two existing assertions (
expect(resolver.resolve).not.toHaveBeenCalled()inbash-command.test.tsandbash-advisory-check.test.ts) invert with the deny consult; the implementation session should expect that, not treat it as a regression.
Stage: Implementation — TDD (2026-08-14T22:07:49Z)
Session summary
Landed one tidy-first preparatory commit plus the plan's three TDD cycles and the doc commit: the unparseable branch now resolves the whole command and returns an explicit deny before synthesizing its sentinel ask, and GateRunner grants any residual ask under yolo through the new pure resolveYoloGrant helper, wired from a single isYoloEnabled reader in index.ts shared with PermissionManager.
Five composition-root tests drive the real factory over the issue's literal repro (git status | xargs grep foo and > out.txt), covering yolo-on, yolo-off, and explicit-deny.
The pi-permission-system suite went 2769 → 2784 tests; check, root lint, and pnpm fallow dead-code are green.
Observations
- The
tidy-first-assessorfound one Recommended prep:resolveBashCommandCheckalready resolved the whole command inline at two sites and the fix would have added a third, soresolveWholeCommandwas extracted first (refactor:). That is the only deviation from the plan's file list, and it made the step-1 diff a single call. The assessor's Optional item (a shared() => falsereader across the threeGateRunnertest fixtures) was declined as the plan predicted. - The plan's two predicted assertion inversions (
expect(resolver.resolve).not.toHaveBeenCalled()inbash-command.test.tsandbash-advisory-check.test.ts) landed exactly as described; no other existing assertion moved, and the #526 yolo-origin runner test was left untouched to hold review-log parity. - The advisory path inherits the deny consult for free (it shares
resolveBashCommandCheck), so a denied unparseable command now reportsdenythere too — an extra test pins it. The advisory path's yolo discrepancy remains deferred and unfiled per the plan's Open Questions. - Pre-completion reviewer: WARN (no FAILs).
Finding 1 — the
runner.tsmodule-tree entry cited#712as bare provenance; fixed by rewording to the constraint itself ("the sole place a post-resolution ask is reconciled with yolo") and amended into the docs commit. Finding 2 — the plan's deferred advisory-parity question carries no issue number; left as an accepted, reasoned deferral recorded in the plan. - Reviewer confirmed the #452 fail-closed, #481/#490 wrapper-floor, and #526 parity invariants survive by diff, not prose.
Stage: Final Retrospective (2026-08-15T00:35:32Z)
Session summary
One Pi session carried #712 from planning through ship: a third-party bug report was verified with a live composition-root spike, planned as four cycles, implemented with one tidy-first preparatory commit, and released as pi-permission-system@25.2.1.
The spike found a second, unreported defect (an explicit bash deny masked by the <unparseable-bash-command> synthetic ask), which became the first TDD cycle and a prerequisite for the yolo grant.
Suite went 2769 → 2784 tests; both CI runs (push and release) were green, and the issue closed with a behavior summary.
Observations
What went well
- The planning-time spike was an instrument, not a formality.
Running the real factory through
makeFakePiwith aui.select-recording ctx reproduced the issue's exact prompt string, then a ten-command probe batch established which inputs actually reach the unparseable branch (> out.txtand2>&1do;cat <<'EOF',((1+1)), andarr=(1 2 3)all parse normally). The same harness then exposed the deny-masking hole — an adjacent defect the report never mentioned — exactly as #493's live repro exposed #507. - The
tidy-first-assessorbeat the plan's own design review. The plan ran thedesign-reviewchecklist and still missed thatresolveBashCommandCheckalready inlined the same five-field whole-command resolve twice and the fix would add a third; the assessor caught it from the upcoming diff and the extraction landed first (2e9f6db2), turning cycle 1's change into a one-line call. - Bundling the third-party gate paid off: direction, placement, and the deny-masking scope question went into a single
ask_usercall after a measured-evidence message, and no follow-up question was needed for the rest of the session. - Every predicted breakage landed as predicted — both
expect(resolver.resolve).not.toHaveBeenCalled()inversions and the #526 parity test staying untouched — so the TDD stage produced no unplanned rework.
What caused friction (agent side)
other— the first spike run printed nothing: Vitest's default reporter hidesconsole.logfrom passing tests, so the measurement had to be re-run with reporter flags. Impact: one wasted run plus one re-run; no rework. A follow-up measurement this session pinned the actual cause —--silent=falsealone still hides the log;--reporter=verboseis what surfaces it.instruction-violation(self-identified) — thearchitecture.mddoc edit carried strayoldText2/newText2keys in oneedits[]entry, the exact trapAGENTS.md§ Edit tool batches documents. Impact: none — the keys were empty and all four intended blocks applied, verified by counting reported blocks against intended edits. Evidence the rule is correct but low-salience mid-flow.missing-context— the newresolveYoloGranttest block was written against invented fixture names (makeAllow/makeAsk) instead of the buildershelpers.test.tsalready uses, and the correctiveEditthen failed to match becausepi-autoformathad reflowed the just-written block. Impact: three extra tool calls (rejected edit → re-read → five-entry corrective batch). Both halves are documented rules — check the file's existing conventions first, and re-read a region you just edited.other— the composition-root tests were appended with a shell heredoc, which bypasses thepi-autoformathook that fires onEdit/Write;pnpm run lintthen failed on formatting and neededpnpm exec biome check --write. Impact: one failed lint plus one fix call. The "no heredoc" rule exists in the repo but is scoped to markdown.other— a brief false start at ship time ("need to check where #737's commits end") over the release range, self-corrected in the same turn. Impact: one extra tool call.other— aReadcall used a doubled package path (pi-packagesdropped), which the extension under test denied with a corrective message. Impact: one wasted call; mildly instructive thatexternal_directorycaught it.
What caused friction (user side)
- Nothing material.
The three
ask_useranswers were decisive and unblocked the whole session; the operator's involvement was strategic (direction, placement, scope) rather than mechanical. - Small opportunity: #712 re-files a NOT_PLANNED issue whose "verified patch" lives on a fork. An upfront steer ("treat the linked patch as reference, not a merge candidate") would have saved fetching #570's body — one tool call — though the issue body did carry the provenance.
Diagnostic details
- Model-performance correlation — the plan/TDD/ship turns ran on
anthropic/claude-sonnet-5; the retrospective stage onanthropic/claude-opus-5. Both subagents (tidy-first-assessor,pre-completion-reviewer) declareanthropic/claude-sonnet-5and both did judgment-heavy work (preparatory-refactor design, invariant verification by diff). No mismatch in either direction. - Escalation-delay tracking — no
rabbit-holefriction points; the longest streak on a single error was two calls (the rejectedEdit), far below the five-call escalation threshold. - Unused-tool detection — no
Exploredispatch was warranted: the issue supplied a numbered source trace, which the plan prompt keeps inline.colgrepwent unused because every hunt was exact-symbol (state: "ask",new GateRunner,<indirection-bash-wrapper>) — the case thecolgrepskill's decision table assigns togrep. - Feedback-loop gap analysis — verification was incremental, not end-loaded: per-cycle
vitest run <file>,pnpm run checkbefore each commit, the full package suite after cycles 1 and 2, and rootlint+fallow dead-codeboth at the end of the TDD stage and again as pre-push gates. The one gap was formatting, caught only by the end-of-cycle lint (friction point 4).
Changes made
AGENTS.md§ Tool-injected messages — recorded thatpi-autoformatfires onEdit/Writeonly, so a heredoc-appended source file skips formatting and failspnpm run lint..pi/skills/package-pi-permission-system/SKILL.mdDebugging rule 5 — widened the live-repro trigger from a claimed bypass to any report of a concrete prompt or decision the gate should not have produced, citing this issue alongside #493/#507..pi/skills/testing/SKILL.md— replaced the spike-output guidance with the measured fix (--reporter=verbose;--silent=falsealone does not surface the log), keeping file-writing for output that must outlive the run.