---
issue: 644
issue_title: "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'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 skip** — `loadAndMergeConfigs` 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.0`→`22.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).
