15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 326 | Unify handleInput's skill-input gate with the GateRunner pipeline |
Unify handleInput's skill-input gate with the GateRunner pipeline
Problem Statement
PermissionGateHandler.handleInput gates /skill:<name> invocations by hand-rolling the same check → log → emit → approve cycle that GateRunner.runDescriptor already owns.
It calls checkPermission directly, builds its own applyPermissionGate(...), emits its own permissions:decision event, and computes the decision resolution with a nested, eslint-disabled ternary that re-implements deriveResolution().
This is the worst-CRAP function in the file (79.4, after handleToolCall was decomposed in #285) and it reaches straight into emitDecision / writeReviewLog / prompt / canPrompt — the very channels that #322 (DecisionReporter) and #323 (GatePrompter) gave owners.
The duplication is also a maintenance trap: a change to the gate cycle (as in #319's resolve() routing) must be made in two places, and the second is easy to miss.
This is preparatory work for #325: collapsing handleInput onto the shared runner removes a large part of the handler's dependency on the concrete PermissionSession, so the eventual role-interface retyping becomes a small change ("make the change easy, then make the easy change").
Goals
- Express the skill-input gate as a
GateDescriptorproduced by a puredescribeSkillInputGate(...)factory and run it through the existingGateRunner. - Delete the inline
applyPermissionGateblock, the nested resolution ternary, and the directemitDecision/writeReviewLog/prompt/canPromptcalls inhandleInput. - Preserve the observable decision-event behavior (surface/value/result/resolution) and the deny-time UI warning exactly.
- Make the skill-input gate logic unit-testable in isolation (it is currently reachable only through
handleInput).
Non-Goals
- Retyping the
PermissionGateHandlerconstructor against role interfaces or dropping theas unknown as PermissionSessioncasts — that is #325. This plan keeps the concrete-class constructor and the existing mocks. - Extracting the tool-call gate pipeline or tightening the
PermissionSessionAPI (getToolPreviewLimits,getInfrastructureReadDirs) — that is #327. - Changing whether skill-input honors session rules.
handleInputresolves viacheckPermission(no session ruleset); this plan preserves that exactly viapreCheck(see Open Questions). - Touching
handleToolCall.
Background
Relevant existing modules:
src/handlers/permission-gate-handler.ts—handleInput(the code being refactored) andhandleToolCall(already runs its gates throughthis.runner). The constructor already buildsthis.runner = new GateRunner(session, session, session, this.reporter)andthis.reporter = new GateDecisionReporter(session.logger, events), sohandleInputcan reuse both with no constructor change.src/handlers/gates/runner.ts—GateRunner.run(gate, agentName, toolCallId)→runDescriptor. For aGateDescriptorwithpreCheckset, it uses that check directly (noresolver.resolve), runsapplyPermissionGate, emits the decision via the reporter, and records session approval only when the descriptor carries asessionApproval.src/handlers/gates/descriptor.ts—GateDescriptorshape (surface,input,denialContext,promptDetails(runner addsrequestId),logContext,decision, optionalpreCheck/preResolved/sessionApproval).src/handlers/gates/skill-read.ts—describeSkillReadGate, the sibling factory this one mirrors (it usespreResolved; this one usespreCheck).src/handlers/gates/helpers.ts—deriveResolution(state, action, hasSession, canConfirm, autoApproved)produces exactly the resolutionshandleInput's ternary computes for the no-session case.src/denial-messages.ts—DenialContextdiscriminated union + three exhaustive body builders (buildDenyBody/buildUnavailableBody/buildUserDeniedBody). Adding askill_inputvariant forces a case in each (TypeScript exhaustiveness — a feature, not a chore).src/permission-prompter.ts—PromptPermissionDetails;toolCallId/toolName/skillNameare optional, so the skill-input descriptor'spromptDetails(source/agentName/message/skillName) typechecks.
AGENTS / skill constraints that apply:
- The skill-input deny/unavailable/user-denied messages will change (see Design Overview) — a deliberate, behavior-affecting decision, documented in the issue. Per the testing skill's TDD rules, any test asserting the old strings must update in the same step as the change.
@typescript-eslint/require-await:handleInputkeeps anawait this.runner.run(...), so it staysasync.- There is no
src/handlers/gates/index.tsbarrel; sibling gate factories are imported directly, so the new factory is imported directly too (no speculative re-export).
Design Overview
describeSkillInputGate factory
A pure factory mirroring describeSkillReadGate, but keyed off a caller-supplied raw preCheck rather than a skill-entry match:
export function describeSkillInputGate(
skillName: string,
agentName: string | null,
preCheck: PermissionCheckResult,
): GateDescriptor {
const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
return {
surface: "skill",
input: { name: skillName },
preCheck,
denialContext: { kind: "skill_input", skillName, agentName: agentName ?? undefined },
promptDetails: { source: "skill_input", agentName, message, skillName },
logContext: { source: "skill_input", skillName, agentName, message },
decision: { surface: "skill", value: skillName },
};
}
It takes only the three values it reads (ISP — no tcc, since skill input is not a tool call).
handleInput after the change
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 check = this.session.checkPermission("skill", { name: skillName }, agentName ?? undefined);
if (check.state === "deny" && ctx.hasUI) {
ctx.ui.notify(/* unchanged deny-warning text */, "warning");
}
const outcome = await this.runner.run(
describeSkillInputGate(skillName, agentName, check),
agentName,
this.session.createPermissionRequestId("skill-input"),
);
return outcome.action === "block" ? { action: "handled" } : { action: "continue" };
}
The runner now owns the prompt (prompter.promptPermission), the review-log writes, the decision-event emission, and the resolution derivation.
Why the decision events are preserved exactly
The runner builds the event via buildDecisionEvent({ surface: "skill", value: skillName }, check, agentName, result, deriveResolution(...)).
For skill input, preCheck.source is never "session" (the raw checkPermission is called without a session ruleset), so the runner's session-hit fast path is unreachable and descriptor.sessionApproval is absent (hasSession is always false).
deriveResolution then yields the identical mapping to handleInput's current ternary:
check.state |
gate action | extra | resolution |
|---|---|---|---|
allow |
allow | — | policy_allow |
deny |
block | — | policy_deny |
ask |
allow | autoApprove | auto_approved |
ask |
allow | — | user_approved |
ask |
block | canConfirm | user_denied |
ask |
block | no UI | confirmation_unavailable |
origin, agentName, and matchedPattern flow from the same check, so the full event matches.
Deliberate change: block-reason messages
Today handleInput passes ad-hoc, tag-less strings to applyPermissionGate (denyReason = the ask-prompt text; unavailableReason = "Skill requires approval, but no interactive UI is available."; userDeniedReason = "User denied skill.").
The runner instead formats messages from descriptor.denialContext via formatDenyReason / formatUnavailableReason / formatUserDeniedReason, which prepend the [pi-permission-system] tag.
A new skill_input DenialContext kind supplies bodies consistent with the skill_read sibling:
deny: Current agent is not permitted to access skill '<name>'.
unavailable: Accessing skill '<name>' requires approval, but no interactive UI is available.
userDenied: User denied access to skill '<name>'.[ Reason: …]
These block reasons are not surfaced to the user for input handling (handleInput returns { action: "handled" } and discards the reason); they appear only in the review log.
No existing input test asserts them.
The change gives skill input the same [pi-permission-system] attribution every other surface already carries.
Module-Level Changes
src/denial-messages.ts— add askill_inputvariant toDenialContext({ kind: "skill_input"; skillName: string; agentName?: string }) and a matchingcase "skill_input":tobuildDenyBody,buildUnavailableBody, andbuildUserDeniedBody. (Grepped: these three switches are the only ones overDenialContext.kind.)src/handlers/gates/skill-input.ts— new:describeSkillInputGate.src/handlers/permission-gate-handler.ts— rewritehandleInputto build the descriptor and callthis.runner.run(...); delete the inlineapplyPermissionGateblock, the nested resolution ternary, and the manualthis.reporter.emitDecision(...); remove the now-unusedapplyPermissionGateimport (used only here) and dropformatSkillAskPromptfrom the#src/permission-promptsimport (moves to the factory).test/handlers/gates/skill-input.test.ts— new: unit tests for the factory (descriptor shape,preCheckpassthrough, message wiring).test/denial-messages.test.ts— addskill_inputcases for the three formatters.test/handlers/input.test.ts— update the "passes agentName in the prompt permission request" test: prompting now flows throughsession.promptPermission(details)(the runner'sGatePrompter), so assert onsession.promptPermission(or the captured details) rather thansession.prompt(ctx, …);expect.anything()no longer matches the (now context-bound) first argument.test/handlers/input-events.test.ts— expected to pass unchanged (resolutions reproduced by the runner); verify, do not edit.
docs/architecture/architecture.md already records this as Phase 3 Step 9 (#326); no further doc edit is needed in this issue.
Test Impact Analysis
- New lower-level tests enabled.
The skill-input gate logic was reachable only through
handleInput; extractingdescribeSkillInputGatemakes the descriptor independently unit-testable (skill-input.test.ts), and the newskill_inputDenialContextgains direct formatter coverage (denial-messages.test.ts). - Redundant tests.
None are removed.
input-events.test.tscontinues to pin the end-to-end resolutions; with the runner now producing them, those assertions also document that the unified path is equivalent. The bespoke-ternary branches are no longer separately reachable, but the event-level tests still cover every resolution. - Tests that must stay as-is.
input.test.ts(activation, skill-name parsing, allow/deny/ask outcomes, deny-warning notify) andinput-events.test.ts(decision events) genuinely exercisehandleInput's contract and remain the behavioral guard for this refactor.runner.test.tsis untouched.
TDD Order
- Add the
skill_inputdenial context. (red→green)- Test surface:
test/denial-messages.test.ts. - Covered:
formatDenyReason/formatUnavailableReason/formatUserDeniedReasonfor askill_inputcontext produce the three tagged bodies above. - Implementation: add the union variant + three switch cases.
- Commit:
feat: add skill_input denial context (#326).
- Test surface:
- Extract
describeSkillInputGateand routehandleInputthrough the runner. (red→green→refactor)- Test surface:
test/handlers/gates/skill-input.test.ts(new, factory unit tests) + the existingtest/handlers/input.test.ts/input-events.test.ts. - Covered: the factory returns the descriptor shape (surface
skill,input.name,preCheckpassthrough,skill_inputdenial context,skill_inputprompt/log source, decision value = skill name);handleInputproduces the same outcomes and decision events throughthis.runner.run(...). - Implementation: add
src/handlers/gates/skill-input.ts; rewritehandleInput; remove the inline gate, ternary, manual emit, and theapplyPermissionGate/formatSkillAskPrompthandler imports; update the oneinput.test.tsprompt assertion to targetpromptPermission. - Run
pnpm --filter @gotgenes/pi-permission-system exec vitest run(full package suite) andpnpm run checkbefore committing — the factory must have asrcconsumer in this same commit (no dead-code window). - Commit:
refactor: route handleInput skill-input gate through GateRunner (#326).
- Test surface:
Risks and Mitigations
- Decision-event drift.
Mitigated by the resolution table above and by
input-events.test.tspassing unchanged; if any resolution differs, that suite fails immediately. preChecktaking the session-hit path. Cannot occur: skill input callscheckPermissionwithout a session ruleset, sosourceis never"session". Documented so a future change that adds session rules here is flagged.- Block-reason message change.
Deliberate (gains the
[pi-permission-system]tag); not asserted by any input test; surfaced only in the review log; called out in the issue anddenial-messages.test.ts. expect.anything()assertion break. Anticipated; the single affectedinput.test.tscase is updated in Step 2.- Unused-import lint after the rewrite.
applyPermissionGateandformatSkillAskPromptbecome unused in the handler; both are removed in Step 2 (eslint would otherwise fail).
Open Questions
- Should skill input honor session rules?
handleInputuses rawcheckPermission(no session ruleset) while everyhandleToolCallgate usesresolve()(session-rule-aware). This plan preserves the raw behavior viapreCheck. Switching toresolve()is a separate, deliberate behavior change and is out of scope here; left as a tracked question on #326.