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,330 @@
---
issue: 329
issue_title: "Extract a SkillInputGatePipeline for the handleInput skill-input gate"
---
# Extract a SkillInputGatePipeline
## Problem Statement
`PermissionGateHandler.handleInput` still hand-assembles the skill-input permission gate inline: a raw `checkPermission` pre-check, a deny notify, the `describeSkillInputGate` descriptor, a request-id mint, and the `runner.run(...)` call.
This is gate-construction work with no owner — the same shape `ToolCallGatePipeline.evaluate` already owns for the six tool-call gates ([#327]).
Because the assembly is inline, `handleInput` reaches the session for `checkPermission` and `createPermissionRequestId`, which is the only reason the handler's `GateHandlerSession` role still carries four members instead of the two-member context role (`activate` + `resolveAgentName`) both entry points actually share.
Extracting a `SkillInputGatePipeline` collaborator makes the `input` path symmetric with the `tool_call` path and lets `GateHandlerSession` shrink to its context role.
## Goals
- Introduce a `SkillInputGatePipeline` collaborator that owns the skill-input gate assembly (pre-check, deny notify, descriptor, request-id mint, run) and exposes an `evaluate(...)` seam.
- Construct the pipeline in the composition root (`index.ts`) and inject it into `PermissionGateHandler`.
- Reduce `handleInput` to `activate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome`, symmetric with `handleToolCall`.
- Shrink `GateHandlerSession` to the two-method context role (`activate`, `resolveAgentName`); `checkPermission` and `createPermissionRequestId` leave the handler's session surface.
- Fold the request-id minting (formerly `PermissionSession.createPermissionRequestId`) into the pipeline and remove it from `PermissionSession` — this absorbs the scope of [#330], which can be closed when this lands.
- Behavior-preserving: the untagged skill-input deny notify, the raw-`checkPermission` (no-session-rules) semantics ([#326]), and the request-id format are all preserved.
## Non-Goals
- Retyping `AgentPrepHandler` / `SessionLifecycleHandler` against role interfaces — that is [#331].
- Reframing `index.ts` as collaborator injection — that is [#320]; this plan injects the *new* collaborator properly but leaves the surrounding factory shape alone.
- Trimming the `input*.test.ts` handler integration tests that now overlap with the new pipeline unit tests — deferred to [#321].
- Changing any permission decision, emitted event, log entry, or user-facing message text.
## Background
Relevant modules:
- `src/handlers/permission-gate-handler.ts``PermissionGateHandler`; `handleInput` hand-rolls the skill-input gate, while `handleToolCall` already delegates to the injected `ToolCallGatePipeline`.
Constructor today is `(session: GateHandlerSession, toolRegistry, pipeline: ToolCallGatePipeline, runner: GateRunner)`.
- `src/gate-handler-session.ts``GateHandlerSession`, the four-method role the handler's `session` is typed against (`activate`, `resolveAgentName`, `checkPermission`, `createPermissionRequestId`); its own doc comment flags [#329] as the issue that shrinks it.
- `src/handlers/gates/skill-input.ts``describeSkillInputGate(skillName, agentName, preCheck)`, the pure descriptor factory ([#326]); the pipeline imports it unchanged.
- `src/handlers/gates/tool-call-gate-pipeline.ts``ToolCallGatePipeline` + `ToolCallGateInputs`; the structural model this plan mirrors.
- `src/handlers/gates/runner.ts``GateRunner.run(gate, agentName, toolCallId)`; reused per gate ([#323]).
- `src/handlers/gates/types.ts``GateOutcome` (`{ action: "allow" } | { action: "block"; reason }`).
- `src/permission-session.ts``PermissionSession`; owns `checkPermission` and `createPermissionRequestId`, and `implements GateHandlerSession`.
- `src/index.ts` — composition root; constructs the session, `ToolCallGatePipeline`, `GateRunner`, and `PermissionGateHandler`.
Constraints from AGENTS.md and the `code-design` skill:
- Default to dependency injection for non-trivial collaborators; inject the new pipeline rather than constructing it in the handler.
- Use a narrow interface type for an injected collaborator, not the concrete `PermissionSession` (avoids `as unknown as` casts).
- `@typescript-eslint/require-await` is enabled for `src/`: `evaluate` has no `await` of its own (it returns `runner.run(...)`), so it must be a non-`async` function returning `Promise<GateOutcome>`, not an `async` function with no `await`.
- `createPermissionRequestId` reads `Date.now()` / `Math.random()` / `process.pid`; it is a handler-layer (SDK-consumer) helper, not a pure library util, so relocating it into the pipeline module is in keeping with the SDK-boundary rule.
- The pipeline lives under `src/handlers/gates/` (a handler/SDK-consumer layer), so importing the descriptor factory and SDK context types is fine.
## Design Overview
### New collaborator: `SkillInputGatePipeline`
A class in `src/handlers/gates/skill-input-gate-pipeline.ts` that owns the skill-input gate assembly and depends on a narrow `SkillInputGateInputs` interface — never the concrete `PermissionSession`.
```typescript
export interface SkillInputGateInputs {
/** Raw permission check (no session rules) — preserves #326 skill-input semantics. */
checkPermission(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
/** Narrow UI seam: warn the user if an interactive UI is available, else no-op. */
export interface GateNotifier {
warn(message: string): void;
}
export class SkillInputGatePipeline {
constructor(private readonly inputs: SkillInputGateInputs) {}
evaluate(
skillName: string,
agentName: string | null,
notifier: GateNotifier,
runner: GateRunner,
): Promise<GateOutcome> {
const check = this.inputs.checkPermission(
"skill",
{ name: skillName },
agentName ?? undefined,
);
if (check.state === "deny") {
notifier.warn(formatSkillDenyNotice(skillName, agentName));
}
return runner.run(
describeSkillInputGate(skillName, agentName, check),
agentName,
createSkillInputRequestId(),
);
}
}
```
`PermissionSession` satisfies `SkillInputGateInputs` structurally at the construction call site (`new SkillInputGatePipeline(session)`); no `implements` clause is added, so the domain module never imports upward from the handler layer (same pattern `ToolCallGatePipeline` uses).
### Why the pipeline owns the request-id mint (absorbing #330)
The user-settled decision for this plan is to fold request-id generation into the pipeline now rather than deferring to [#330].
`createPermissionRequestId` touches zero session state — it is a misplaced utility on the session god-object whose sole caller is the skill-input assembly.
Folding it in means the pipeline mints its own id and `PermissionSession.createPermissionRequestId` is removed outright, so [#330] is satisfied by this issue and can be closed when this ships.
The minter relocates as a small module-level helper so its format/uniqueness tests have a direct target:
```typescript
/** Mint a unique id for a skill-input permission request. Format preserved from #330. */
export function createSkillInputRequestId(): string {
return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
}
```
The `prefix` parameter is dropped — the pipeline only ever minted `"skill-input"` ids.
### Why the runner is passed per-call, not injected into the pipeline
`GateRunner` is shared: the handler holds it and both `handleToolCall` (via `ToolCallGatePipeline`) and `handleInput` (via `SkillInputGatePipeline`) pass it to `evaluate`.
This mirrors `ToolCallGatePipeline.evaluate(tcc, runner)` exactly and avoids dual ownership of the runner.
### The notifier seam (Tell-Don't-Ask split)
The deny notify needs two facts: the permission decision (`deny`) and whether an interactive UI exists.
The pipeline owns the decision; the UI availability is per-event context.
Splitting them keeps the pipeline free of `ExtensionContext`: the pipeline *tells* the notifier to `warn`, and the notifier (built in the handler from `ctx`) decides whether a UI is present.
```typescript
// handleInput — builds the notifier from ctx, then tells the pipeline to evaluate
const notifier: GateNotifier = {
warn: (message) => {
if (ctx.hasUI) {
ctx.ui.notify(message, "warning");
}
},
};
const outcome = await this.skillInputPipeline.evaluate(
skillName,
agentName,
notifier,
this.runner,
);
```
Net behavior is identical to today's `if (check.state === "deny" && ctx.hasUI) ctx.ui.notify(...)`: the pipeline calls `warn` only on deny; the notifier delivers only when `hasUI`.
The notify message stays untagged (no `[pi-permission-system]` prefix) — distinct from the gate deny reasons the runner routes through `formatDenyReason`:
```typescript
function formatSkillDenyNotice(skillName: string, agentName: string | null): string {
return agentName
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
: `Skill '${skillName}' is not permitted by the current skill policy.`;
}
```
### `handleInput` after the change
```typescript
async handleInput(
event: InputPayload,
ctx: ExtensionContext,
): Promise<InputEventResult> {
this.session.activate(ctx);
const skillName = extractSkillNameFromInput(event.text);
if (!skillName) {
return { action: "continue" };
}
const agentName = this.session.resolveAgentName(ctx);
const notifier: GateNotifier = {
warn: (message) => {
if (ctx.hasUI) {
ctx.ui.notify(message, "warning");
}
},
};
const outcome = await this.skillInputPipeline.evaluate(
skillName,
agentName,
notifier,
this.runner,
);
return outcome.action === "block"
? { action: "handled" }
: { action: "continue" };
}
```
The handler no longer calls `session.checkPermission`, `session.createPermissionRequestId`, or imports `describeSkillInputGate`.
### `GateHandlerSession` shrinks to the context role
```typescript
export interface GateHandlerSession {
activate(ctx: ExtensionContext): void;
resolveAgentName(ctx: ExtensionContext): string | null;
}
```
`checkPermission` and `createPermissionRequestId` are removed; the `PermissionCheckResult` import in `gate-handler-session.ts` goes with them.
`PermissionSession implements GateHandlerSession` still holds (it has both remaining methods); `checkPermission` stays on `PermissionSession` (used by `resolve` and structurally by `SkillInputGateInputs`), while `createPermissionRequestId` is removed entirely.
### Handler constructor
```typescript
constructor(
private readonly session: GateHandlerSession,
private readonly toolRegistry: ToolRegistry,
private readonly pipeline: ToolCallGatePipeline,
private readonly skillInputPipeline: SkillInputGatePipeline,
private readonly runner: GateRunner,
) {}
```
The fifth collaborator sits at the dependency-width threshold; all five are distinct injected collaborators (not a relay bag), and grouping them is [#320]'s concern — track and watch, do not address here.
### Composition-root wiring
```typescript
const skillInputGatePipeline = new SkillInputGatePipeline(session);
const gates = new PermissionGateHandler(
session,
toolRegistry,
toolCallGatePipeline,
skillInputGatePipeline,
gateRunner,
);
```
### Edge cases (all behavior-preserving)
- Non-skill input: `extractSkillNameFromInput` returns `null`; `handleInput` returns `{ action: "continue" }` before the pipeline is touched — `checkPermission` is never called (the existing "does not check permissions for non-skill input" test still holds).
- Deny + no UI: pipeline calls `notifier.warn`; the notifier no-ops because `hasUI` is false — `ctx.ui.notify` is not called.
- Ask + no confirmation: handled inside `runner.run` exactly as today (`confirmation_unavailable`).
- Request-id format/uniqueness: preserved by `createSkillInputRequestId`.
## Module-Level Changes
| File | Change |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/handlers/gates/skill-input-gate-pipeline.ts` | **New.** `SkillInputGateInputs` + `GateNotifier` interfaces, `SkillInputGatePipeline` class, and the `createSkillInputRequestId` + `formatSkillDenyNotice` helpers. |
| `src/handlers/permission-gate-handler.ts` | Add injected `skillInputPipeline` constructor param. Rewrite `handleInput` to build the notifier and delegate to `pipeline.evaluate`. Remove the inline `checkPermission` / notify / `describeSkillInputGate` / `createPermissionRequestId` block and the now-unused `describeSkillInputGate` import; add `GateNotifier` + `SkillInputGatePipeline` imports. |
| `src/gate-handler-session.ts` | Shrink `GateHandlerSession` to `activate` + `resolveAgentName`; remove `checkPermission`, `createPermissionRequestId`, the `PermissionCheckResult` import, and refresh the doc comment (drop the "transitional" note). |
| `src/permission-session.ts` | Remove the `createPermissionRequestId` method; drop any import left unused by its removal. `checkPermission` stays. |
| `src/index.ts` | Construct `SkillInputGatePipeline` and inject it into `PermissionGateHandler` (fifth arg). |
| `test/helpers/handler-fixtures.ts` | `MockGateHandlerSession` and `makeSession` drop `createPermissionRequestId`. `makeHandler` builds a real `SkillInputGatePipeline(session)` and injects it. |
| `test/helpers/gate-fixtures.ts` | Add `makeSkillInputInputs` (mock of `SkillInputGateInputs`) and `makeNotifier` (`GateNotifier` mock with a `warn` `vi.fn()`). |
| `test/handlers/gates/skill-input-gate-pipeline.test.ts` | **New.** Pipeline unit tests. |
| `test/handlers/external-directory-integration.test.ts` | Local `makeHandler` constructs + injects `SkillInputGatePipeline`; local session mock drops `createPermissionRequestId`. |
| `test/handlers/external-directory-session-dedup.test.ts` | Same construction + mock update as above. |
| `test/permission-session.test.ts` | Remove the `createPermissionRequestId` describe block (behavior relocated to the pipeline). |
| `test/composition-root.test.ts` | Verify handler registration / shared-instance wiring is unchanged; update only if it asserts the handler's constructor arity. |
| `docs/architecture/architecture.md` | Add `skill-input-gate-pipeline.ts` to the module tree; refresh the `gate-handler-session.ts` (now two-method) and `permission-gate-handler.ts` (fifth collaborator) descriptions; remove `createPermissionRequestId` from the `permission-session.ts` description; mark Step 12 done and note Step 13 ([#330]) folded into Step 12. |
| `.pi/skills/package-pi-permission-system/SKILL.md` | Document `makeSkillInputInputs` + `makeNotifier` in the `gate-fixtures.ts` inventory. |
Grep confirmation (every removed symbol): `createPermissionRequestId` appears only in `gate-handler-session.ts`, `permission-session.ts`, `permission-gate-handler.ts`, `permission-session.test.ts`, the two `external-directory-*.test.ts` local mocks, and `handler-fixtures.ts` — all listed above.
## Test Impact Analysis
New unit tests the extraction enables (previously only reachable through the full `handleInput` path):
- `skill-input-gate-pipeline.test.ts` — the pipeline in isolation:
- deny → `notifier.warn` called with a message containing the skill name, and `describeSkillInputGate` run with the `preCheck`;
- allow / ask → `notifier.warn` not called; outcome maps from `runner.run`;
- block outcome → `{ action: "block" }`; allow outcome → `{ action: "allow" }`;
- `createSkillInputRequestId` format (`startsWith("skill-input-")`) and uniqueness across calls (relocated from `permission-session.test.ts`).
- Uses `makeSkillInputInputs` (cast-free single-method mock) + `makeGateRunner` (real runner with role mocks) + `makeNotifier`.
Existing tests that become partially redundant but stay (behavior-preserving; trimming deferred to [#321]):
- `input.test.ts`, `input-events.test.ts` — exercise the skill-input flow through `handleInput`; they remain valid integration coverage and still pass through the real handler → pipeline → notifier path after the `makeSession` mock update.
They now overlap with the pipeline unit tests; flag as [#321] candidates, do not delete here.
Existing tests that must stay as-is (genuinely exercise their layer):
- `external-directory-integration.test.ts`, `external-directory-session-dedup.test.ts` — drive the tool-call path; the constructor-arity + mock edits are mechanical, the assertions unchanged.
- `permission-session.test.ts` — only the `createPermissionRequestId` block moves; `checkPermission`, `resolve`, and the rest are untouched.
## TDD Order
1. **Introduce the pipeline.**
Add `SkillInputGateInputs` + `GateNotifier` + `SkillInputGatePipeline` + `createSkillInputRequestId` + `formatSkillDenyNotice` in the new module, plus `makeSkillInputInputs` and `makeNotifier` in `gate-fixtures.ts`.
Write `skill-input-gate-pipeline.test.ts` (deny-notify, allow/ask no-notify, block/allow mapping, request-id format + uniqueness).
The pipeline is not yet wired into the handler.
Run `pnpm run check`.
Commit: `feat: introduce SkillInputGatePipeline collaborator (#329)`.
2. **Inject the pipeline, rewrite `handleInput`, and remove the request-id minter.**
This is one commit because the constructor-arity change and the `GateHandlerSession` / `PermissionSession` shrink break every call site and the `createPermissionRequestId` consumers at the type level simultaneously:
- add the injected `skillInputPipeline` param to `PermissionGateHandler`; rewrite `handleInput` to build the notifier and delegate; drop the `describeSkillInputGate` import;
- shrink `GateHandlerSession` to `activate` + `resolveAgentName`; remove `PermissionSession.createPermissionRequestId`;
- construct + inject `SkillInputGatePipeline` in `index.ts`;
- update `makeHandler` / `makeSession` / `MockGateHandlerSession` and the two `external-directory-*.test.ts` local `makeHandler` + session mocks (construct the pipeline, drop `createPermissionRequestId`);
- remove the `createPermissionRequestId` describe block from `permission-session.test.ts`.
Verify `composition-root.test.ts`.
Run the full suite (`pnpm --filter @gotgenes/pi-permission-system exec vitest run`), `pnpm run check`, and `pnpm fallow dead-code` (confirm `createPermissionRequestId` and the old inline assembly are gone).
Commit: `refactor: delegate skill-input gate construction to injected SkillInputGatePipeline (#329, #330)`.
3. **Update docs.**
Add `skill-input-gate-pipeline.ts` to the `architecture.md` module tree; refresh the `gate-handler-session.ts`, `permission-gate-handler.ts`, and `permission-session.ts` descriptions; mark roadmap Step 12 done and note Step 13 ([#330]) folded in; document `makeSkillInputInputs` + `makeNotifier` in the package SKILL.
Commit: `docs: document SkillInputGatePipeline in architecture and package skill (#329)`.
## Risks and Mitigations
- **Session mocks missing the new shape → runtime surprise.**
`MockGateHandlerSession` casts away from the concrete class, so a dropped field can pass typecheck but fail at runtime.
Mitigation: step 2 updates every session mock on the handler path and runs the full suite, not just the typecheck.
- **Notify behavior drift when the `hasUI` gate moves into the notifier closure.**
Mitigation: the deny/no-deny and UI-present/absent cases are pinned by the existing `input.test.ts` notify tests (unchanged) plus the new pipeline `notifier.warn` unit assertions.
- **Request-id format regression when the minter relocates.**
Mitigation: the format + uniqueness tests move to the pipeline unit test against `createSkillInputRequestId`; the expression is copied verbatim.
- **Closing [#330] prematurely.**
Mitigation: the plan removes `createPermissionRequestId` outright and `pnpm fallow dead-code` confirms no residual caller; [#330] is closed only after this ships.
- **`index.ts` wiring regression.**
Mitigation: `composition-root.test.ts` (the `make-fake-pi.ts` harness) covers handler registration and shared-instance contracts.
## Open Questions
- Whether the handler's five injected collaborators should be grouped — deferred to [#320] (composition-root reframe); track and watch.
- Whether the `input*.test.ts` integration tests should be trimmed once the pipeline unit tests exist — deferred to [#321].
[#320]: https://github.com/gotgenes/pi-packages/issues/320
[#321]: https://github.com/gotgenes/pi-packages/issues/321
[#323]: https://github.com/gotgenes/pi-packages/issues/323
[#326]: https://github.com/gotgenes/pi-packages/issues/326
[#327]: https://github.com/gotgenes/pi-packages/issues/327
[#330]: https://github.com/gotgenes/pi-packages/issues/330
[#331]: https://github.com/gotgenes/pi-packages/issues/331