21 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 107 | 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:
- Skill-read gate — checks whether a
readtargets a skill file. - External-directory gate — checks whether a file tool targets a path outside CWD (including a Pi infrastructure read bypass).
- Bash external-directory gate — extracts paths from bash commands and checks them against external-directory policy.
- 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
handleToolCallto 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
HandlerDepsorExtensionRuntimeinterfaces. - Changing
PermissionGateParamsorapplyPermissionGate. - 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:
/** 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:
/** 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:
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,ToolCallContexttypes.src/handlers/gates/helpers.ts—deriveDecisionValue,deriveResolution(currently private intool-call.ts).src/handlers/gates/skill-read.ts— skill-read gate logic extracted from lines ~130–185 oftool-call.ts.src/handlers/gates/external-directory.ts— external-directory gate logic extracted from lines ~190–310, including Pi infrastructure read bypass and session-rule check.src/handlers/gates/bash-external-directory.ts— bash external-directory gate extracted from lines ~315–405.src/handlers/gates/tool.ts— normal tool gate extracted from lines ~410–530.src/handlers/gates/index.ts— barrel re-exports.
Changed files
src/handlers/tool-call.ts— replace inline gate logic with calls to extracted functions; movederiveDecisionValue,deriveResolutiontogates/helpers.tsor keep in place and export.tests/handlers/tool-call.test.ts— no changes expected (the public APIhandleToolCallis unchanged; existing tests exercise the full pipeline through the same entry point).
New test files
tests/handlers/gates/helpers.test.ts— unit tests forderiveDecisionValueandderiveResolution.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 referencestool-call.tsstructure.
TDD Order
Step 1: Introduce gate types
- Create
src/handlers/gates/types.tswithGateOutcomeandToolCallContext. - Create
src/handlers/gates/index.tsbarrel. - 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.
- Write
tests/handlers/gates/helpers.test.tstesting:deriveDecisionValue: returns command for bash, target for mcp, toolName otherwise.deriveResolution: returnspolicy_allowfor allow state,policy_denyfor deny state.deriveResolution: returnsuser_approvedfor ask+allow without session.deriveResolution: returnsuser_approved_for_sessionfor ask+allow with session.deriveResolution: returnsauto_approvedfor ask+allow with autoApproved flag.deriveResolution: returnsuser_deniedfor ask+block with canConfirm.deriveResolution: returnsconfirmation_unavailablefor ask+block without canConfirm.
- Move
deriveDecisionValueandderiveResolutiontosrc/handlers/gates/helpers.ts. - 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.
- Write
tests/handlers/gates/skill-read.test.tstesting:- Returns
nullwhen tool is notread. - Returns
nullwhen no active skill entries. - Returns
nullwhen read path doesn't match any skill. - Returns
{ action: "allow" }when skill state isallow. - Returns
{ action: "block", reason }when skill state isdeny. - Returns
{ action: "allow" }when state isaskand user approves. - Returns
{ action: "block", reason }when state isaskand user denies. - Returns
{ action: "block" }when state isaskand no UI available (confirmation-unavailable). - Emits decision event with correct surface (
skill), resolution, origin, and matchedPattern fields.
- Returns
- Implement
src/handlers/gates/skill-read.ts. - 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).
- Write
tests/handlers/gates/external-directory.test.tstesting:- Returns
nullwhen no CWD. - Returns
nullwhen tool is not path-bearing. - Returns
nullwhen path is inside CWD. - Pi infrastructure read bypass — returns
{ action: "allow" }, emits event with resolutioninfrastructure_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 resolutionsession_approvedand correctmatchedPattern. - Policy deny — returns
{ action: "block" }, emits event with resolutionpolicy_deny. - Policy ask, user approves once — returns
{ action: "allow" }, does NOT record session rule. - Policy ask, user approves for session — records session rule via
deriveApprovalPatternand returns{ action: "allow" }. - Policy ask, user denies — returns
{ action: "block" }, emits event with resolutionuser_denied. - Policy ask, no UI available — returns
{ action: "block" }, emits event with resolutionconfirmation_unavailable.
- Returns
- Implement
src/handlers/gates/external-directory.ts. - 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.
- Write
tests/handlers/gates/bash-external-directory.test.tstesting:- Returns
nullwhen tool is notbash. - Returns
nullwhen no CWD. - Returns
nullwhen command has no external paths. - Returns
nullwhen all external paths are session-covered (logssession_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.
- Returns
- Implement
src/handlers/gates/bash-external-directory.ts. - 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.
- Write
tests/handlers/gates/tool.test.tstesting:- Session-rule hit — returns
{ action: "allow" }, emits event with resolutionsession_approvedand correctmatchedPattern. - Policy allow — returns
{ action: "allow" }, emits event with resolutionpolicy_allow. - Policy deny — returns
{ action: "block" }, emits event with resolutionpolicy_deny. - Policy ask, user approves once — returns
{ action: "allow" }, emitsuser_approved, does NOT record session rule. - Policy ask, user approves for session — records session rule via
suggestSessionPattern, emitsuser_approved_for_session. - Policy ask, user denies — returns
{ action: "block" }, emitsuser_denied. - Policy ask, no UI available — returns
{ action: "block" }, emitsconfirmation_unavailable. - Auto-approved decision emits resolution
auto_approved. - Bash tool:
deriveDecisionValueproduces the command string; unavailable message includes the command. - MCP tool:
deriveDecisionValueproduces the target string.
- Session-rule hit — returns
- Implement
src/handlers/gates/tool.ts. - Tests go green.
Commit: refactor: extract evaluateToolGate (#107)
Step 7: Wire orchestrator and verify existing tests
- Replace inline gate logic in
handleToolCallwith calls to the four extracted gate functions. - Update imports (helpers already moved in step 2).
- Run full test suite — all 812 lines of
tests/handlers/tool-call.test.tsmust pass unchanged. - Run
pnpm run buildto confirm types.
Commit: refactor: wire handleToolCall to per-gate functions (#107)
Step 8: Remove redundant integration tests
After steps 3–6 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 intool-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
- Update
docs/architecture/target-architecture.mdif it referencestool-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
deriveDecisionValueandderiveResolutionshould live ingates/helpers.tsor stay intool-call.tsand 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
HandlerDepsor the full bag. The plan uses the fullHandlerDepsfor pragmatism; narrowing can be a follow-up if it improves testability.