22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 327 | Extract a ToolCallGatePipeline collaborator that owns tool-call gate construction |
Extract a ToolCallGatePipeline collaborator
Problem Statement
PermissionGateHandler.handleToolCall builds six gate producers inline.
To do so it pulls a cluster of anemic getters off the session — getActiveSkillEntries(), getInfrastructureDirs() + getInfrastructureReadPaths() (concatenated by hand), and config (to compute resolveToolPreviewLimits) — then assembles the gates itself.
This is "ask for the ingredients, then assemble": gate-construction work that currently has no owner.
Issue #319 deliberately rejected a single session-implemented "context" interface because that "would just re-expose the session (glomming state)." The anemic getters are the same smell from the other direction: the missing piece is not a wider session interface but a collaborator that owns gate construction and depends on narrow inputs.
Goals
- Introduce a
ToolCallGatePipelinecollaborator that owns the ordered tool-call gate-producer assembly and exposes anevaluate(...)seam. - Construct the pipeline in the composition root (
index.ts) and inject it intoPermissionGateHandler— not construct it inside the handler. - Apply Tell-Don't-Ask narrowings on
PermissionSessionso the pipeline reads clean values, not raw config:getToolPreviewLimits()andgetInfrastructureReadDirs(). - Remove the now-unused
getInfrastructureDirs()/getInfrastructureReadPaths()getters. - Shrink
handleToolCall's direct session reaches to context/identity operations (activate,resolveAgentName), setting up #325. - Behavior-preserving: no change to any permission decision, log entry, or emitted event.
Non-Goals
- Retyping the
PermissionGateHandlerconstructor against narrow role interfaces and dropping theas unknown as PermissionSessioncasts — that is #325, which #327 prepares for. - Relocating the existing
new GateRunner(...)/new GateDecisionReporter(...)construction out of the handler constructor — that is the explicit scope of #320 (composition root) and #325 (inject the pre-built reporter). This plan injects the new collaborator properly from the start but leaves the pre-existing internal construction for its owning issues. - Deleting the handler integration tests that now overlap with the new pipeline unit tests — deferred to #321 (shared test-fixture extraction).
- Unifying
handleInputwith the runner pipeline — that is #326, already landed.
Background
Relevant modules:
src/handlers/permission-gate-handler.ts—PermissionGateHandler;handleToolCallassembles the six gate producers and loops them throughthis.runner.run(...). Constructor is(session, events, toolRegistry, customFormatters?); it buildsGateDecisionReporterandGateRunnerinternally and holdscustomFormattersonly to construct theToolPreviewFormatter.src/handlers/gates/runner.ts—GateRunner.run(gate, agentName, toolCallId)dispatches null / bypass / descriptor; reused per gate (#323).src/handlers/gates/types.ts—ToolCallContext,GateOutcome.src/handlers/gates/*.ts— the pure descriptor factories the producers call (describeSkillReadGate,describePathGate,describeExternalDirectoryGate,describeBashExternalDirectoryGate,describeBashPathGate,describeToolGate,resolveBashCommandCheck) plusBashProgram(#308).src/permission-session.ts—PermissionSession; ownsresolve(PermissionResolver),getActiveSkillEntries(),getInfrastructureDirs()/getInfrastructureReadPaths(), and theconfiggetter.src/tool-preview-formatter.ts—ToolPreviewFormatter,ToolPreviewFormatterOptions,resolveToolPreviewLimits(config)(#266).src/index.ts— composition root; constructs the session, reporter inputs, andPermissionGateHandler.
Constraints from AGENTS.md and the code-design skill:
- Default to dependency injection for non-trivial collaborators; accept them as parameters rather than constructing them internally.
- Use a narrow interface type for an injected collaborator, not the concrete class (avoids forcing
as unknown ascasts in test mocks). @typescript-eslint/require-awaitis enabled forsrc/— keepevaluategenuinelyasync(it awaitsBashProgram.parseandrunner.run).- The pipeline lives under
src/handlers/gates/(a Pi SDK / event-handler consumer layer), so importing the gate factories andToolPreviewFormatteris fine.
Design Overview
New collaborator: ToolCallGatePipeline
A class in src/handlers/gates/tool-call-gate-pipeline.ts that owns the ordered gate-producer assembly and the run loop.
It depends on a narrow ToolCallGateInputs interface (extending PermissionResolver) plus optional custom formatters — never the concrete PermissionSession.
export interface ToolCallGateInputs extends PermissionResolver {
getActiveSkillEntries(): SkillPromptEntry[];
getInfrastructureReadDirs(): string[];
getToolPreviewLimits(): ToolPreviewFormatterOptions;
}
export class ToolCallGatePipeline {
constructor(
private readonly inputs: ToolCallGateInputs,
private readonly customFormatters?: ToolInputFormatterLookup,
) {}
async evaluate(
tcc: ToolCallContext,
runner: GateRunner,
): Promise<GateOutcome> {
// owns: bash-command extraction + single BashProgram.parse,
// formatter construction from getToolPreviewLimits(),
// infraDirs from getInfrastructureReadDirs(),
// the six gate producers, and the run loop returning the first block.
}
}
PermissionSession already supplies resolve and getActiveSkillEntries(); the two new methods (below) complete the contract.
The session is not declared implements ToolCallGateInputs — the structural check happens at the construction call site (new ToolCallGatePipeline(session, ...)), which keeps the domain module permission-session.ts free of an upward import from the handler layer.
Why the pipeline owns the bash parse (settled in planning)
The issue flagged the seam shape as open ("most likely evaluate(tcc, bashProgram, runner)").
Decision: the pipeline owns the bash-command extraction and the single BashProgram.parse — the seam is evaluate(tcc, runner).
The bash command string and BashProgram are purely tool-call gate-construction inputs (handleInput never needs them), so moving the parse into the pipeline is the strongest realization of "the handler tells the pipeline to evaluate a tool call."
Why the runner is passed per-call, not injected into the pipeline
GateRunner is shared: handleInput calls this.runner.run(...) directly, and handleToolCall delegates to the pipeline.
The handler keeps the runner as its member (constructed in its constructor today; relocation is #320's job) and passes it to evaluate.
This avoids dual ownership and avoids pulling runner construction out of the handler — out of scope for #327.
Dependency-injection wiring
The pipeline is constructed in index.ts and injected into the handler:
// index.ts
const toolCallGatePipeline = new ToolCallGatePipeline(session, formatterRegistry);
const gates = new PermissionGateHandler(
session,
pi.events,
toolRegistry,
toolCallGatePipeline,
);
// PermissionGateHandler
constructor(
private readonly session: PermissionSession,
events: PermissionEventBus,
private readonly toolRegistry: ToolRegistry,
private readonly pipeline: ToolCallGatePipeline,
) {
this.reporter = new GateDecisionReporter(session.logger, events);
this.runner = new GateRunner(session, session, session, this.reporter);
}
The handler drops its customFormatters constructor parameter (the pipeline owns the formatter now).
handleToolCall shrinks to: activate → validate tool → resolve agent name → build tcc → await this.pipeline.evaluate(tcc, this.runner) → map the outcome.
Tell-Don't-Ask narrowings on PermissionSession
getToolPreviewLimits(): ToolPreviewFormatterOptions {
return resolveToolPreviewLimits(this.config);
}
getInfrastructureReadDirs(): string[] {
return [
...this.paths.piInfrastructureDirs,
...(this.config.piInfrastructureReadPaths ?? []),
];
}
getInfrastructureReadDirs() replaces the two-method reach plus the handler's hand-rolled concat ([...getInfrastructureDirs(), ...getInfrastructureReadPaths()]).
getToolPreviewLimits() replaces the handler's resolveToolPreviewLimits(session.config) reach.
The config getter stays — getToolPreviewLimits / getInfrastructureReadDirs read it internally, and index.ts still consumes config elsewhere.
Consumer call-site sketch (Tell-Don't-Ask check)
// handleToolCall residual — a single tell, no gate-construction reach-through
const tcc: ToolCallContext = { toolName, agentName, input, toolCallId, cwd: ctx.cwd };
const outcome = await this.pipeline.evaluate(tcc, this.runner);
return outcome.action === "block" ? { block: true, reason: outcome.reason } : {};
The handler no longer reads getActiveSkillEntries, getInfrastructureDirs, getInfrastructureReadPaths, or config in handleToolCall; the pipeline owns those reads through the narrow interface.
Edge cases (all behavior-preserving)
- Non-bash tools:
command/bashProgramarenull; the three bash gates short-circuit exactly as today. - Bash with an empty command: same
nullBashProgram, same fallback to the whole-input check. - Parse-once invariant:
BashProgram.parseruns at most once perevaluate, shared across the three bash gates and the tool gate (#308) — now enforced inside the pipeline. - Infra read bypass: driven by
getInfrastructureReadDirs()returning the combined list, identical to the prior concat.
Module-Level Changes
| File | Change |
|---|---|
src/handlers/gates/tool-call-gate-pipeline.ts |
New. ToolCallGateInputs interface + ToolCallGatePipeline class; owns bash parse, formatter, infra dirs, the six producers, and the run loop. |
src/permission-session.ts |
Add getToolPreviewLimits() and getInfrastructureReadDirs(); import resolveToolPreviewLimits + ToolPreviewFormatterOptions. Later remove getInfrastructureDirs() / getInfrastructureReadPaths(). |
src/handlers/permission-gate-handler.ts |
Add injected pipeline constructor param; drop customFormatters param. Remove inline gate-producer assembly, formatter construction, infraDirs concat, bash parse, and the now-unused imports (resolveToolPreviewLimits, ToolPreviewFormatter, getNonEmptyString, BashProgram, the six gate factories, resolveBashCommandCheck, GateResult). handleInput is unchanged. |
src/index.ts |
Construct ToolCallGatePipeline and pass it to PermissionGateHandler in place of formatterRegistry. |
test/helpers/gate-fixtures.ts |
Add makeGateInputs (mock of ToolCallGateInputs). |
test/handlers/gates/tool-call-gate-pipeline.test.ts |
New. Pipeline unit tests. |
test/helpers/handler-fixtures.ts |
makeHandler builds a real ToolCallGatePipeline from the mocked session and injects it. makeSession adds getToolPreviewLimits + getInfrastructureReadDirs mocks and drops the two old infra getters. |
test/handlers/tool-call-events.test.ts |
Rename the getInfrastructureDirs override to getInfrastructureReadDirs. |
test/handlers/external-directory-integration.test.ts |
Replace the getInfrastructureDirs / getInfrastructureReadPaths mocks with getInfrastructureReadDirs. |
test/handlers/external-directory-session-dedup.test.ts |
Same mock rename as above. |
test/permission-session.test.ts |
Replace the getInfrastructureDirs / getInfrastructureReadPaths unit tests with a getInfrastructureReadDirs test; add a getToolPreviewLimits test. |
test/composition-root.test.ts |
Verify wiring is unchanged after the index.ts injection (handler registration, shared instances). Update only if it asserts the handler's constructor arity. |
docs/architecture/architecture.md |
Add tool-call-gate-pipeline.ts to the module tree; refresh the permission-gate-handler.ts description; note roadmap step 10. |
.pi/skills/package-pi-permission-system/SKILL.md |
Document makeGateInputs in the gate-fixtures.ts inventory. |
Test Impact Analysis
New unit tests the extraction enables (previously only reachable through the full handleToolCall path):
tool-call-gate-pipeline.test.ts— the pipeline in isolation: runs the six gates in order, short-circuits on the first block, returns{ action: "allow" }when all pass, parses the bash command at most once, builds the formatter fromgetToolPreviewLimits(), and usesgetInfrastructureReadDirs()for the external-directory gate. UsesmakeGateInputs(cast-free mock) +makeGateRunner(real runner with role mocks).
Existing tests that become partially redundant but stay (behavior-preserving issue; removal deferred to #321):
tool-call.test.ts,tool-call-events.test.ts— exercise the gate flow throughhandleToolCall; they remain valid integration coverage and still pass after the refactor (with the mock renames above). They now overlap with the pipeline unit tests; flag as #321 simplification candidates, do not delete here.
Existing tests that must stay as-is (genuinely exercise the layer):
external-directory-integration.test.ts,external-directory-session-dedup.test.ts— drive infra-read bypass and session-dedup through the full handler; the mock renames are mechanical and the assertions are unchanged.permission-session.test.ts— the newgetInfrastructureReadDirs/getToolPreviewLimitscases replace the old infra-getter cases;resolveand skill-entry tests are untouched.
TDD Order
-
Add the Tell-Don't-Ask session methods. Add
getToolPreviewLimits()andgetInfrastructureReadDirs()toPermissionSessionalongside the existing getters; cover both inpermission-session.test.ts(combined infra list; preview limits resolved from config). Runpnpm run check. Commit:feat: add getToolPreviewLimits and getInfrastructureReadDirs to PermissionSession (#327). -
Introduce the pipeline. Add
ToolCallGateInputs+ToolCallGatePipelinein the new module andmakeGateInputsingate-fixtures.ts; writetool-call-gate-pipeline.test.ts(gate order, first-block short-circuit, all-allow, parse-once, formatter/infra-dir sourcing). The pipeline is not yet wired into the handler. Runpnpm run check. Commit:feat: introduce ToolCallGatePipeline collaborator (#327). -
Inject the pipeline and delegate. Change the
PermissionGateHandlerconstructor to accept the injectedpipelineand dropcustomFormatters; replace the inline gate assembly inhandleToolCallwithawait this.pipeline.evaluate(tcc, this.runner)and remove the now-unused imports. Construct the pipeline inindex.tsand pass it. Update bothnew PermissionGateHandler(...)call sites (index.ts,makeHandler) in this same commit — the constructor-arity change forces it. UpdatemakeSessionand the local session mocks intool-call-events.test.ts/external-directory-*.test.tsto the new method names. Verifycomposition-root.test.ts. Run the full suite (pnpm --filter @gotgenes/pi-permission-system exec vitest run) andpnpm run check. Commit:refactor: delegate tool-call gate construction to injected ToolCallGatePipeline (#327). -
Remove the dead infra getters. Delete
getInfrastructureDirs()/getInfrastructureReadPaths()fromPermissionSessionand their dedicatedpermission-session.test.tscases (now covered bygetInfrastructureReadDirs). Runpnpm fallow dead-codeto confirm nothing else references them. Commit:refactor: remove unused infrastructure-dir getters from PermissionSession (#327). -
Update docs. Add
tool-call-gate-pipeline.tsto thearchitecture.mdmodule tree, refresh thepermission-gate-handler.tsdescription, note roadmap step 10, and documentmakeGateInputsin the package SKILL. Commit:docs: document ToolCallGatePipeline in architecture and package skill (#327).
Risks and Mitigations
- Session mocks missing the new methods → runtime
undefined(timing bugs the type checker won't catch, sincemakeSessioncasts toPermissionSession). Mitigation: step 3 updates every session mock on the handler/pipeline path and runs the full suite, not just the typecheck. - Behavior drift when the bash parse moves into the pipeline. Mitigation: behavior-preserving extraction; the existing integration tests plus the new parse-once unit test pin the invariant.
- Layer inversion if
permission-session.tsimports the pipeline's interface. Mitigation: noimplementson the session; the structural check lives at thenew ToolCallGatePipeline(session, ...)call site, so the domain module never imports from the handler layer. index.tswiring regression. Mitigation:composition-root.test.ts(themake-fake-pi.tsharness) covers handler registration and shared-instance contracts.