feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,263 @@
---
issue: 340
issue_title: "Extract a PermissionResolver collaborator out of PermissionSession"
---
# Extract a PermissionResolver collaborator out of PermissionSession
## Problem Statement
`PermissionSession` is a god object that implements six role interfaces.
One of those roles — permission resolution — is a cohesive cluster of methods (`resolve` / `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`) that is currently fused into the session.
Because the resolution logic lives on the session, every test that wants to exercise resolution must build a full session fixture (the 17-field `makeSession` intersection mock).
This is Phase 4, Step 7 (Track C: split the session) of the `pi-permission-system` improvement roadmap.
It promotes `PermissionResolver` from a one-method interface (`resolve`) into a concrete collaborator that holds the `PermissionManager` + `SessionRules` and owns the whole resolution surface, so the resolve role becomes a distinct, directly unit-testable object.
## Goals
- Promote `permission-resolver.ts` to a concrete `PermissionResolver` class holding `ScopedPermissionManager` + `SessionRules`, owning `resolve` / `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`.
- Rename the narrow `{ resolve }` role interface (currently `PermissionResolver`) to `ScopedPermissionResolver` so the concrete class can take the canonical name.
- Route `GateRunner`, `ToolCallGatePipeline`, and `SkillInputGatePipeline` through the new resolver for the resolve / check role.
- Remove the resolve role from `PermissionSession` (drop the `resolve` method and the `ScopedPermissionResolver` implements clause).
- Keep the change behavior-preserving — the full suite stays green at every step.
## Non-Goals
- Removing `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` from `PermissionSession`.
These remain (delegating to the session's own `PermissionManager`) because the `AgentPrepSession`, `SessionLifecycleSession`, and `SkillPermissionChecker` interfaces still depend on them.
Removing them and unwinding those fig-leaf interfaces is Step 8 ([#341]).
- Rewiring `AgentPrepHandler` / `SessionLifecycleHandler` to depend on the resolver — deferred to Step 8.
- Touching `LocalPermissionsService` (`permissions-service.ts`), which keeps its own direct `PermissionManager` delegation.
- Any change to permission decision semantics, config format, schema, or docs beyond the architecture/skill descriptions.
## Background
Relevant modules (see `docs/architecture/architecture.md`):
- `src/permission-resolver.ts` — currently just the `PermissionResolver` interface (`resolve(surface, input, agentName)`); the relay-collapsing abstraction introduced in [#319].
Implemented by `PermissionSession`.
- `src/permission-session.ts` — the god object.
Implements `PermissionResolver`, `SessionApprovalRecorder`, `GateHandlerSession`, `AgentPrepSession`, `SessionLifecycleSession`.
Holds the injected `ScopedPermissionManager` + `SessionRules`.
Its `resolve` composes `checkPermission` with `getSessionRuleset()`.
- `src/handlers/gates/runner.ts``GateRunner` is constructed with a `PermissionResolver` and calls `resolver.resolve(...)`.
- `src/handlers/gates/tool-call-gate-pipeline.ts``ToolCallGateInputs extends PermissionResolver`; the pipeline is constructed with `session` and uses `this.inputs.resolve(...)` plus three query methods (`getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`).
- `src/handlers/gates/skill-input-gate-pipeline.ts``SkillInputGateInputs` is a narrow `{ checkPermission }`; constructed with `session`.
- Gate descriptor factories `path.ts`, `bash-command.ts`, `bash-external-directory.ts`, `bash-path.ts` — each takes a `resolver: PermissionResolver` and calls `resolver.resolve(...)`.
- `src/index.ts` — composition root.
Constructs `permissionManager`, `sessionRules`, `session`, and wires `new GateRunner(session, session, gateway, reporter)`, `new ToolCallGatePipeline(session, formatterRegistry)`, `new SkillInputGatePipeline(session)`.
Precedent from Step 6 ([#339], `PromptingGateway`): the prompting role was fully removed from `PermissionSession` and `GateRunner` was rewired to a distinct collaborator.
This step mirrors that for resolution.
Naming follows the established role-interface + concrete-class convention (`ScopedPermissionManager` + `PermissionManager`, `GatePrompter` + `PromptingGateway`, `DecisionReporter` + `GateDecisionReporter`).
Per the user decision on this issue, the concrete class takes the canonical name `PermissionResolver` and the narrow role interface is renamed `ScopedPermissionResolver` — symmetric with `ScopedPermissionManager` (the narrow session-scoped contract the concrete class implements).
Constraint from `AGENTS.md` / `code-design`: a shared interface referencing a collaborator must use a narrow interface type, not the concrete class — the gate factories' test mocks are plain objects (`{ resolve }`), so the `{ resolve }` interface must survive as a distinct type from the class.
## Design Overview
### The narrow role interface
```typescript
// permission-resolver.ts — the resolve role the gate factories / runner / pipeline need.
export interface ScopedPermissionResolver {
resolve(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
```
### The concrete class
```typescript
// permission-resolver.ts — the concrete collaborator holding the manager + rules.
export class PermissionResolver implements ScopedPermissionResolver {
constructor(
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
) {}
resolve(surface: string, input: unknown, agentName?: string): PermissionCheckResult {
return this.checkPermission(surface, input, agentName, this.sessionRules.getRuleset());
}
checkPermission(surface: string, input: unknown, agentName?: string, sessionRules?: Rule[]): PermissionCheckResult {
return this.permissionManager.checkPermission(surface, input, agentName, sessionRules);
}
getToolPermission(toolName: string, agentName?: string): PermissionState {
return this.permissionManager.getToolPermission(toolName, agentName);
}
getConfigIssues(agentName?: string): string[] {
return this.permissionManager.getConfigIssues(agentName);
}
getPolicyCacheStamp(agentName?: string): string {
return this.permissionManager.getPolicyCacheStamp(agentName);
}
}
```
Notes on the dependency contract:
- The constructor accepts `ScopedPermissionManager` (the narrow interface), not the concrete `PermissionManager`, so unit tests pass a fake manager without an `as unknown as` cast.
- The session-rules dependency is narrowed to `Pick<SessionRules, "getRuleset">` (ISP — the resolver only reads the ruleset; it never records approvals).
Unit tests can pass a real `new SessionRules()` or a `{ getRuleset: () => rules }` stub.
- `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` are included per the issue's resolution surface even though no current resolver consumer calls them; Step 8 rewires `AgentPrepHandler` / `SessionLifecycleHandler` to use them.
### Consumer call sites
`GateRunner` (unchanged body; constructor param type only):
```typescript
// runner.ts
constructor(
private readonly resolver: ScopedPermissionResolver, // was PermissionResolver
private readonly recorder: SessionApprovalRecorder,
private readonly prompter: GatePrompter,
private readonly reporter: DecisionReporter,
) {}
// ... this.resolver.resolve(descriptor.surface, descriptor.input, agentName ?? undefined)
```
`ToolCallGatePipeline` (resolver split out of the query inputs):
```typescript
// tool-call-gate-pipeline.ts
export interface ToolCallGateInputs { // no longer extends ScopedPermissionResolver
getActiveSkillEntries(): SkillPromptEntry[];
getInfrastructureReadDirs(): string[];
getToolPreviewLimits(): ToolPreviewFormatterOptions;
}
constructor(
private readonly resolver: ScopedPermissionResolver,
private readonly inputs: ToolCallGateInputs,
private readonly customFormatters?: ToolInputFormatterLookup,
) {}
// gate factories now receive this.resolver; query methods stay on this.inputs:
// describePathGate(tcc, this.resolver)
// describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver)
// describeBashPathGate(tcc, bashProgram, this.resolver)
// resolveBashCommandCheck(command ?? "", bashProgram.commands(), agentName, this.resolver)
// this.resolver.resolve(tcc.toolName, tcc.input, ...)
// this.inputs.getActiveSkillEntries() / getInfrastructureReadDirs() / getToolPreviewLimits()
```
`index.ts` (construct the resolver once; share the same `permissionManager` + `sessionRules` instances the session holds):
```typescript
const resolver = new PermissionResolver(permissionManager, sessionRules);
// ...
const gateRunner = new GateRunner(resolver, session, gateway, reporter);
const toolCallGatePipeline = new ToolCallGatePipeline(resolver, session, formatterRegistry);
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
```
`SkillInputGatePipeline` needs no interface change — the `PermissionResolver` class satisfies `SkillInputGateInputs` (`{ checkPermission }`) structurally; only the construction site moves from `session` to `resolver`.
### Separation of concerns / shared-instance contract
After this step, both `PermissionSession` and `PermissionResolver` hold references to the *same* `permissionManager` and `sessionRules` instances (injected from the composition root — never reconstructed).
`PermissionSession` keeps the manager for lifecycle (`configureForCwd` in `resetForNewSession` / `reload`) and the transitional query methods; the rules for `getSessionRuleset` / `recordSessionApproval` / `clear`.
`PermissionResolver` reads them for resolution.
There is no split-brain because the instances are identical — this mirrors the shared-instance contract established when `ExtensionRuntime` was dissolved in [#337].
### Edge cases
- Raw vs. session-scoped check: `resolve` applies `sessionRules.getRuleset()`; `checkPermission` (called by `SkillInputGatePipeline` with three args) intentionally passes no session rules — the raw skill-input semantics from [#326] are preserved because the 4th argument stays optional.
- Empty session ruleset: `resolve` forwards `[]` when no approvals are recorded (identical to the current session behavior).
## Module-Level Changes
Source:
- `src/permission-resolver.ts` — rename interface `PermissionResolver``ScopedPermissionResolver`; add concrete `class PermissionResolver implements ScopedPermissionResolver` (constructor `ScopedPermissionManager` + `Pick<SessionRules, "getRuleset">`; methods `resolve`, `checkPermission`, `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp`).
Add imports for `ScopedPermissionManager`, `SessionRules`, `Rule`, `PermissionState` (types).
- `src/permission-session.ts` — drop `resolve` method; remove `ScopedPermissionResolver` (formerly `PermissionResolver`) from the `implements` list and its import.
Keep `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` (transitional; removed in Step 8).
- `src/handlers/gates/runner.ts` — import + constructor param `PermissionResolver``ScopedPermissionResolver`.
- `src/handlers/gates/tool-call-gate-pipeline.ts``ToolCallGateInputs` no longer extends the resolve interface (becomes the three query methods); add `resolver: ScopedPermissionResolver` as the first constructor param; route gate factories + tool resolve through `this.resolver`.
Update the doc comment.
- `src/handlers/gates/path.ts`, `bash-command.ts`, `bash-external-directory.ts`, `bash-path.ts` — import + `resolver` param type `PermissionResolver``ScopedPermissionResolver`.
- `src/index.ts` — construct `new PermissionResolver(permissionManager, sessionRules)`; rewire `GateRunner` (first arg → `resolver`), `ToolCallGatePipeline` (prepend `resolver`), `SkillInputGatePipeline` (`session``resolver`).
`src/handlers/gates/skill-input-gate-pipeline.ts` is unchanged (only its construction site in `index.ts` moves).
Tests:
- `test/permission-resolver.test.ts` — new: unit tests for the concrete class (no session fixture).
- `test/permission-session.test.ts` — remove the `describe("resolve")` block (moves to the resolver test); `makePermissionManager` and the surviving delegation tests stay.
- `test/helpers/gate-fixtures.ts``makeResolver` / `makeGateRunner` / `makeGateInputs` type references `PermissionResolver["resolve"]``ScopedPermissionResolver["resolve"]`; `makeGateInputs` drops the `resolve` field (now produced by `makeResolver`).
Imports updated.
- `test/handlers/gates/tool-call-gate-pipeline.test.ts` — construct `new ToolCallGatePipeline(resolver, inputs, ...)`; the `resolve`-override test (`makeGateInputs({ resolve })`) switches to `makeResolver({ ... })`.
- `test/handlers/gates/skill-input-gate-pipeline.test.ts` — no construction change (still `new SkillInputGatePipeline(inputs)` via `makeSkillInputInputs`, which is structurally a resolver subset); verify it still type-checks.
- `test/handlers/gates/bash-external-directory.test.ts`, `bash-path.test.ts` — import `PermissionResolver` type → `ScopedPermissionResolver`.
Docs:
- `docs/architecture/architecture.md` — update the module-structure entries for `permission-resolver.ts` (now interface + concrete class), `permission-session.ts` (implements four interfaces, resolve role removed), and `runner.ts` (constructed with `ScopedPermissionResolver`); decrement the "role interfaces implemented by one class" metric (5 → 4) in the constructibility table. (The Step 7 `✓ complete` marker on the roadmap step line is appended during `/ship-issue`, per the package skill.)
- `.pi/skills/package-pi-permission-system/SKILL.md` — update the `makeResolver` description (`PermissionResolver` mock → `ScopedPermissionResolver` mock) and `makeGateInputs` (no longer stubs `resolve`).
## Test Impact Analysis
1. New unit tests enabled by the extraction: `test/permission-resolver.test.ts` exercises `resolve` (applies the session ruleset; defaults `agentName` to `undefined`; returns the manager's result; applies a recorded approval), and `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` delegation — all by constructing `new PermissionResolver(fakeManager, new SessionRules())` with no session fixture.
This is the headline win: the resolve role is now testable without `makeSession`.
2. Redundant tests: the `describe("resolve")` block in `test/permission-session.test.ts` (four cases) duplicates the new resolver tests once `resolve` moves off the session — removed in the same step that removes `session.resolve`.
3. Tests that must stay as-is: the session's `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` delegation tests (the session keeps those methods until Step 8); the `AgentPrepHandler` / `SessionLifecycleHandler` tests (still depend on the session interfaces); the gate-factory and runner tests (still drive `resolve` through the narrow interface, now `ScopedPermissionResolver`).
## TDD Order
1. **Rename the narrow interface `PermissionResolver` → `ScopedPermissionResolver`** (refactor).
Mechanical rename across `permission-resolver.ts` and every type-importer (`runner.ts`, `tool-call-gate-pipeline.ts`, `path.ts`, `bash-command.ts`, `bash-external-directory.ts`, `bash-path.ts`, `permission-session.ts` implements clause, `test/helpers/gate-fixtures.ts`, `test/handlers/gates/bash-external-directory.test.ts`, `test/handlers/gates/bash-path.test.ts`).
No behavior change; the existing suite is the regression guard.
Run `pnpm run check` after committing (a renamed export breaks all importers in one commit — this is the atomic rename step).
Commit: `refactor: rename PermissionResolver interface to ScopedPermissionResolver (#340)`.
2. **Add the concrete `PermissionResolver` class; route `GateRunner` + `SkillInputGatePipeline` through it** (test + feat).
Red→green: write `test/permission-resolver.test.ts` against the new class (resolve + four delegations), then implement the class in `permission-resolver.ts`.
Construct `new PermissionResolver(permissionManager, sessionRules)` in `index.ts`; pass it as `GateRunner`'s first arg and as `SkillInputGatePipeline`'s constructor arg.
`session.resolve` still exists and is still used by `ToolCallGatePipeline`, so the suite stays green.
Commit: `feat: add PermissionResolver class and route gate runner through it (#340)`.
3. **Inject the resolver into `ToolCallGatePipeline`** (refactor + test).
Narrow `ToolCallGateInputs` to the three query methods; add `resolver: ScopedPermissionResolver` as the first constructor param; route gate factories + tool resolve through `this.resolver`.
Update `index.ts` (`new ToolCallGatePipeline(resolver, session, formatterRegistry)`), `makeGateInputs` (drop `resolve`), and `tool-call-gate-pipeline.test.ts` (pass a `makeResolver(...)` resolver; move the `resolve`-override case onto it).
These land together because narrowing the interface and constructing the pipeline are type-coupled (single call site in `index.ts`).
Commit: `refactor: inject resolver into ToolCallGatePipeline (#340)`.
4. **Remove the resolve role from `PermissionSession`** (refactor).
With no remaining consumer of `session.resolve`, delete the method and the `ScopedPermissionResolver` implements clause (and its import); remove the now-redundant `describe("resolve")` block from `test/permission-session.test.ts`.
Commit: `refactor: remove resolve role from PermissionSession (#340)`.
5. **Update architecture and skill docs** (docs).
Update the `docs/architecture/architecture.md` module-structure entries (`permission-resolver.ts`, `permission-session.ts`, `runner.ts`) and decrement the role-interfaces metric (5 → 4); update the `makeResolver` / `makeGateInputs` descriptions in `.pi/skills/package-pi-permission-system/SKILL.md`.
Commit: `docs: update architecture and skill for PermissionResolver extraction (#340)`.
## Risks and Mitigations
- Risk: the session and resolver hold different `PermissionManager` / `SessionRules` instances (split-brain).
Mitigation: `index.ts` injects the same instances into both; neither reconstructs them.
Verified by `test/composition-root.test.ts` (shared-instance contract).
- Risk: a missed `session.resolve` consumer breaks at runtime, not at type-check.
Mitigation: grep confirms the only `resolve` callers are the gate factories, `GateRunner`, and `ToolCallGatePipeline`, all rewired before Step 4 removes the method; run the full suite (not just changed files) before each commit.
- Risk: narrowing `ToolCallGateInputs` (dropping `resolve`) silently leaves a stale `resolve` field in a fixture.
Mitigation: update `makeGateInputs` and the pipeline test in the same step (Step 3); `pnpm run check` flags excess/missing properties.
- Risk: the interface rename misses an importer.
Mitigation: dedicated rename step (Step 1) followed immediately by `pnpm run check`.
## Open Questions
- Step 8 ([#341]) removes the transitional `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` from `PermissionSession`, rewires `AgentPrepHandler` / `SessionLifecycleHandler` to the resolver, and unwinds the fig-leaf interfaces.
The exact disposition of `SkillPermissionChecker` (whether `AgentPrepHandler` passes the resolver to `resolveSkillPromptEntries`) is decided there, not here.
[#319]: https://github.com/gotgenes/pi-packages/issues/319
[#326]: https://github.com/gotgenes/pi-packages/issues/326
[#337]: https://github.com/gotgenes/pi-packages/issues/337
[#339]: https://github.com/gotgenes/pi-packages/issues/339
[#341]: https://github.com/gotgenes/pi-packages/issues/341