Files
my-pi/pi-permission-system/docs/retro/0644-gate-project-config-on-trust.md
T

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.refreshloadAndMergeConfigs) also merges the project's config.json, including yoloMode: true — arguably the worse hole. Operator chose to gate both.
  • Design reuses existing levers where possible: passing undefined cwd to configureForCwd already yields global-only policy (via derivePolicyLoaderOptions), so the policy path needs no new manager code. The runtime path needs an explicit includeProjectScope flag, not an empty cwd — getProjectConfigPath("") resolves relative to process.cwd(), which would defeat the gate.
  • Chose a required (no-default) projectTrusted parameter 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 on ExtensionContext in @earendil-works/pi-coding-agent@0.79.1. resources_discover handlers do receive (event, ctx); index.ts currently drops the ctx arg — cycle 2 wires it.
  • #646 fail-closed clamp does not interact: an untrusted project's config is never loaded, so projectConfig.invalid never 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-system UI.

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_start also calls refreshConfig (a mid-session runtime-config reload that would have re-leaked an untrusted project's yoloMode right before agent start), and the factory-init configStore.refresh() in index.ts. The required-parameter cascade made tsc surface both immediately. Both are now gated (before_agent_start on ctx.isProjectTrusted(), no re-warn; factory-init withholds the project scope with (undefined, false) since no trust decision exists yet). Grepped every refresh/configureForCwd call site to confirm no ungated path remains. Deviation documented in the fix! commit body.
  • Tidy-first paid off — the assessor's one recommendation (extract makeBaseCtx in composition-root.test.ts) turned a five-place isProjectTrusted edit 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), not tsc — a reminder that hand-rolled ctx literals dodge the type check.
  • End-to-end proof — the composition-root pair (untrusted project bash: allow does NOT override global bash: deny; trusted DOES) exercises the whole gate through the real service, the strongest evidence the fix holds.
  • extractedProject empty on skiploadAndMergeConfigs returns project: {} when the scope is withheld, so MergedConfigResult.project stays honest for downstream readers.
  • Pre-completion reviewer: PASS (one non-blocking WARN, now fixed).

Reviewer warnings

  • WARN (fixed): the README.md "Upgrading" heading read 21.0.0, but 21.0.0 already released (#646); relabeled to 22.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 UNSTABLE disambiguation held. release_pr_merge refused PR #650 with merge_state: UNSTABLE; statusCheckRollup showed a check run still IN_PROGRESS (not the empty-rollup GITHUB_TOKEN case). The runbook's three-branch rule was applied correctly — waited via ci_watch for the check to finish, then retried release_pr_merge (rather than falling back to gh pr merge while a check was running). Merge succeeded, v22.0.0 tagged, publish job green.
  • Required-parameter design as a completeness check. Choosing a required (no-default) projectTrusted at every seam turned two unenumerated call sites (before_agent_start, factory-init configStore.refresh()) from a silent security gap into tsc errors 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.022.0.0 version-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 in test/permission-events.test.ts (cast to ExtensionContext) lacked isProjectTrusted, so it slipped tsc and failed only at the full-suite run (ctx.isProjectTrusted is not a function). 18 test files hand-roll ctx literals via as 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 the before_agent_start and factory-init refreshConfig call sites, despite the testing skill's rule to list every file in a threaded-parameter chain. Impact: none in the end — the required-param cascade made tsc surface 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, check after interface changes, full suite + check + lint + fallow at cycle end), but the permission-events ctx 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-8 and anthropic/claude-sonnet-5; the tidy-first-assessor and pre-completion-reviewer subagents 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/colgrep and both bracketing subagents used where appropriate. Nothing notable.

Changes made

  1. .pi/skills/package-pi-permission-system/SKILL.md — added a Testing-section rule: when a change reads a new ExtensionContext field/method, update makeCtx and grep every hand-built ctx literal (grep -rln "hasUI:" test/), since the as unknown as ExtensionContext / as never casts bypass tsc and fail only at the full-suite run (#644 evidence).