15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 285 | Decompose handleToolCall in permission-gate-handler.ts |
Decompose handleToolCall into a gate pipeline
Problem Statement
PermissionGateHandler.handleToolCall in src/handlers/permission-gate-handler.ts runs six permission gates in sequence — skill-read, path, external-directory, bash-external-directory, bash-path, and the normal tool gate.
Four of the six gates repeat the same hand-written shape: produce a descriptor, branch on isGateBypass, write the review log, emit the decision, otherwise call runGateCheck and short-circuit on a block result.
fallow health --targets ranks this function as the package's single highest-priority refactoring target: cognitive complexity 52 in a 396-LOC file with CRAP risk 172, the worst in the package — and it sits on the security-critical tool_call decision path where untested complexity is a correctness risk, not only a maintainability one.
This is Phase 2 step 1 of the improvement roadmap in packages/pi-permission-system/docs/architecture/architecture.md.
Goals
- Extract a single
runGatehelper that uniformly handles the bypass log/emit branch, callsrunGateCheck, and returns either a block result orundefined. - Extract the tool-name validation prelude (missing-name / unregistered-tool checks) into a helper returning a discriminated result.
- Collapse the body of
handleToolCallto read as: validate → build context → run an ordered gate pipeline that short-circuits on the first block. - Behavior-preserving: the existing
permission-gate.test.ts,tool-call.test.ts,tool-call-events.test.ts,runner.test.ts, and the external-directory integration suites stay green without modification. - Drive cognitive complexity from 52 toward the
< 15target and dissolve the CRAP-172 hotspot.
Non-Goals
- No change to
runGateCheck(#287),resolvePermissions(#286),bash-path-extractor.ts(#289), orstripJsonComments(#290) — those are sibling Phase 2 steps. - No change to gate behavior, ordering, decision semantics, review-log output, or any gate descriptor shape.
- No change to
GateRunnerDeps,GateDescriptor, orGateResultingates/descriptor.ts. - No formatter threading (Phase 1 step 2 / #282, #266) — this plan only makes that future change cheaper by decomposing the call site first.
handleInputand the two existing pure helpers (getEventInput,extractSkillNameFromInput) are untouched.
Background
Relevant existing modules:
src/handlers/permission-gate-handler.ts—PermissionGateHandlerclass;handleToolCallis the target method.src/handlers/gates/descriptor.ts— definesGateResult = GateDescriptor | GateBypass | null, theisGateBypasstype guard, andGateRunnerDeps.src/handlers/gates/runner.ts—runGateCheck(descriptor, agentName, toolCallId, deps)returns aGateOutcome({ action: "allow" }or{ action: "block"; reason }).src/handlers/gates/types.ts—ToolCallContext(thetccvalue) andGateOutcome.src/tool-registry.ts—getToolNameFromValue(value)returnsstring | null;checkRequestedToolRegistration(toolName, tools)returnsToolRegistrationCheckResult("missing-tool-name" | "registered" | "unregistered").src/permission-prompts.ts—formatMissingToolNameReason(),formatUnknownToolReason(name, available).
Each gate producer differs only in arity and async-ness:
describeSkillReadGate(tcc, getActiveSkillEntries)→GateDescriptor | null(never a bypass).describePathGate(tcc, checkPermission, getSessionRuleset)→GateResult(bypass carrieslogonly).describeExternalDirectoryGate(tcc, infraDirs)→GateResult(bypass carriesloganddecision).describeBashExternalDirectoryGate(tcc, checkPermission, getSessionRuleset)→Promise<GateResult>(bypass carrieslogonly).describeBashPathGate(tcc, checkPermission, getSessionRuleset)→Promise<GateResult>(bypass carrieslogonly).- The normal tool gate is special:
handleToolCallrunscheckPermissionfirst, thendescribeToolGate(tcc, toolCheck)(always a descriptor), then assignstoolDescriptor.preCheck = toolCheck.
Constraints from AGENTS.md / package skill that apply:
- Default to least privilege; the same policy + input must always produce the same decision — the refactor must not perturb ordering or any branch.
- This file already exports two internal-plus-tested pure helpers (
getEventInput,extractSkillNameFromInput); a new exported pure helper follows the established convention, so fallow will not flag it (a test imports it).
Design Overview
Unified runGate helper
A local arrow function inside handleToolCall, closing over tcc and runnerDeps (and the writeReviewLog / emitDecision closures already built there).
It accepts any GateResult and returns a block result or undefined:
const runGate = async (
gate: GateResult,
): Promise<{ block: true; reason: string } | undefined> => {
if (!gate) {
return undefined;
}
if (isGateBypass(gate)) {
if (gate.log) {
writeReviewLog(gate.log.event, gate.log.details);
}
if (gate.decision) {
emitDecision(gate.decision);
}
return undefined;
}
const result = await runGateCheck(
gate,
tcc.agentName,
tcc.toolCallId,
runnerDeps,
);
return result.action === "block"
? { block: true, reason: result.reason }
: undefined;
};
Behavior-preservation note: the bypass branch always handles both log and decision.
Today the path/bash gates only have their log read and the external-directory gate has both read; since those gates never emit a decision on bypass except external-directory, reading gate.decision unconditionally is strictly equivalent (it is undefined for the others).
The skill-read gate never returns a bypass, so routing it through runGate (which calls runGateCheck for descriptors) preserves its current direct-runGateCheck path.
Ordered pipeline
The six gates collapse into an ordered list of producer thunks (some async), iterated with first-block short-circuit:
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
() => describeSkillReadGate(tcc, () => this.session.getActiveSkillEntries()),
() => describePathGate(tcc, checkPermission, getSessionRuleset),
() => describeExternalDirectoryGate(tcc, infraDirs),
() => describeBashExternalDirectoryGate(tcc, checkPermission, getSessionRuleset),
() => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
() => {
const toolCheck = checkPermission(
tcc.toolName,
tcc.input,
tcc.agentName ?? undefined,
getSessionRuleset(),
);
const toolDescriptor = describeToolGate(tcc, toolCheck);
toolDescriptor.preCheck = toolCheck;
return toolDescriptor;
},
];
for (const produce of gateProducers) {
const blocked = await runGate(await produce());
if (blocked) {
return blocked;
}
}
return {};
The infraDirs array (getInfrastructureDirs() + getInfrastructureReadPaths()) is computed once before the loop, exactly as today.
Ordering is identical to the current sequence.
Tool-name validation prelude
Extract a pure, exported helper that composes getToolNameFromValue + checkRequestedToolRegistration + the two reason formatters, returning a discriminated result.
It reads the raw tool name (not the normalized one) to match the current tcc.toolName value exactly:
export type RequestedToolValidation =
| { status: "ok"; toolName: string }
| { status: "block"; reason: string };
export function validateRequestedTool(
event: unknown,
availableTools: readonly ToolInfo[],
): RequestedToolValidation {
const toolName = getToolNameFromValue(event);
if (!toolName) {
return { status: "block", reason: formatMissingToolNameReason() };
}
const check = checkRequestedToolRegistration(toolName, availableTools);
if (check.status === "missing-tool-name") {
return { status: "block", reason: formatMissingToolNameReason() };
}
if (check.status === "unregistered") {
return {
status: "block",
reason: formatUnknownToolReason(
check.requestedToolName,
check.availableToolNames,
),
};
}
return { status: "ok", toolName };
}
handleToolCall then opens with:
const validation = validateRequestedTool(event, this.toolRegistry.getAll());
if (validation.status === "block") {
return { block: true, reason: validation.reason };
}
const toolName = validation.toolName;
The availableTools parameter type reuses whatever getToolNameFromValue's registration check already accepts (the getAll() return type from ToolRegistry); no new type is invented — ISP holds because the helper reads only the tool list.
Edge cases (all unchanged)
- Empty / missing tool name → block with the missing-name reason.
- Unregistered tool → block with the unknown-tool reason (including the available-names list).
- A bypass gate with a
decision(external-directory) still emits it; a bypass gate with only alogstill writes only the log. - A
nullgate result is a no-op. - First gate to block wins; later gates do not run.
Module-Level Changes
src/handlers/permission-gate-handler.ts:
- Add exported
validateRequestedTool+RequestedToolValidationtype (placed with the other pure helpers at the bottom, following the stepdown rule). - Rewrite the body of
handleToolCall: validation prelude call, then therunGateclosure, then the producer-array pipeline replacing the six hand-written gate blocks. - Remove the now-dead direct
checkRequestedToolRegistration/formatMissingToolNameReason/formatUnknownToolReasonusages from the method body (they move into the helper); keep the imports — they are still referenced by the helper in the same file. - No change to
handleInput,getEventInput, orextractSkillNameFromInput.
src/index.ts: no change — new PermissionGateHandler(...) and gates.handleToolCall(...) wiring is untouched.
Documentation:
docs/architecture/architecture.md— line ~493 module listing currently readspermission-gate-handler.ts PermissionGateHandler (...); getEventInput + extractSkillNameFromInput pure helpers; addvalidateRequestedToolto the pure-helper list. Mark Phase 2 step 1 (#285) as completed in the roadmap steps section, and refresh theWorst CRAP riskhealth-metric note forpermission-gate-handler.tsif re-runningfallow healthconfirms the drop (record the new number)..pi/skills/package-pi-permission-system/SKILL.md— no symbol it documents is removed; no change needed.
No file in Module-Level Changes is also claimed unchanged in Non-Goals.
Test Impact Analysis
- New unit tests enabled by the extraction.
validateRequestedToolbecomes directly testable as a pure function: missing/empty name,missing-tool-nameregistration status,unregisteredstatus (asserting the reason includes available names), and theokpath returning the raw tool name. Previously these paths could only be exercised end-to-end throughhandleToolCall. - Tests that become redundant.
None are removed in this plan.
The end-to-end missing-name and unknown-tool assertions in
tool-call.test.tscould in principle be thinned once the unit tests exist, but they also verify the{ block, reason }wiring throughhandleToolCall, so they stay as integration coverage — behavior-preserving means they must keep passing unmodified. - Tests that must stay as-is.
permission-gate.test.ts,runner.test.ts,tool-call.test.ts,tool-call-events.test.ts, andexternal-directory-integration.test.tsgenuinely exercise the gate-orchestration layer being refactored; they are the safety net proving behavior preservation and must remain green without modification.
TDD Order
test:AddvalidateRequestedToolunit tests (newtest/handlers/validate-requested-tool.test.ts, or adescribeblock intool-call.test.ts). Cover: empty/missing name → block + missing reason;missing-tool-namestatus → block;unregistered→ block with available names in the reason;ok→ returns the raw tool name. Red: the export does not exist yet. Suggested commit:test: cover validateRequestedTool extraction.refactor:ExtractvalidateRequestedTool+RequestedToolValidationand wire the prelude inhandleToolCall. Green: new unit tests + all existing handler suites pass. Suggested commit:refactor: extract validateRequestedTool from handleToolCall.refactor:Introduce therunGateclosure and replace the six hand-written gate blocks with the ordered producer pipeline. No new test — the existing handler/integration suites prove behavior preservation; runpnpm --filter @gotgenes/pi-permission-system run testbefore committing. Suggested commit:refactor: decompose handleToolCall into a gate pipeline.docs:Updatearchitecture.md(module listing, Phase 2 step 1 status, CRAP-risk metric) after re-runningfallow health --targetsto capture the new complexity number. Suggested commit:docs: mark Phase 2 step 1 complete in permission-system roadmap.
All four steps are small and individually reviewable; no step requires rewriting an entire large test file. The validation extraction (step 2) and its single call site live in the same file, so the type checker stays satisfied within each commit.
Risks and Mitigations
- Risk: a subtle behavior change in the bypass branch (e.g., emitting a
decisionwhere the old code did not). Mitigation: only the external-directory gate produces a bypassdecision, and its current block already emits it; readinggate.decisionunconditionally is equivalent for thelog-only gates. The external-directory integration suite verifies this. - Risk: gate ordering drift. Mitigation: the producer array preserves the exact six-gate order; the integration tests assert decisions that depend on ordering.
- Risk: the normal tool gate's
preCheckassignment is lost in the producer thunk. Mitigation: the thunk reproduces thecheckPermission → describeToolGate → preChecksequence verbatim;runner.test.tsandtool-call.test.tsexercise thepreCheckpath. - Risk:
validateRequestedToolreturns the normalized name instead of the raw one, shiftingtcc.toolName. Mitigation: the helper returns the rawgetToolNameFromValueresult, matching today'stoolNamebinding; theok-path unit test asserts the raw value.
Open Questions
- Whether to also extract the inline
toolCallIdstring-coercion ternary into a tiny helper — deferred; it is incidental noise, not part of the two named extractions, and removing it now would widen scope without a test-surface payoff. - Whether the end-to-end missing-name/unknown-tool assertions in
tool-call.test.tsshould later be thinned once unit coverage exists — deferred to a future test-dedup pass (#288).