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,365 @@
---
issue: 478
issue_title: "pi-permission-system: narrow ScopedPermissionResolver to a single resolve(intent) (Phase 6 Step 6)"
---
# Narrow `ScopedPermissionResolver` to a single `resolve(intent)`
## Release Recommendation
**Release:** ship independently
This is Phase 6 Step 6, tagged `Release: independent` in the architecture roadmap (it is not a member of either Phase 6 batch).
It is a self-contained, behavior-preserving structural refactor of the resolver/manager surface, so it ships on its own.
## Problem Statement
The permission-resolution surface widens once per gate.
Gates today call either `resolver.resolve(surface, input, agentName)` (tool-shaped) or `resolver.resolvePathPolicy(values, agentName, surface)` (precomputed path values), and the underlying `ScopedPermissionManager` mirrors that split with `checkPermission` + `checkPathPolicy`.
Adding a gate can widen this surface again, and — because the surface is a method pair rather than a single entry — a test fixture can stub one method and forget the other.
That is the structural cause of the [#393] false-green: a stubbed-but-unrouted `checkPathPolicy` silently returned its default (`allow`), a bug invisible in the edited test file and caught only by the full suite.
The fix is to make gates *emit* a minimal access intent that one `resolve(intent)` answers.
Adding a gate then cannot widen the resolver surface, and — by collapsing the manager to a single resolution method — the false-green class becomes structurally impossible (there is no second method to forget).
## Goals
- Introduce a minimal `AccessIntent` value that each gate emits (surface + value-or-`AccessPath` + `agentName`), carrying no principal identity.
- Collapse `ScopedPermissionResolver.resolve` + `resolvePathPolicy` into one `resolve(intent)`.
- Collapse `ScopedPermissionManager.checkPermission` + `checkPathPolicy` into one `check(intent, sessionRules?)`, migrating every raw (non-gate) caller onto it.
- Preserve all current behavior — this is a structural refactor, not a behavior change.
- Let `AccessPath` flow into the resolver as a first-class intent variant (the resolver, not the gate, asks it for `matchValues()`), seeding the `AccessPath`-as-universal-representation direction ([#487]).
## Non-Goals
- No change to what either path surface matches against.
The `path` surface stays lexical-only; `external_directory` stays lexical canonical (the [#418] set).
Whether `path` should also match the canonical form is tracked separately in [#486].
- No migration of bash-path's `path-values` intent onto `AccessPath` — that depends on [#486] and is part of the broader [#487] direction.
- No principal identity on `AccessIntent`; cross-session path portability stays deferred ([#309] tracks the related advisory-path unification).
- No change to the resolver's query methods (`getToolPermission`, `getConfigIssues`) or to the resolver's raw `checkPermission` (the no-session-rules path the skill-input gate depends on via `SkillInputGateInputs`).
- No change to `configuration.md` — there is no user-facing behavior or config change.
## Background
Relevant modules and how they relate:
- `src/permission-resolver.ts` — the `ScopedPermissionResolver` interface (`resolve` + `resolvePathPolicy`) and the `PermissionResolver` class.
The class composes a `ScopedPermissionManager` with a `SessionRules` store so gates never thread the session ruleset by hand (the [#319] / [#340] seam).
The class also exposes `checkPermission` (raw, no session rules), `getToolPermission`, and `getConfigIssues` — these are **not** on the narrow interface and are out of scope.
- `src/permission-manager.ts``ScopedPermissionManager` interface + `PermissionManager` class.
`checkPermission(toolName, input, agentName?, sessionRules?)` runs `normalizeInput` (needs `getConfiguredMcpServerNames()` + `currentCwd`) then `buildCheckResult`.
`checkPathPolicy(values, agentName?, sessionRules?, surface="path")` skips normalization, uses precomputed values, then `buildCheckResult`.
Both already funnel through the shared private `buildCheckResult` helper ([#393]).
- `src/access-intent/access-path.ts` — the `AccessPath` value object ([#476]); `matchValues()` returns the lexical alias union canonical for the `external_directory` surface.
- Gate descriptor factories that resolve:
`src/handlers/gates/path.ts` (`resolve("path", {path})`), `bash-command.ts` (three `resolve("bash", {command})` calls), `bash-path.ts` (`resolvePathPolicy(policyValues)`), `external-directory-policy.ts` (`resolvePathPolicy(path.matchValues(), …, "external_directory")`), plus the tool-resolve site in `tool-call-gate-pipeline.ts` and the descriptor-resolve site in `runner.ts`.
- Raw (non-gate) manager callers: `src/permissions-service.ts`, `src/skill-prompt-sanitizer.ts`, `src/permission-event-rpc.ts`, and the resolver's own raw `checkPermission`.
AGENTS.md / skill constraints that apply:
- The `architecture.md` "ScopedPermissionResolver surface" health-metric row and the access-intent directory listing must be updated when this lands (the package skill's "module-move check misses narrative prose" rule).
- The package skill's testing notes about wiring new manager/resolver methods through `makeHandler`'s surface dispatcher ([#393] / [#418]) must be rewritten, because there is now a single method.
## Design Overview
### `AccessIntent` — the gate-emitted value
Three variants, modeling the three genuine ways a gate supplies "what is being accessed":
```typescript
// src/access-intent/access-intent.ts
import type { AccessPath } from "#src/access-intent/access-path";
/** Raw tool input the manager must normalize (path/bash/MCP/extension tools). */
export interface ToolAccessIntent {
kind: "tool";
/** Tool name fed to input normalization (e.g. "read", "bash", "path", an MCP name). */
surface: string;
input: unknown;
agentName?: string;
}
/** Precomputed equivalent policy values for a path-shaped surface (bash-path). */
export interface PathValuesAccessIntent {
kind: "path-values";
/** "path" or "external_directory". */
surface: string;
values: readonly string[];
agentName?: string;
}
/** An AccessPath value object for a path-shaped surface (external-directory). */
export interface AccessPathAccessIntent {
kind: "access-path";
surface: string;
path: AccessPath;
agentName?: string;
}
/** What a gate emits. */
export type AccessIntent =
| ToolAccessIntent
| PathValuesAccessIntent
| AccessPathAccessIntent;
/** What the manager consumes — access-path already unwrapped to values. */
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
```
Why three variants and not two:
- The `tool` variant carries raw input only the manager can normalize (it needs `getConfiguredMcpServerNames()` + `currentCwd`).
- The `path-values` variant carries bash-path's cd-resolved lexical `string[]` for the `path` surface, which has no canonical-boundary notion.
- The `access-path` variant lets the external-directory gate hand its `AccessPath` directly, so `AccessPath` flows into the resolver (a meaningful domain boundary) rather than being flattened at the gate.
Forcing bash-path's plain `string[]` into an `AccessPath` would inject the canonical alias the `path` surface does not match today — a behavior change out of scope here.
### Where the unwrap happens
The **resolver** unwraps the `access-path` variant via `path.matchValues()` (Tell-Don't-Ask: it asks the `AccessPath` for its match set) and hands a `ResolvedAccessIntent` (string-based) to the manager.
The low-level `PermissionManager` stays string-based — it never imports `AccessPath`.
### Resolver
```typescript
export interface ScopedPermissionResolver {
resolve(intent: AccessIntent): PermissionCheckResult;
}
// PermissionResolver class
resolve(intent: AccessIntent): PermissionCheckResult {
return this.permissionManager.check(
toResolvedIntent(intent),
this.sessionRules.getRuleset(),
);
}
```
`toResolvedIntent` is a private module helper:
```typescript
function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
if (intent.kind === "access-path") {
return {
kind: "path-values",
surface: intent.surface,
values: intent.path.matchValues(),
agentName: intent.agentName,
};
}
return intent;
}
```
The class keeps `checkPermission` (raw, for skill-input), `getToolPermission`, and `getConfigIssues` unchanged — they remain off the narrow interface.
The raw `checkPermission` body now builds a `tool` intent and calls `manager.check(intent, sessionRules)`.
### Manager
```typescript
export interface ScopedPermissionManager {
configureForCwd(cwd: string | undefined | null): void;
check(
intent: ResolvedAccessIntent,
sessionRules?: Ruleset,
): PermissionCheckResult;
getToolPermission(toolName: string, agentName?: string): PermissionState;
getConfigIssues(agentName?: string): string[];
}
// PermissionManager class
check(intent: ResolvedAccessIntent, sessionRules?: Ruleset): PermissionCheckResult {
const { composedRules } = this.resolvePermissions(intent.agentName);
const fullRules: Ruleset = sessionRules?.length
? [...composedRules, ...sessionRules]
: composedRules;
if (intent.kind === "path-values") {
const lookupValues = intent.values.length > 0 ? [...intent.values] : ["*"];
return buildCheckResult(
intent.surface, lookupValues, {}, intent.surface, intent.surface, fullRules,
);
}
const toolName = intent.surface.trim();
const { surface, values, resultExtras } = normalizeInput(
toolName, intent.input, this.loader.getConfiguredMcpServerNames(), this.currentCwd,
);
return buildCheckResult(
surface, values, resultExtras, toolName, intent.surface, fullRules,
);
}
```
The two branches are exactly the former `checkPermission` and `checkPathPolicy` bodies, preserving the trimmed-toolName-for-source / original-toolName-for-result distinction.
### Consumer call-site sketches
External-directory policy helper (the `AccessPath` now flows into the resolver):
```typescript
// external-directory-policy.ts
export function resolveExternalDirectoryPolicy(path, resolver, agentName) {
return resolver.resolve({
kind: "access-path",
surface: "external_directory",
path,
agentName,
});
}
```
bash-path gate (precomputed values):
```typescript
// bash-path.ts
const check = resolver.resolve({
kind: "path-values",
surface: "path",
values: policyValues,
agentName: tcc.agentName ?? undefined,
});
```
Tool/path/bash-command/runner sites emit a `tool` intent:
```typescript
const check = resolver.resolve({
kind: "tool",
surface: "path", // or "bash" / tcc.toolName / descriptor.surface
input: { path: filePath }, // or { command } / tcc.input / descriptor.input
agentName: tcc.agentName ?? undefined,
});
```
### Edge cases preserved
- Empty `path-values` falls back to `["*"]` (the former `checkPathPolicy` behavior).
- The `path` vs `external_directory` surface tag still drives `evaluateAnyValue` (last-match-wins across aliases) via `PATH_SURFACES` inside `buildCheckResult` — unchanged.
- The bash-command unparseable-command fail-closed sentinel ([#452]) is in `resolveBashCommandCheck`, which now emits `tool` intents; the sentinel path is untouched.
- The `path`-surface "only the universal default fired → skip gate" guard ([#58]) lives in the gate factories, not the resolver — untouched.
## Module-Level Changes
### Added
- `src/access-intent/access-intent.ts``ToolAccessIntent`, `PathValuesAccessIntent`, `AccessPathAccessIntent`, `AccessIntent`, `ResolvedAccessIntent`.
### Changed — production
- `src/permission-manager.ts` — replace `checkPermission` + `checkPathPolicy` (interface + class) with `check(intent, sessionRules?)`; the two former bodies become the intent-kind branches; `buildCheckResult` unchanged.
- `src/permission-resolver.ts` — narrow `ScopedPermissionResolver` to `resolve(intent: AccessIntent)`; remove `resolvePathPolicy`; `resolve` delegates via `toResolvedIntent`; raw `checkPermission` body builds a `tool` intent; add `toResolvedIntent` private helper.
- `src/permissions-service.ts``checkPermission``check({ kind: "tool", … })`.
- `src/skill-prompt-sanitizer.ts``checkPermission``check({ kind: "tool", … })`.
- `src/permission-event-rpc.ts``checkPermission``check({ kind: "tool", … })`.
- `src/handlers/gates/path.ts` — emit a `tool` intent.
- `src/handlers/gates/bash-command.ts` — three call sites emit `tool` intents.
- `src/handlers/gates/bash-path.ts` — emit a `path-values` intent.
- `src/handlers/gates/external-directory-policy.ts``resolveExternalDirectoryPolicy` emits an `access-path` intent (drops the inline `path.matchValues()` call).
- `src/handlers/gates/tool-call-gate-pipeline.ts` — the tool-resolve site emits a `tool` intent.
- `src/handlers/gates/runner.ts` — the descriptor-resolve site emits a `tool` intent from `descriptor.surface` + `descriptor.input`.
### Changed — tests and fixtures
- `test/helpers/session-fixtures.ts``makeFakePermissionManager`: replace `checkPermission` + `checkPathPolicy` stubs with a single `check` stub.
- `test/helpers/handler-fixtures.ts``makeHandler`: route the `surfaceCheck` override onto the single `permissionManager.check` via an intent→(surface,input) adapter (replaces the dual `checkPermission`/`checkPathPolicy` routing); the `MockGateHandlerSession.checkPermission` override surface is unchanged.
- `test/helpers/gate-fixtures.ts``makeResolver`, `makeGateRunner`, `makePathDispatchResolver`: drop `resolvePathPolicy`; `makePathDispatchResolver`'s single `resolve` dispatches on intent kind (`tool``input.path`; `path-values` → any matching value; `access-path``path.matchValues()`).
- `test/permission-manager-unified.test.ts` — migrate `checkPermission`/`checkPathPolicy` tests to `check(intent)`.
- `test/permission-resolver.test.ts` — migrate `resolve`/`resolvePathPolicy` tests to `resolve(intent)` (including an `access-path` unwrap test).
- `test/handlers/gates/path.test.ts`, `bash-path.test.ts`, `bash-external-directory.test.ts`, `external-directory-policy.test.ts`, `external-directory.test.ts`, `bash-command-metamorphic.test.ts` — update resolver-mock assertions to the intent shape.
- `test/handlers/external-directory-session-dedup.test.ts` — update the inline manager mocks (`checkPermission`/`checkPathPolicy`) to the single `check`.
- Grep `test/` for any inline `ScopedPermissionResolver` / `ScopedPermissionManager` mock not covered by the fixtures and migrate it in the same commit as the interface change.
### Changed — docs
- `docs/architecture/architecture.md` — mark Step 6 ✅ (heading + Mermaid `S6` node); update the "ScopedPermissionResolver surface" health-metric row to met (`resolve(intent)`); rewrite the `permission-resolver.ts`, `permission-manager.ts`, `bash-path.ts`, and `external-directory-policy.ts` directory-listing descriptions to the new surface; add `access-intent.ts` to the access-intent directory listing; refresh the line-622 resolver-surface-widening narrative.
- `.pi/skills/package-pi-permission-system/SKILL.md` — rewrite the `makeFakePermissionManager` / `makeResolver` / `makePathDispatchResolver` / `makeGateRunner` / `makeHandler` fixture notes for the single `check` / `resolve(intent)`; rewrite the [#393] / [#418] "wire the new method through the surface dispatcher" testing notes to state the false-green is now structurally impossible (one method).
## Test Impact Analysis
1. **New unit tests enabled.**
`resolve(intent)` can be tested per-variant in one place, including the `access-path``matchValues()` unwrap (previously only reachable indirectly through the external-directory gate).
`check(intent)` can be tested per-kind directly on the manager.
2. **Tests that become redundant.**
The separate `checkPermission` vs `checkPathPolicy` manager test groups merge into intent-kind cases of `check`.
The separate `resolve` vs `resolvePathPolicy` resolver test groups merge into intent-variant cases of `resolve`.
Consolidate, do not duplicate.
3. **Tests that must stay.**
The gate behavior tests (path, bash-path, external-directory single/bash, bash-command chain) still exercise gate → resolver → manager end-to-end; they only change the asserted mock shape.
The [#393] / [#418] integration tests (`external-directory-session-dedup.test.ts`, the `tool-call.test.ts` bash-path/external-directory blocks) stay — they pin that the unification did not reintroduce a silent `allow`.
## Invariants at risk
This step touches surfaces earlier Phase 6 / earlier-phase steps refactored.
List and pin:
- **[#393] false-green class** — pinned by the `external-directory-session-dedup` and `tool-call` integration tests routing through real instances.
After unification the class is structurally impossible (single `check`); the tests must still pass green.
- **[#418] external_directory alias matching (lexical canonical)** — pinned by `bash-external-directory.test.ts` / `external-directory.test.ts` asserting both typed and symlink-resolved patterns match; the `access-path` variant must resolve the same `matchValues()` set.
- **[#452] bash fail-closed sentinel** — pinned by the bash-command unparseable-command tests; `resolveBashCommandCheck` keeps the sentinel.
- **[#58] universal-default skip on `path`** — pinned by the path-gate tests asserting no prompt when only the universal default fired.
- **[#306] / [#301] bash chain most-restrictive** — pinned by `bash-command-metamorphic.test.ts`.
All invariants live in existing tests; none rely on prose only, so no new pinning test is required beyond the migrated assertions.
## TDD Order
Lift-and-shift where an existing name's signature changes, to avoid a single giant test rewrite (per the testing skill).
1. **Add `AccessIntent` types + manager `check(intent)` alongside the old pair.**
New `src/access-intent/access-intent.ts`; add `check` to the `ScopedPermissionManager` interface + `PermissionManager` class (delegating through the existing `buildCheckResult`), leaving `checkPermission`/`checkPathPolicy` in place; add a `check` stub to `makeFakePermissionManager` and route it in `makeHandler` alongside the existing dispatch.
Red: `permission-manager-unified.test.ts` cases for `check` covering `tool` and `path-values` intents.
Commit: `feat(pi-permission-system): add ScopedPermissionManager.check(intent) (#478)`.
2. **Migrate manager callers to `check`; remove `checkPermission`/`checkPathPolicy`.**
Switch resolver internals (`resolve`/`resolvePathPolicy`/raw `checkPermission` bodies), `permissions-service.ts`, `skill-prompt-sanitizer.ts`, `permission-event-rpc.ts` to `check`; remove the old pair from the interface + class; drop the old stubs from `makeFakePermissionManager`, `makeHandler`, and the inline dedup-test mocks; migrate `permission-manager-unified.test.ts`.
One commit (interface removal breaks all manager mocks at the type level).
Run `pnpm run check` immediately after.
The resolver's **public** surface is unchanged here, so gates and resolver fixtures are untouched.
Commit: `refactor(pi-permission-system): route all callers through manager.check (#478)`.
3. **Add resolver `resolveIntent(intent)` alongside the old pair; migrate gates incrementally.**
Add `resolveIntent` to the interface + class (with `toResolvedIntent`); add a `resolveIntent` stub to `makeResolver`/`makeGateRunner`/`makePathDispatchResolver` alongside the existing `resolve`/`resolvePathPolicy`; add resolver-level tests for all three intent variants.
Then migrate each gate + its tests to `resolveIntent`, one commit per gate: `path.ts`, `bash-command.ts` (+ the `tool-call-gate-pipeline` tool-resolve site + `runner.ts`), `bash-path.ts`, `external-directory-policy.ts` (+ external-directory test files).
Commits: `feat(pi-permission-system): add resolver resolveIntent seam (#478)` then `refactor(pi-permission-system): emit AccessIntent from <gate> (#478)` per gate.
4. **Remove `resolve(surface,input)` + `resolvePathPolicy`; rename `resolveIntent` → `resolve`.**
Drop the old pair from the interface + class + fixtures; rename `resolveIntent` to `resolve` across the migrated call sites and tests; migrate `permission-resolver.test.ts` to the final `resolve(intent)`.
One commit (mechanical rename + final interface narrowing).
Run `pnpm run check` immediately after.
Commit: `refactor(pi-permission-system): narrow ScopedPermissionResolver to resolve(intent) (#478)`.
5. **Docs.**
Update `architecture.md` (Step 6 ✅, surface metric, directory descriptions, access-intent listing) and the package `SKILL.md` fixture/testing notes.
Commit: `docs(pi-permission-system): record resolve(intent) narrowing (#478)`.
If `/tdd-plan` judges the per-gate lift-and-shift heavier than an atomic resolver narrowing (only six production call sites), it may collapse steps 34 into a single atomic interface-change commit — the interface removal forces all consumers into one commit either way.
## Risks and Mitigations
- **Large interface-removal commits.**
Both the manager (step 2) and resolver (step 4) removals break every typed mock at once.
Mitigation: lift-and-shift the new method in first (steps 1, 3), grep `test/` for inline mocks before the removal commit, and run `pnpm run check` immediately after each removal.
- **Silent behavior change in the `tool`-intent branch.**
The trimmed-vs-original tool-name distinction (`deriveSource` uses trimmed; the result reports original) must be preserved.
Mitigation: keep `buildCheckResult(surface, values, extras, trimmedToolName, originalSurface, fullRules)` argument order; the manager-unified tests assert `source` and `toolName`.
- **`AccessPath` coupling creep.**
Mitigation: the manager consumes `ResolvedAccessIntent` (no `AccessPath`); only the resolver imports `AccessPath`, via `toResolvedIntent`.
- **Reintroducing the [#393] false-green during migration.**
Mitigation: the integration tests route through real instances; keep them green at every step.
## Open Questions
- Should the `path` surface match the canonical form like `external_directory`?
Filed as [#486]; resolving it gates the bash-path → `AccessPath` migration.
- Adopt `AccessPath` as the universal internal path representation?
Filed as [#487]; this step's `path-values` variant is the transitional accommodation that shrinks under that direction.
[#58]: https://github.com/gotgenes/pi-packages/issues/58
[#301]: https://github.com/gotgenes/pi-packages/issues/301
[#306]: https://github.com/gotgenes/pi-packages/issues/306
[#309]: https://github.com/gotgenes/pi-packages/issues/309
[#319]: https://github.com/gotgenes/pi-packages/issues/319
[#340]: https://github.com/gotgenes/pi-packages/issues/340
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#452]: https://github.com/gotgenes/pi-packages/issues/452
[#476]: https://github.com/gotgenes/pi-packages/issues/476
[#486]: https://github.com/gotgenes/pi-packages/issues/486
[#487]: https://github.com/gotgenes/pi-packages/issues/487