---
issue: 644
issue_title: "pi-permission-system: project policy is loaded without checking project trust"
---

# Gate project-scoped config loading on `ctx.isProjectTrusted()`

## Release Recommendation

**Release:** ship independently

This issue is not part of any architecture-roadmap batch — it is a standalone security fix.
It is a breaking behavior change (untrusted projects stop contributing project-scoped config on upgrade), so it cuts a major release on its own.

## Problem Statement

The globally installed extension loads project-scoped configuration from the current working directory without ever consulting `ctx.isProjectTrusted()`.
Because project (and project-agent) scopes have higher precedence than global, an untrusted repository can ship `.pi/extensions/pi-permission-system/config.json` that **loosens** an operator's global restrictions — e.g. flipping a global `bash: deny` to `bash: allow`, or setting `yoloMode: true` — before the user has granted project trust.
This is inconsistent with Pi's own trust model, which withholds project-local skills, prompts, and agents from untrusted directories.
ADR-0001 (`docs/decisions/0001-project-trust-adoption.md`) already confirmed the gap and scoped the fix; this issue is the deferred implementation.

## Goals

- When `ctx.isProjectTrusted()` is `false`, do **not** load any project-scoped configuration; only global and global-agent scopes participate in policy and runtime-config resolution.
- Cover **both** untrusted-project load paths that are keyed on `ctx.cwd`:
  1. Permission **policy** — `PermissionManager.configureForCwd` (project + project-agent `permission` blocks).
  2. Extension **runtime config** — `ConfigStore.refresh` → `loadAndMergeConfigs` (project `config.json` scalars: `yoloMode`, `permissionReviewLog`, `piInfrastructureReadPaths`, `shellTools`, `authorizerChain`, …).
- **Loudly warn** the user (UI notification + review-log entry) whenever project config is skipped because the project is untrusted, so the reduced-scope state is never silent.
- Preserve the existing trust-grant recovery: when the user grants trust after startup, Pi fires `resources_discover` with `reason: "reload"`, and the reload path re-reads trust and loads project policy.
- **Breaking:** this changes observable behavior on upgrade for untrusted directories with no user edit.
  Ship as `fix(pi-permission-system)!:` with a `BREAKING CHANGE:` footer.

## Non-Goals

- Registering a `project_trust` handler or deciding trust actively — the extension observes the decision via `ctx.isProjectTrusted()`, it is not a trust oracle (ADR-0001 alternative, rejected).
- Changing the merge model to a restrict-only project merge — the override semantics for trusted projects stay intentional (ADR-0001 alternative, rejected).
- Reloading the extension **runtime config** (`yoloMode` etc.) on the trust-grant `resources_discover reload`.
  Today `reload()` re-reads policy only, not runtime config; that asymmetry is pre-existing and its safe direction (global-only runtime until the next session start) is acceptable here.
  Tracked in Open Questions.
- Per-agent frontmatter tolerance and the fail-closed clamp for invalid higher-precedence scopes (#646) — untouched.
  An untrusted project's config is simply not loaded, so its validity is never evaluated.

## Background

Relevant modules and how they relate:

- `src/handlers/lifecycle.ts` — `SessionLifecycleHandler.handleSessionStart(event, ctx)` calls `session.refreshConfig(ctx)` (runtime-config path) then `session.resetForNewSession(ctx)` (policy path).
  `handleResourcesDiscover(event)` calls `session.reload()` on `reason: "reload"`.
  Both handlers receive an `ExtensionContext` from `pi.on(...)`; the resources_discover registration in `index.ts` currently drops the `ctx` argument.
- `src/permission-session.ts` — `PermissionSession.refreshConfig(ctx?)` delegates to `configStore.refresh(ctx)`; `resetForNewSession(ctx)` calls `permissionManager.configureForCwd(ctx.cwd)`; `reload()` calls `permissionManager.configureForCwd(this.context?.cwd)`.
- `src/permission-manager.ts` — `configureForCwd(cwd)` rebuilds the `FilePolicyLoader`.
  `derivePolicyLoaderOptions(agentDir, cwd)` already yields `projectGlobalConfigPath: undefined` / `projectAgentsDir: undefined` when `cwd` is `undefined`, so passing `undefined` cwd loads **global-only** policy.
  This is the existing lever the gate reuses.
- `src/config-store.ts` — `ConfigStore.refresh(ctx?)` calls `loadAndMergeConfigs(agentDir, cwd ?? "", EXTENSION_ROOT)` and normalizes the merged runtime config.
  Passing an **empty** cwd here is unsafe: `getProjectConfigPath("")` yields a relative `.pi/…` path that `existsSync` resolves against `process.cwd()` — so the runtime-config path needs an **explicit** project-skip flag, not an empty cwd.
- `src/config-loader.ts` — `loadAndMergeConfigs(agentDir, cwd, extensionRoot)` merges (1) legacy global, (2) legacy extension config, (3) new global, (4) legacy project policy, (5) new project config.
  Steps 4–5 are the project-scope steps to gate.
- `ctx.isProjectTrusted(): boolean` — verified present on `ExtensionContext` in `@earendil-works/pi-coding-agent@0.79.1` (`dist/core/extensions/types.d.ts`).
  It is a live call reflecting the current (including remembered/temporary) decision, so a stored ctx re-reports updated trust after a grant.

AGENTS.md / skill constraints that apply:

- Default to least privilege — when gating, the reduced-scope (global-only) state is the safe default.
- Keep schema, example config, `docs/configuration.md`, and `README.md` aligned — but this change adds **no** config field, so the schema is untouched.
- The published tarball ships `docs/*.md`, `docs/migration`, `README.md` — a migration note lands in `docs/migration/`.

## Design Overview

### Decision model

Read the trust decision **once** at the lifecycle boundary (the only place that owns `ctx`) and thread it as an explicit `projectTrusted: boolean` down each config-load operation.
The parameter is **required** (no default) at every internal seam, so TypeScript forces every call site to make the trust decision consciously — there is no silent "trusted by default" fallback for a security gate.

```typescript
// handlers/lifecycle.ts — handleSessionStart
const projectTrusted = ctx.isProjectTrusted();
this.session.refreshConfig(ctx, projectTrusted); // runtime-config path
this.session.resetForNewSession(ctx, projectTrusted); // policy path
this.session.logResolvedConfigPaths();
if (!projectTrusted) {
  this.logger.review("project_trust.skipped", {
    cwd: ctx.cwd,
    phase: "session_start",
  });
  this.logger.warn(UNTRUSTED_PROJECT_MESSAGE);
}
// … existing agent-name / policy-issue / serviceLifecycle.activate flow …
```

```typescript
// handlers/lifecycle.ts — handleResourcesDiscover(event, ctx)
if (event.reason !== "reload") return Promise.resolve();
const projectTrusted = ctx.isProjectTrusted();
this.session.reload(projectTrusted); // policy path
if (!projectTrusted) {
  this.logger.review("project_trust.skipped", {
    cwd: ctx.cwd,
    phase: "resources_discover",
  });
  this.logger.warn(UNTRUSTED_PROJECT_MESSAGE);
}
// … existing lifecycle.reload debug log …
```

`this.logger.warn(...)` is the existing user-facing warning channel (it is what surfaces policy issues today and calls the injected `notify` → `ctx.ui.notify`), so reusing it satisfies the "loud warning" requirement and is asserted the same way as policy-issue warnings.
The paired `logger.review(...)` entry records the skip in the permission review log for audit.

### Policy path — reuse the `undefined`-cwd lever

`PermissionSession` withholds the project cwd from the manager when untrusted:

```typescript
resetForNewSession(ctx: ExtensionContext, projectTrusted: boolean): void {
  this.permissionManager.configureForCwd(projectTrusted ? ctx.cwd : undefined);
  this.skillEntries = [];
  this.activate(ctx);
}

reload(projectTrusted: boolean): void {
  this.permissionManager.configureForCwd(
    projectTrusted ? this.context?.cwd : undefined,
  );
  this.skillEntries = [];
}
```

Passing `undefined` cwd makes `derivePolicyLoaderOptions` omit the project paths, so the loader resolves global-only — no new manager code, and it composes correctly with the #646 fail-closed clamp (an untrusted project's config is never read, so `projectConfig.invalid` never fires).
The session still `activate`s the real ctx (forwarding, normalizer, authorizer selection all bind to the true cwd) — only the **policy scope** is narrowed.

### Runtime-config path — explicit project-skip flag

`loadAndMergeConfigs` gains an options object; `ConfigStore.refresh` passes the trust decision through:

```typescript
// config-loader.ts
export function loadAndMergeConfigs(
  agentDir: string,
  cwd: string,
  extensionRoot: string,
  options: { includeProjectScope?: boolean } = {},
): MergedConfigResult {
  const includeProjectScope = options.includeProjectScope !== false;
  // … steps 1–3 (legacy global, legacy ext, new global) unchanged …
  if (includeProjectScope) {
    // step 4: legacy project policy
    // step 5: new project config
  }
  // …
}
```

```typescript
// config-store.ts — refresh(ctx, projectTrusted)
const mergeResult = loadAndMergeConfigs(this.deps.agentDir, cwd ?? "", EXTENSION_ROOT, {
  includeProjectScope: projectTrusted,
});
```

`includeProjectScope` defaults to `true`, so the existing `loadAndMergeConfigs` test callers (which omit it) are unaffected; only `ConfigStore.refresh` opts a project out when untrusted.
The `config.loaded` debug entry gains a `projectTrusted` field for traceability.

### Warning message

A module-level constant in `lifecycle.ts`:

```typescript
const UNTRUSTED_PROJECT_MESSAGE =
  "pi-permission-system: project is not trusted — skipping project-scoped " +
  "permission configuration. Only global policy applies. Grant project trust " +
  "to load this project's permission rules.";
```

### Signature cascade (why the gate lands in one commit)

Making `projectTrusted` required changes the signatures of `ConfigStore.refresh`, `PermissionSession.{refreshConfig,resetForNewSession,reload}`, and the two lifecycle handlers, plus the `index.ts` resources_discover registration.
These are a single compile-coupled chain (each caller is the sole consumer of the next), so the gate + all consumer + test updates land together — the TDD-plan lift-and-shift rule for a cascading signature change.

## Module-Level Changes

- `src/config-loader.ts` — add `options?: { includeProjectScope?: boolean }` (4th param) to `loadAndMergeConfigs`; guard steps 4–5 on it; update the function doc comment.
- `src/config-store.ts` — change `SessionConfigStore.refresh` (interface) and `ConfigStore.refresh` (impl) to `refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean)`; forward `{ includeProjectScope: projectTrusted }`; add `projectTrusted` to the `config.loaded` debug entry.
- `src/permission-session.ts` — `refreshConfig(ctx: ExtensionContext | undefined, projectTrusted: boolean)`, `resetForNewSession(ctx, projectTrusted)`, `reload(projectTrusted)`; withhold cwd from `configureForCwd` when untrusted; update the method doc comments.
- `src/handlers/lifecycle.ts` — add `UNTRUSTED_PROJECT_MESSAGE`; `handleSessionStart` reads `ctx.isProjectTrusted()`, threads it, warns + review-logs on skip; `handleResourcesDiscover(event, ctx)` gains the `ctx` param, reads trust, threads it into `reload`, warns + review-logs on skip.
- `src/index.ts` — update the `resources_discover` registration to `(event, ctx) => lifecycle.handleResourcesDiscover(event, ctx)`.
- `test/helpers/handler-fixtures.ts` — `makeCtx` adds `isProjectTrusted: vi.fn().mockReturnValue(true)` (default trusted preserves existing behavior; untrusted tests override).
- `test/handlers/lifecycle.test.ts` — update `configStore.refresh` / `resetForNewSession` assertions to the two-arg form; `handleResourcesDiscover` calls now pass `ctx`; add untrusted-path tests (skip + warn + review-log for both handlers).
- `test/permission-session.test.ts` — update `resetForNewSession` / `reload` / `refreshConfig` calls to pass the trust arg; add trusted-vs-untrusted `configureForCwd` assertions.
- `test/config-store.test.ts` — update `refresh` calls to the two-arg form; assert `includeProjectScope` is forwarded per trust.
- `test/session-start.test.ts` — the hand-built `mockCtx` gains `isProjectTrusted: () => true`.
- `test/composition-root.test.ts` — the ctx builders (`makeUiCtx`, `makeChildCtx`, `makeSessionApprovingCtx`, and inline ctx literals) gain `isProjectTrusted: () => true`; add one end-to-end untrusted test (global `bash: deny` survives an untrusted project `bash: allow`).
- `docs/decisions/0001-project-trust-adoption.md` — update Status from "Accepted — defer implementation to a follow-up issue" to implemented, referencing issue #644 and its release.
- `docs/configuration.md` — add a "Project trust" subsection near the scope table describing that project + project-agent scopes are withheld until trust is granted, and the reduced-scope warning.
- `README.md` — add a one-line trust note by the scope table (lines ~104–109).
- `docs/migration/0644-project-trust-gating.md` — new migration note (breaking): what changed, who is affected (untrusted directories), how to restore prior behavior (grant project trust or set `defaultProjectTrust`), following the `strict-config-validation.md` pattern.
  Add its link to the README doc-index table.

## Test Impact Analysis

1. **New unit tests enabled.**
   The explicit `projectTrusted` seam makes the trust decision directly assertable at each layer: `loadAndMergeConfigs` project-skip (loader unit), `ConfigStore.refresh` forwarding (store unit), `PermissionSession` cwd-withholding (session unit), and handler skip/warn behavior (handler unit) — none of which existed before because trust was never consulted.
2. **Redundant tests.**
   None become redundant; the existing trusted-path tests remain valid as the `projectTrusted: true` case (they now pass the arg explicitly).
3. **Tests that must stay as-is.** `test/permission-manager-unified.test.ts` `configureForCwd(cwd) applies project config` and the #646 fail-closed clamp tests genuinely exercise the trusted/loaded path and the manager merge; they are unchanged (the gate never reaches the manager when untrusted).

## Invariants at risk

- **#646 fail-closed clamp** (`floorAllowsToAsk` on an invalid non-global scope) — pinned by `test/permission-manager-unified.test.ts` and the config-loader fail-closed tests.
  This change does not touch `resolvePermissions`; when untrusted, the project scope is not loaded so `projectConfig.invalid` never fires — no interaction, no regression.
  Verified by leaving those tests untouched and green.
- **Trust-grant recovery** (ADR-0001) — pinned by a new `handleResourcesDiscover` reload test asserting `session.reload(true)` loads project policy after trust flips to `true`.
- **`refreshConfig` before `resetForNewSession` ordering** — pinned by the existing `calls refreshConfig before resetForNewSession` test; preserved (both simply gain the trust arg).

## TDD Order

1. **Loader project-scope skip.**
   Test surface: `test/config-loader.test.ts`.
   Covers: `loadAndMergeConfigs(agentDir, cwd, root, { includeProjectScope: false })` omits both the new project config and the legacy project policy (global-only merge); default / `true` still merges project.
   Commit: `feat(pi-permission-system): support skipping project scope in loadAndMergeConfigs`.

2. **Gate both load paths on project trust + loud warning.**
   Test surfaces: `test/handlers/lifecycle.test.ts`, `test/permission-session.test.ts`, `test/config-store.test.ts`, `test/session-start.test.ts`, `test/composition-root.test.ts`, and the `makeCtx` fixture.
   Covers: `handleSessionStart` reads `ctx.isProjectTrusted()` and calls `refreshConfig(ctx, trusted)` / `resetForNewSession(ctx, trusted)`; when untrusted it warns (`UNTRUSTED_PROJECT_MESSAGE`) and review-logs, and withholds the project cwd (`configureForCwd(undefined)`) and skips project runtime config (`includeProjectScope: false`); `handleResourcesDiscover(event, ctx)` gates `reload` on trust, warns on skip, and loads project policy after a trust grant; an end-to-end test proves a global `bash: deny` survives an untrusted project `bash: allow`.
   This single commit lands the required-parameter signature cascade across `config-loader` consumer, `config-store`, `permission-session`, `lifecycle`, `index.ts`, and all affected fixtures/tests (they break at the type level together).
   Commit: `fix(pi-permission-system)!: gate project-scoped config on project trust` with a `BREAKING CHANGE:` footer.

3. **Docs: ADR status, configuration, README, migration note.**
   Covers: ADR-0001 Status → implemented (Refs #644); `docs/configuration.md` project-trust subsection; `README.md` scope-table trust note + migration-doc index row; `docs/migration/0644-project-trust-gating.md`.
   Commit: `docs(pi-permission-system): document project-trust gating for project config`.

## Risks and Mitigations

- **Risk:** an empty cwd passed to the runtime-config path resolves project paths against `process.cwd()`, defeating the gate.
  **Mitigation:** gate the runtime path with an explicit `includeProjectScope: false` flag, never an empty/undefined cwd; asserted in the config-store unit test.
- **Risk:** breaking-change surprise for users who rely on project config in untrusted directories.
  **Mitigation:** loud UI warning + review-log entry on every skip; a migration note explaining how to restore behavior (grant trust or set `defaultProjectTrust`); `fix!` + `BREAKING CHANGE:` footer so the changelog and close comment surface it.
- **Risk:** trust granted mid-session does not pick up the project's runtime knobs (`yoloMode` etc.) until the next session start.
  **Mitigation:** documented as an accepted limitation (the safe direction — global-only runtime); policy still reloads immediately via `resources_discover`.
  Tracked in Open Questions.
- **Risk:** a hand-built test ctx lacking `isProjectTrusted` throws at `ctx.isProjectTrusted()`.
  **Mitigation:** default `makeCtx` to trusted and audit every ctx builder (`session-start`, `composition-root`) for the method in cycle 2.

## Open Questions

- Should the `resources_discover reload` path also re-run `refreshConfig` so a trust grant immediately reloads the project **runtime** config (not just policy)?
  Deferred — today's reload re-reads policy only, and global-only runtime is the safe interim state.
  If desired, file a follow-up to unify the reload to refresh both; not created now (nothing concrete depends on it).
- Is there value in surfacing the trust state in the `/permission-system` status/UI (beyond the transient warning)?
  Deferred; out of scope for the enforcement fix.
