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,372 @@
---
issue: 107
issue_title: "refactor: break handleToolCall into per-gate functions"
---
# Extract per-gate functions from handleToolCall
## Problem Statement
`src/handlers/tool-call.ts` is a ~600-line file whose `handleToolCall` function orchestrates four sequential permission gates inline:
1. **Skill-read gate** — checks whether a `read` targets a skill file.
2. **External-directory gate** — checks whether a file tool targets a path outside CWD (including a Pi infrastructure read bypass).
3. **Bash external-directory gate** — extracts paths from bash commands and checks them against external-directory policy.
4. **Normal tool permission gate** — the standard tool/bash/mcp/skill check.
Each gate follows the same structural pattern (check permission → build message → call `applyPermissionGate()` → emit decision event → handle session approval), but the wiring is inlined and repeated, making the function hard to read and test in isolation.
## Goals
- Extract each gate into its own pure-ish function with a narrow input type.
- Reduce `handleToolCall` to a ~30-line orchestrator that chains gates and short-circuits on block.
- Factor repeated emit-decision / record-session-rule patterns into shared helpers.
- Preserve all existing behavior — this is a strict refactor, not a behavior change.
- Keep all existing tests green throughout.
## Non-Goals
- Adding new gates (e.g., network-access) — that is a follow-up.
- Changing `HandlerDeps` or `ExtensionRuntime` interfaces.
- Changing `PermissionGateParams` or `applyPermissionGate`.
- Modifying permission prompts, decision events, or session-rule logic.
## Background
### Permission surfaces involved
All surfaces are touched indirectly: the tool gate handles `tools / bash / mcp / skill`, the external-directory gate handles `external_directory`, and the skill-read gate handles `skill` (specifically skill-file reads).
### Key modules
| File | Role |
| ---------------------------------- | ----------------------------------------------------------- |
| `src/handlers/tool-call.ts` | The monolith being decomposed |
| `src/handlers/types.ts` | `HandlerDeps` and `PromptPermissionDetails` types |
| `src/permission-gate.ts` | `applyPermissionGate()` — the generic deny/ask/allow gate |
| `src/permission-events.ts` | `emitDecisionEvent()` — the broadcast event emitter |
| `src/session-rules.ts` | `deriveApprovalPattern()` — session-rule recording |
| `src/external-directory.ts` | Path-bearing-tool helpers, Pi infrastructure read detection |
| `src/skill-prompt-sanitizer.ts` | `findSkillPathMatch()` — skill-file matching |
| `tests/handlers/tool-call.test.ts` | 812-line test file exercising the full handler |
### Current structure
`handleToolCall` runs gates sequentially.
Each gate can short-circuit with `{ block: true, reason }`.
If no gate blocks, the function returns `{}` (allow).
The helper functions `deriveDecisionValue`, `deriveResolution`, and `getEventInput` are already at module scope.
## Design Overview
### Gate result type
All gates return a common result type:
```typescript
/** Outcome of a single permission gate evaluation. */
export type GateOutcome =
| { action: "allow" }
| { action: "block"; reason: string };
```
This is simpler than `PermissionGateResult` because session-approval recording is handled internally by each gate before returning.
### Gate context
Each gate receives a narrow context object assembled by the orchestrator, rather than the full `HandlerDeps` bag.
However, since these are internal helpers (not public API) and they all need overlapping subsets of `HandlerDeps`, the pragmatic approach is to pass `HandlerDeps`, the event, and the `ExtensionContext` — the same signature as `handleToolCall` — plus any gate-specific pre-computed values (e.g., `toolName`, `agentName`, `input`).
A shared context struct avoids repeating the pre-validation logic:
```typescript
/** Pre-validated context shared across all gates. */
interface ToolCallContext {
toolName: string;
agentName: string | null;
input: unknown;
toolCallId: string;
cwd: string | undefined;
}
```
### File layout
New files under `src/handlers/gates/`:
| File | Exports |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| `types.ts` | `GateOutcome`, `ToolCallContext` |
| `skill-read.ts` | `evaluateSkillReadGate(ctx, tcc, deps) → Promise<GateOutcome \| null>` |
| `external-directory.ts` | `evaluateExternalDirectoryGate(ctx, tcc, deps) → Promise<GateOutcome \| null>` |
| `bash-external-directory.ts` | `evaluateBashExternalDirectoryGate(ctx, tcc, deps) → Promise<GateOutcome \| null>` |
| `tool.ts` | `evaluateToolGate(ctx, tcc, deps) → Promise<GateOutcome>` |
| `index.ts` | Re-exports |
Gates that may not apply (skill-read, external-directory, bash-external-directory) return `null` when they are not relevant (e.g., tool is not `read`, path is not outside CWD), signaling "no opinion — continue to next gate."
### Orchestrator
`handleToolCall` becomes:
```typescript
export async function handleToolCall(deps, event, ctx) {
deps.runtime.runtimeContext = ctx;
deps.startForwardedPermissionPolling(ctx);
const agentName = deps.resolveAgentName(ctx);
const toolName = getToolNameFromValue(event);
// ... early validation (missing tool, unregistered) ...
const tcc: ToolCallContext = { toolName, agentName, input, toolCallId, cwd: ctx.cwd };
const skillResult = await evaluateSkillReadGate(ctx, tcc, deps);
if (skillResult?.action === "block") return { block: true, reason: skillResult.reason };
const extDirResult = await evaluateExternalDirectoryGate(ctx, tcc, deps);
if (extDirResult?.action === "block") return { block: true, reason: extDirResult.reason };
const bashExtResult = await evaluateBashExternalDirectoryGate(ctx, tcc, deps);
if (bashExtResult?.action === "block") return { block: true, reason: bashExtResult.reason };
const toolResult = await evaluateToolGate(ctx, tcc, deps);
if (toolResult.action === "block") return { block: true, reason: toolResult.reason };
return {};
}
```
### Shared helpers
`deriveDecisionValue` and `deriveResolution` stay in `tool-call.ts` (or move to `gates/helpers.ts`) since multiple gates use them.
## Module-Level Changes
### New files
- `src/handlers/gates/types.ts``GateOutcome`, `ToolCallContext` types.
- `src/handlers/gates/helpers.ts``deriveDecisionValue`, `deriveResolution` (currently private in `tool-call.ts`).
- `src/handlers/gates/skill-read.ts` — skill-read gate logic extracted from lines ~130185 of `tool-call.ts`.
- `src/handlers/gates/external-directory.ts` — external-directory gate logic extracted from lines ~190310, including Pi infrastructure read bypass and session-rule check.
- `src/handlers/gates/bash-external-directory.ts` — bash external-directory gate extracted from lines ~315405.
- `src/handlers/gates/tool.ts` — normal tool gate extracted from lines ~410530.
- `src/handlers/gates/index.ts` — barrel re-exports.
### Changed files
- `src/handlers/tool-call.ts` — replace inline gate logic with calls to extracted functions; move `deriveDecisionValue`, `deriveResolution` to `gates/helpers.ts` or keep in place and export.
- `tests/handlers/tool-call.test.ts` — no changes expected (the public API `handleToolCall` is unchanged; existing tests exercise the full pipeline through the same entry point).
### New test files
- `tests/handlers/gates/helpers.test.ts` — unit tests for `deriveDecisionValue` and `deriveResolution`.
- `tests/handlers/gates/skill-read.test.ts` — unit tests for the skill-read gate in isolation.
- `tests/handlers/gates/external-directory.test.ts` — unit tests for external-directory gate.
- `tests/handlers/gates/bash-external-directory.test.ts` — unit tests for bash external-directory gate.
- `tests/handlers/gates/tool.test.ts` — unit tests for the normal tool gate.
### Documentation
- `docs/architecture/target-architecture.md` — update if it references `tool-call.ts` structure.
## TDD Order
### Step 1: Introduce gate types
1. Create `src/handlers/gates/types.ts` with `GateOutcome` and `ToolCallContext`.
2. Create `src/handlers/gates/index.ts` barrel.
3. Verify build passes.
Commit: `refactor: add gate types for tool-call decomposition (#107)`
### Step 2: Extract helpers (red → green)
`deriveDecisionValue` and `deriveResolution` are currently private module-scope functions.
Extracting them to `src/handlers/gates/helpers.ts` makes them directly unit-testable.
1. Write `tests/handlers/gates/helpers.test.ts` testing:
- `deriveDecisionValue`: returns command for bash, target for mcp, toolName otherwise.
- `deriveResolution`: returns `policy_allow` for allow state, `policy_deny` for deny state.
- `deriveResolution`: returns `user_approved` for ask+allow without session.
- `deriveResolution`: returns `user_approved_for_session` for ask+allow with session.
- `deriveResolution`: returns `auto_approved` for ask+allow with autoApproved flag.
- `deriveResolution`: returns `user_denied` for ask+block with canConfirm.
- `deriveResolution`: returns `confirmation_unavailable` for ask+block without canConfirm.
2. Move `deriveDecisionValue` and `deriveResolution` to `src/handlers/gates/helpers.ts`.
3. Tests go green.
Commit: `refactor: extract gate helper functions (#107)`
### Step 3: Extract skill-read gate (red → green)
The existing integration tests only cover deny and non-skill-path passthrough.
The extracted gate's direct interface enables testing paths that are hard to reach through the full pipeline.
1. Write `tests/handlers/gates/skill-read.test.ts` testing:
- Returns `null` when tool is not `read`.
- Returns `null` when no active skill entries.
- Returns `null` when read path doesn't match any skill.
- Returns `{ action: "allow" }` when skill state is `allow`.
- Returns `{ action: "block", reason }` when skill state is `deny`.
- Returns `{ action: "allow" }` when state is `ask` and user approves.
- Returns `{ action: "block", reason }` when state is `ask` and user denies.
- Returns `{ action: "block" }` when state is `ask` and no UI available (confirmation-unavailable).
- Emits decision event with correct surface (`skill`), resolution, origin, and matchedPattern fields.
2. Implement `src/handlers/gates/skill-read.ts`.
3. Tests go green.
Commit: `refactor: extract evaluateSkillReadGate (#107)`
### Step 4: Extract external-directory gate (red → green)
The existing integration tests miss: confirmation-unavailable, user-denies-ask, and decision event field assertions (resolution, origin, matchedPattern) for each sub-path (infra bypass, session hit, policy gate).
1. Write `tests/handlers/gates/external-directory.test.ts` testing:
- Returns `null` when no CWD.
- Returns `null` when tool is not path-bearing.
- Returns `null` when path is inside CWD.
- Pi infrastructure read bypass — returns `{ action: "allow" }`, emits event with resolution `infrastructure_auto_allowed`, and writes review log.
- Pi infrastructure read bypass respects `config.piInfrastructureReadPaths`.
- Does NOT bypass for write tools targeting infra dirs.
- Session-rule hit — returns `{ action: "allow" }`, emits event with resolution `session_approved` and correct `matchedPattern`.
- Policy deny — returns `{ action: "block" }`, emits event with resolution `policy_deny`.
- Policy ask, user approves once — returns `{ action: "allow" }`, does NOT record session rule.
- Policy ask, user approves for session — records session rule via `deriveApprovalPattern` and returns `{ action: "allow" }`.
- Policy ask, user denies — returns `{ action: "block" }`, emits event with resolution `user_denied`.
- Policy ask, no UI available — returns `{ action: "block" }`, emits event with resolution `confirmation_unavailable`.
2. Implement `src/handlers/gates/external-directory.ts`.
3. Tests go green.
Commit: `refactor: extract evaluateExternalDirectoryGate (#107)`
### Step 5: Extract bash external-directory gate (red → green)
The existing integration tests miss: ask+user approves, ask+user denies, confirmation-unavailable, and multiple-uncovered-paths recording multiple session rules.
1. Write `tests/handlers/gates/bash-external-directory.test.ts` testing:
- Returns `null` when tool is not `bash`.
- Returns `null` when no CWD.
- Returns `null` when command has no external paths.
- Returns `null` when all external paths are session-covered (logs `session_approved`).
- Uncovered paths, policy deny — returns `{ action: "block" }`.
- Uncovered paths, policy ask, user approves once — returns `{ action: "allow" }`, does NOT record session rules.
- Uncovered paths, policy ask, user approves for session — records one session rule per uncovered path.
- Uncovered paths, policy ask, user denies — returns `{ action: "block" }`.
- Uncovered paths, policy ask, no UI available — returns `{ action: "block" }`.
- Mixed covered/uncovered — only uncovered paths appear in the prompt.
2. Implement `src/handlers/gates/bash-external-directory.ts`.
3. Tests go green.
Commit: `refactor: extract evaluateBashExternalDirectoryGate (#107)`
### Step 6: Extract normal tool gate (red → green)
The existing integration tests cover allow/deny/ask+approve/ask+deny and session recording well.
The extracted gate additionally exposes: decision event field assertions per resolution, `deriveDecisionValue` producing the correct value for bash (command) and mcp (target), auto-approved resolution, and the bash-specific vs generic unavailable message.
1. Write `tests/handlers/gates/tool.test.ts` testing:
- Session-rule hit — returns `{ action: "allow" }`, emits event with resolution `session_approved` and correct `matchedPattern`.
- Policy allow — returns `{ action: "allow" }`, emits event with resolution `policy_allow`.
- Policy deny — returns `{ action: "block" }`, emits event with resolution `policy_deny`.
- Policy ask, user approves once — returns `{ action: "allow" }`, emits `user_approved`, does NOT record session rule.
- Policy ask, user approves for session — records session rule via `suggestSessionPattern`, emits `user_approved_for_session`.
- Policy ask, user denies — returns `{ action: "block" }`, emits `user_denied`.
- Policy ask, no UI available — returns `{ action: "block" }`, emits `confirmation_unavailable`.
- Auto-approved decision emits resolution `auto_approved`.
- Bash tool: `deriveDecisionValue` produces the command string; unavailable message includes the command.
- MCP tool: `deriveDecisionValue` produces the target string.
2. Implement `src/handlers/gates/tool.ts`.
3. Tests go green.
Commit: `refactor: extract evaluateToolGate (#107)`
### Step 7: Wire orchestrator and verify existing tests
1. Replace inline gate logic in `handleToolCall` with calls to the four extracted gate functions.
2. Update imports (helpers already moved in step 2).
3. Run full test suite — all 812 lines of `tests/handlers/tool-call.test.ts` must pass unchanged.
4. Run `pnpm run build` to confirm types.
Commit: `refactor: wire handleToolCall to per-gate functions (#107)`
### Step 8: Remove redundant integration tests
After steps 36 provide comprehensive per-gate unit tests, 18 integration tests in `tests/handlers/tool-call.test.ts` become redundant — they exercise gate-internal logic through the full pipeline with no additional fidelity over the direct gate tests.
The orchestrator is now a ~30-line linear chain; one wiring-smoke-test per gate (kept below) is sufficient.
Tests to **remove** (gate-internal logic fully covered by per-gate tests):
From `describe("handleToolCall")`:
- "blocks when tool ask has no UI available" → `tool.test.ts`
- "allows when user approves the ask prompt" → `tool.test.ts`
- "blocks when user denies the ask prompt" → `tool.test.ts`
From `describe("handleToolCall — external-directory gate")`:
- "allows when session has an existing approval for the external path" → `external-directory.test.ts`
- "approves session when user selects approved_for_session" → `external-directory.test.ts`
From `describe("handleToolCall — Pi infrastructure read bypass")` (entire block):
- "skips external-directory gate for read tool targeting an infra dir" → `external-directory.test.ts`
- "does NOT skip gate for write tool targeting an infra dir" → `external-directory.test.ts`
- "does NOT skip gate for read tool targeting a non-infra external path" → `external-directory.test.ts`
- "writes a review log entry when bypassing the gate" → `external-directory.test.ts`
- "respects config piInfrastructureReadPaths for bypass" → `external-directory.test.ts`
From `describe("handleToolCall — bash external-directory gate")`:
- "skips bash external gate when all referenced paths are session-approved" → `bash-external-directory.test.ts`
From `describe("handleToolCall — session-hit detection (normal gate)")` (entire block):
- "skips gate and logs session_approved when bash check returns source=session" → `tool.test.ts`
- "skips gate and logs session_approved when mcp check returns source=session" → `tool.test.ts`
- "does NOT call sessionRules.approve when source is session" → `tool.test.ts`
From `describe("handleToolCall — session recording on approved_for_session")` (entire block):
- "records bash session approval with suggestBashPattern result" → `tool.test.ts`
- "records mcp session approval with suggestMcpPattern result" → `tool.test.ts`
- "records tool session approval with * pattern for read surface" → `tool.test.ts`
- "does NOT call sessionRules.approve when user approves once" → `tool.test.ts`
Tests to **keep** (orchestrator wiring, setup, pre-gate validation):
- `getEventInput` (4 tests) — utility function stays in `tool-call.ts`
- "sets runtime context" — orchestrator setup
- "starts forwarded permission polling" — orchestrator setup
- "blocks when tool name cannot be resolved" — pre-gate validation
- "blocks when tool is not registered" — pre-gate validation
- "returns empty object when tool is allowed" — end-to-end happy-path smoke
- "blocks when tool is denied by policy" — wiring: tool gate block propagates
- "blocks a read of a denied skill path" — wiring: skill-read gate block propagates
- "allows a read of a non-skill path…" — wiring: skill-read null → falls through
- "blocks a read of a path outside cwd when policy is deny" — wiring: ext-dir gate block propagates
- "blocks a bash command referencing an external path…" — wiring: bash-ext-dir gate block propagates
Commit: `test: remove redundant integration tests covered by per-gate units (#107)`
### Step 9: Update architecture docs
1. Update `docs/architecture/target-architecture.md` if it references `tool-call.ts`.
Commit: `docs: update architecture for gate extraction (#107)`
## Risks and Mitigations
| Risk | Mitigation |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Behavioral regression during extraction | All existing integration tests in `tool-call.test.ts` run after each step; the orchestrator's public contract is unchanged. |
| Could this silently weaken a permission? | No — the refactor moves code without changing logic. Gate ordering is preserved. Short-circuit semantics are preserved. No new `"allow"` paths are introduced. |
| Gate functions may need `HandlerDeps` fields that change | Gates use the same `HandlerDeps` interface; no interface changes are planned. |
| Over-decomposition makes the call chain harder to follow | Each gate file is self-contained; the orchestrator is a linear chain. The overall structure is easier to follow than the monolith. |
| Test mocking complexity increases | Gate unit tests construct narrow mocks for their specific gate; existing integration tests continue exercising the full pipeline. |
## Open Questions
- Whether `deriveDecisionValue` and `deriveResolution` should live in `gates/helpers.ts` or stay in `tool-call.ts` and be imported by gates.
Defer until implementation — the answer depends on which feels cleaner once the code is written.
- Whether gate functions should take a narrower subset of `HandlerDeps` or the full bag.
The plan uses the full `HandlerDeps` for pragmatism; narrowing can be a follow-up if it improves testability.