Files
my-pi/pi-permission-system/docs/plans/0319-introduce-permission-resolver.md
T

164 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 319
issue_title: "Introduce PermissionResolver and remove the session-rule relay from the permission gates"
---
# Introduce PermissionResolver and remove the session-rule relay
## Problem Statement
Every permission gate is handed two functions, `checkPermission` and `getSessionRuleset`, but `getSessionRuleset` is never used on its own.
At all five call sites — `runGateCheck` plus `describePathGate`, `describeBashPathGate`, `describeBashExternalDirectoryGate`, and `resolveBashCommandCheck` — the ruleset is fetched only to be handed straight back into the next `checkPermission` call:
```typescript
const sessionRules = getSessionRuleset();
const check = checkPermission(surface, input, agent, sessionRules);
```
So the pair is not two collaborators; it is one operation — "resolve the effective permission, applying the current session rules" — split into a primitive plus a relay.
This is the first step of a larger rework: the `GateRunnerDeps` closure bag in `handleToolCall` conflates this relay with four genuine roles, and the relay must go before the roles become visible.
## Goals
- Define a narrow `PermissionResolver` interface exposing a single `resolve(surface, input, agentName?)` operation.
- Have `PermissionSession` implement it by composing `checkPermission` with `getSessionRuleset` internally.
- Migrate all four gate descriptor producers and `resolveBashCommandCheck` to depend on `PermissionResolver` instead of the `checkPermission` + `getSessionRuleset` pair.
- Replace the `checkPermission` + `getSessionRuleset` members of the `GateRunnerDeps` bag with `resolve`.
- Keep the change behavior-preserving.
## Non-Goals
- Extracting the `DecisionReporter` (`writeReviewLog` + `emitDecision`) collaborator — that is #322.
- Replacing `GateRunnerDeps` with a `GateRunner` class injected with role collaborators, and adding the `GatePrompter` role — that is #323.
- Changing any permission decision, log entry, or decision-event payload.
- Touching `handleInput` (it calls `session.checkPermission` directly with no session-rule relay and is out of scope).
## Background
- `src/handlers/gates/runner.ts` (`runGateCheck`) resolves the check via `deps.checkPermission(surface, input, agent, deps.getSessionRuleset())` unless `preCheck`/`preResolved` short-circuits it.
- `src/handlers/gates/{path,bash-path,bash-external-directory}.ts` each declare a local `CheckPermissionFn` type and take `(checkPermission, getSessionRuleset)`; each calls `getSessionRuleset()` once, then `checkPermission(..., sessionRules)` one or more times.
- `src/handlers/gates/bash-command.ts` (`resolveBashCommandCheck`) takes `(command, commands, agentName, sessionRules, checkPermission)` and calls `checkPermission(..., sessionRules)` per command unit.
- `src/handlers/permission-gate-handler.ts` builds `checkPermission` and `getSessionRuleset` closures over `this.session`, threads them into every gate producer and the inline tool-gate resolution, and packs them into the `GateRunnerDeps` bag.
- `PermissionSession` already exposes both `checkPermission(surface, input, agentName?, sessionRules?)` and `getSessionRuleset()`.
- `SessionRules.getRuleset()` returns a fresh array copy (`[...this.rules]`) on each call.
Constraint from AGENTS.md / `code-design`: when a shared interface references a collaborator, use a narrow interface type, not the concrete class; keep Pi SDK imports out of the new pure module.
## Design Overview
A single new role interface:
```typescript
// src/permission-resolver.ts
import type { PermissionCheckResult } from "./types";
/**
* Resolves the effective permission for a surface/input, applying the
* current session rules internally. Collapses the checkPermission +
* getSessionRuleset relay that every gate previously threaded by hand.
*/
export interface PermissionResolver {
resolve(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
```
`PermissionSession` implements it:
```typescript
class PermissionSession implements PermissionResolver {
resolve(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult {
return this.checkPermission(
surface,
input,
agentName,
this.getSessionRuleset(),
);
}
}
```
Gate consumer call site (replaces the `(checkPermission, getSessionRuleset)` pair):
```typescript
// describePathGate, after migration
const check = resolver.resolve("path", { path: filePath }, tcc.agentName ?? undefined);
```
The module is a pure type — no SDK imports, no behavior — so the session imports it downward (`./permission-resolver`) and the gates/runner import it via `#src/permission-resolver`.
No import cycle: the resolver references only `PermissionCheckResult` from `types.ts`.
Edge cases:
- Multi-check gates (`describeBashPathGate`, `describeBashExternalDirectoryGate`) previously snapshotted the ruleset once and reused it across token checks; after migration `resolve` re-snapshots per call.
Because no `recordSessionApproval` happens during descriptor construction, every snapshot within a gate is equal — behavior-preserving (see Risks).
- `resolveBashCommandCheck`'s empty-`commands` fallback still calls `resolve("bash", { command }, agentName)`, matching the prior whole-command `checkPermission` fallback.
- `GateRunnerDeps` keeps `resolve` aligned with the interface by extending it (`interface GateRunnerDeps extends PermissionResolver { … }`).
## Module-Level Changes
- `src/permission-resolver.ts`**new**: the `PermissionResolver` interface.
- `src/permission-session.ts` — add `implements PermissionResolver` and the `resolve` method; import the interface type.
- `src/handlers/gates/path.ts` — replace the `checkPermission` + `getSessionRuleset` params with a single `resolver: PermissionResolver`; drop the local `CheckPermissionFn` type; call `resolver.resolve(...)`.
- `src/handlers/gates/bash-path.ts` — same migration; the per-token loop calls `resolver.resolve(...)`.
- `src/handlers/gates/bash-external-directory.ts` — same migration.
- `src/handlers/gates/bash-command.ts``resolveBashCommandCheck` drops the `sessionRules` and `checkPermission` params for a single `resolver: PermissionResolver`; drop the local `CheckPermissionFn` type.
- `src/handlers/gates/descriptor.ts``GateRunnerDeps`: remove `checkPermission` and `getSessionRuleset`; `extends PermissionResolver` to gain `resolve`.
- `src/handlers/gates/runner.ts` — resolve the check via `deps.resolve(descriptor.surface, descriptor.input, agentName ?? undefined)`.
- `src/handlers/permission-gate-handler.ts` — expose `this.session` as `PermissionResolver` to every gate producer and the inline tool-gate resolution; set the bag's `resolve`; remove the now-unused `checkPermission` and `getSessionRuleset` closures.
- `test/helpers/gate-fixtures.ts` — add `makeResolver(overrides)` returning `{ resolve: vi.fn() }`; update `makeRunnerDeps` to expose `resolve` instead of `checkPermission` + `getSessionRuleset`.
- `test/handlers/gates/{path,bash-path,bash-external-directory,bash-command,runner}.test.ts` — inject a resolver mock; assert on `resolver.resolve(surface, input, agentName)` (three args, no ruleset) instead of `checkPermission(..., sessionRules)`.
- `docs/architecture/architecture.md` — add `permission-resolver.ts` to the `src/` file tree; reframe the Phase 3 Track C roadmap entry (old Step 6 "GateRunnerContext narrow interface") into the three-step decomposition (#319 resolver, #322 reporter, #323 GateRunner), and update the matching Mermaid node and Track C summary row.
No removed or renamed public exports; `getSessionRuleset` and `checkPermission` remain on `PermissionSession` (still used by `resolve`, `handleInput`, and other callers).
A repo-wide grep confirms no other consumer imports the gates' local `CheckPermissionFn` types (they are file-private).
## Test Impact Analysis
1. New unit tests enabled: `PermissionSession.resolve` can now be tested in isolation — that it forwards the surface/input/agent and applies the current session ruleset.
This composition was previously implicit in every gate's wiring and never unit-tested on its own.
2. Tests simplified: the five gate/runner test files drop the separate `getSessionRuleset` mock and the four-argument `checkPermission` assertion, asserting instead on a single three-argument `resolver.resolve` call — fewer moving parts per test.
3. Tests that stay as-is: every gate descriptor test keeps exercising its gate's branching logic (null/bypass/descriptor, most-restrictive selection, backward-compat `matchedPattern === undefined` handling); only the injected collaborator and its assertion shape change.
## TDD Order
1. Add `PermissionResolver` + `PermissionSession.resolve`.
Surface: `test/permission-session.test.ts`.
Covers: `resolve` forwards `surface`/`input`/`agentName` and applies the session ruleset; reflects a recorded approval on the next `resolve`.
Commit: `feat: add PermissionResolver.resolve to PermissionSession`.
2. Migrate `describePathGate` to `PermissionResolver`; add `makeResolver` to `gate-fixtures.ts`; update `path.test.ts` and the handler call site (handler keeps the old closures for the not-yet-migrated gates and the runner bag).
Commit: `refactor: migrate describePathGate to PermissionResolver`.
3. Migrate `describeBashExternalDirectoryGate`; update its test and the handler call site.
Commit: `refactor: migrate describeBashExternalDirectoryGate to PermissionResolver`.
4. Migrate `describeBashPathGate`; update its test and the handler call site.
Commit: `refactor: migrate describeBashPathGate to PermissionResolver`.
5. Migrate `resolveBashCommandCheck` and the inline tool-gate resolution in `handleToolCall`; update `bash-command.test.ts`.
Commit: `refactor: migrate resolveBashCommandCheck to PermissionResolver`.
6. Replace the bag's `checkPermission` + `getSessionRuleset` with `resolve`: update `GateRunnerDeps` (`extends PermissionResolver`), `runner.ts`, `makeRunnerDeps`, `runner.test.ts`, and remove the handler's now-unused closures.
Commit: `refactor: resolve via PermissionResolver in the gate runner`.
7. Update `docs/architecture/architecture.md` (file tree + Phase 3 Track C roadmap reframing).
Commit: `docs: reframe Phase 3 Track C into the gate-runner collaborator decomposition`.
Each step changes one gate's signature plus its single handler call site and its test in the same commit — the type checker would reject splitting them.
The handler carries both the resolver and the legacy closures through steps 25, so the repo stays green between commits; step 6 deletes the last closures once no consumer remains.
## Risks and Mitigations
- Per-call ruleset snapshot: `getRuleset()` copies the array each call, so multi-token gates now snapshot per `resolve` instead of once per gate.
Mitigation: no `recordSessionApproval` runs during descriptor construction, so all snapshots within a gate are identical; the result is unchanged and the extra allocations are negligible for realistic ruleset/token sizes.
- Mechanical breadth: five gate/runner test files change their injected collaborator.
Mitigation: a shared `makeResolver` fixture and one-gate-per-commit sequencing keep each diff small and reviewable.
- Inline tool-gate coupling: `handleToolCall` resolves the tool check via both `resolveBashCommandCheck` and a direct `checkPermission` call.
Mitigation: migrate both in step 5 so the inline path flips to `resolve` atomically.
## Open Questions
- The home and grouping of the remaining roles (`GatePrompter`, `SessionApprovalRecorder`, `DecisionReporter`) are deferred to #322 and #323; this plan introduces only `PermissionResolver`.