9.5 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 644 | pi-permission-system: project policy is loaded without checking project trust |
Retro: #644 — pi-permission-system: project policy is loaded without checking project trust
Stage: Planning (2026-06-13T00:00:00Z)
Session summary
Planned the ADR-0001 implementation: gate project-scoped config loading on ctx.isProjectTrusted().
This is a third-party issue (author marcoscale98), so the ask_user direction gate was mandatory; the operator confirmed implementing the ADR direction, covering both untrusted load paths, and loudly warning the user on skip.
Produced a 3-cycle TDD plan (docs/plans/0644-gate-project-config-on-trust.md) and committed it.
Observations
- Source review surfaced a hole the issue and ADR-0001 did not name: untrusted project config leaks through two independent cwd-keyed paths, not one.
The ADR only covers the permission policy (
PermissionManager.configureForCwd); the extension runtime config path (ConfigStore.refresh→loadAndMergeConfigs) also merges the project'sconfig.json, includingyoloMode: true— arguably the worse hole. Operator chose to gate both. - Design reuses existing levers where possible: passing
undefinedcwd toconfigureForCwdalready yields global-only policy (viaderivePolicyLoaderOptions), so the policy path needs no new manager code. The runtime path needs an explicitincludeProjectScopeflag, not an empty cwd —getProjectConfigPath("")resolves relative toprocess.cwd(), which would defeat the gate. - Chose a required (no-default)
projectTrustedparameter at every internal seam so TypeScript forces a conscious trust decision — no unsafe "trusted by default" fallback. The signature cascade (config-store → session → handler → index.ts) is compile-coupled, so the gate + all consumer/test updates land in one commit (cycle 2), per the lift-and-shift rule. - Verified
ctx.isProjectTrusted()exists onExtensionContextin@earendil-works/pi-coding-agent@0.79.1.resources_discoverhandlers do receive(event, ctx);index.tscurrently drops the ctx arg — cycle 2 wires it. #646fail-closed clamp does not interact: an untrusted project's config is never loaded, soprojectConfig.invalidnever fires. No regression.- Breaking change (
fix!) → next major (package.json already at 21.0.0, release-please manifest ahead). Not in any roadmap batch → ship independently. - Deferred (Open Questions, no follow-up filed): reload path re-reading runtime config on trust grant (safe interim = global-only runtime); surfacing trust state in
/permission-systemUI.
Stage: Implementation — TDD (2026-07-24T18:00:00Z)
Session summary
Implemented the trust gate across three TDD cycles plus one tidy-first prep commit: (1) loadAndMergeConfigs gained an includeProjectScope option; (2) the required projectTrusted boolean cascade through ConfigStore.refresh / PermissionSession.{refreshConfig,resetForNewSession,reload} / the two lifecycle handlers / index.ts, plus the loud warn + project_trust.skipped review-log entry; (3) docs (ADR-0001 status, configuration.md, README.md, new migration note).
Test count went from 2555 to 2570 (+15); all green, check/lint/fallow clean.
Observations
- Plan miss caught by
tsc— the plan's Module-Level Changes did not enumerate two additional project-config load sites:before_agent_startalso callsrefreshConfig(a mid-session runtime-config reload that would have re-leaked an untrusted project'syoloModeright before agent start), and the factory-initconfigStore.refresh()inindex.ts. The required-parameter cascade madetscsurface both immediately. Both are now gated (before_agent_startonctx.isProjectTrusted(), no re-warn; factory-init withholds the project scope with(undefined, false)since no trust decision exists yet). Grepped everyrefresh/configureForCwdcall site to confirm no ungated path remains. Deviation documented in thefix!commit body. - Tidy-first paid off — the assessor's one recommendation (extract
makeBaseCtxincomposition-root.test.ts) turned a five-placeisProjectTrustededit into one. Two more hand-built ctx objects outside that file (session-start.test.ts,permission-events.test.ts) still needed the field; the latter only surfaced at full-suite runtime (ctx.isProjectTrusted is not a function), nottsc— a reminder that hand-rolled ctx literals dodge the type check. - End-to-end proof — the composition-root pair (untrusted project
bash: allowdoes NOT override globalbash: deny; trusted DOES) exercises the whole gate through the real service, the strongest evidence the fix holds. extractedProjectempty on skip —loadAndMergeConfigsreturnsproject: {}when the scope is withheld, soMergedConfigResult.projectstays honest for downstream readers.- Pre-completion reviewer: PASS (one non-blocking WARN, now fixed).
Reviewer warnings
- WARN (fixed): the
README.md"Upgrading" heading read21.0.0, but 21.0.0 already released (#646); relabeled to22.0.0(next major) and updated the in-page anchor. Amended into the docs commit before ship.
Stage: Final Retrospective (2026-07-24T19:00:00Z)
Session summary
One continuous session carried #644 through planning, TDD implementation, and ship: a third-party security bug (untrusted project config could loosen global policy) gated behind ctx.isProjectTrusted() across both config-load paths, released as pi-permission-system-v22.0.0 (breaking major).
The ship phase was clean end-to-end — lint/fallow/CI green, issue closed with a curated comment, and the release-please PR merged after correctly waiting out an in-progress check.
Observations
What went well
- Ship-runbook
UNSTABLEdisambiguation held.release_pr_mergerefused PR #650 withmerge_state: UNSTABLE;statusCheckRollupshowed acheckrun stillIN_PROGRESS(not the empty-rollupGITHUB_TOKENcase). The runbook's three-branch rule was applied correctly — waited viaci_watchfor the check to finish, then retriedrelease_pr_merge(rather than falling back togh pr mergewhile a check was running). Merge succeeded,v22.0.0tagged,publishjob green. - Required-parameter design as a completeness check.
Choosing a required (no-default)
projectTrustedat every seam turned two unenumerated call sites (before_agent_start, factory-initconfigStore.refresh()) from a silent security gap intotscerrors during cycle 2 — an optional-with-default param would have compiled and shipped the hole. The design choice paid a concrete safety dividend. - Pre-completion reviewer caught a real doc slip.
The
21.0.0→22.0.0version-label WARN would have misdirected readers post-release; fixed and amended before ship. The version prediction (22.0.0) was then confirmed by the release-please PR body.
What caused friction (agent side)
missing-context— a hand-built ctx literal intest/permission-events.test.ts(cast toExtensionContext) lackedisProjectTrusted, so it slippedtscand failed only at the full-suite run (ctx.isProjectTrusted is not a function). 18 test files hand-roll ctx literals viaas unknown as ExtensionContext/as never; only 4 needed the new field, and the casts hide the gap from the type check. Impact: one extra fix cycle during TDD (a full-suite runtime failure after the affected-file cycle passed); no shipped defect.missing-context(plan-time) — the plan's Module-Level Changes did not enumerate thebefore_agent_startand factory-initrefreshConfigcall sites, despite thetestingskill's rule to list every file in a threaded-parameter chain. Impact: none in the end — the required-param cascade madetscsurface both, folded into the same commit; but the miss was a latent security gap that only the design choice caught.
What caused friction (user side)
- None.
The one preference-sensitive gate (third-party direction + scope) was resolved cleanly at plan time via
ask_user; the rest ran without correction.
Diagnostic details
- Feedback-loop gap — verification cadence was otherwise good (per-cycle
vitest,checkafter interface changes, full suite +check+lint+fallowat cycle end), but thepermission-eventsctx failure surfaced only at the full-suite run, not the cycle-scoped file run — because it lives in a test file outside the changed cycle's affected set. This is the hand-built-ctx hazard above, not a verification-timing miss; the fix is a grep discipline (proposed below), not more-frequent test runs. - Model-performance correlation — session ran on a mix of
anthropic/claude-opus-4-8andanthropic/claude-sonnet-5; thetidy-first-assessorandpre-completion-reviewersubagents ran on their frontmatter-pinned models. No reasoning-weak-on-judgment or high-cost-on-mechanical mismatch. The reviewer's long wall-clock (~37 min, 45 tool uses) was thorough judgment work, appropriately modeled — not a mismatch. - Escalation-delay / unused-tool — no rabbit-holes; no >5-call error loops;
grep/colgrepand both bracketing subagents used where appropriate. Nothing notable.
Changes made
.pi/skills/package-pi-permission-system/SKILL.md— added a Testing-section rule: when a change reads a newExtensionContextfield/method, updatemakeCtxand grep every hand-built ctx literal (grep -rln "hasUI:" test/), since theas unknown as ExtensionContext/as nevercasts bypasstscand fail only at the full-suite run (#644 evidence).