mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
249 lines
19 KiB
Markdown
249 lines
19 KiB
Markdown
---
|
||
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.
|