15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 1 | Add integration tests for external_directory tool_call enforcement |
External-directory integration tests
Problem Statement
The external_directory enforcement in the tool_call handler has no integration test coverage at the handler level.
Existing tests cover the policy-resolution layer (PermissionManager.checkPermission) and the pure descriptor function (describeExternalDirectoryGate), but never exercise the wiring that decides whether the external-directory check fires, formats deny/ask messages, interacts with the UI, and writes review-log entries.
This gap allowed a critical upstream bug to ship — four undefined symbols referenced at runtime — because the test suite stopped at the manager layer.
The code has since been refactored into a descriptor + runner architecture:
src/handlers/gates/external-directory.ts— pure descriptor (unit-tested)src/handlers/gates/runner.ts— generic gate runner (unit-tested)src/handlers/permission-gate-handler.ts— wiring (handleToolCall)
The wiring layer — PermissionGateHandler.handleToolCall — has only one external-directory test (tests/handlers/tool-call.test.ts) and zero review-log assertions.
Integration tests at this level are the durable defense against this class of regression.
Goals
- Cover the path-scope matrix: inside CWD (skip), outside CWD (fire), non-path-bearing tool (skip), each
PATH_BEARING_TOOLSmember, optional path omitted (skip). - Cover the policy-state matrix with out-of-cwd paths:
allow,deny,ask(user approves / user denies / no UI). - Assert on both the
{ block, reason }return value andsession.logger.review(review-log) side effects. - Assert on
permissions:decisionevent emissions for each code path. - Verify that removing any of the four
external_directoryhelpers causes test failures (regression guard). - Per-agent override of
external_directoryis honored over the global policy.
Non-Goals
- Full end-to-end tests wiring
piPermissionSystemExtension(stubApi)— the handler-level test is the right seam given the current architecture. - Testing bash external-directory enforcement — that is a separate gate with its own descriptor (
describeBashExternalDirectoryGate) and is out of scope for this issue. - Testing
PermissionManager.checkPermission("external_directory", ...)— already covered intests/permission-system.test.ts. - Refactoring any production code.
Background
Permission surface
external_directory — gates tool calls whose input.path resolves outside the working directory (ctx.cwd).
Path-bearing tools
The set PATH_BEARING_TOOLS in src/path-utils.ts contains: read, write, edit, find, grep, ls.
Of these, find, grep, and ls have optional path — when omitted, the external-directory gate is skipped.
Handler architecture
PermissionGateHandler.handleToolCall (in src/handlers/permission-gate-handler.ts):
- Activates the session, resolves agent name.
- Validates tool name and registration.
- Runs the skill-read gate (descriptor → runner).
- Runs the external-directory gate (descriptor → runner), handling both
GateBypass(infra reads) andGateDescriptor(permission check) results. - Runs the bash external-directory gate (descriptor → runner).
- Runs the normal tool permission gate (descriptor → runner).
Existing test coverage
| File | What it tests | Gap |
|---|---|---|
tests/permission-system.test.ts |
checkPermission("external_directory", ...) |
Policy only; no handler wiring |
tests/handlers/gates/external-directory.test.ts |
describeExternalDirectoryGate pure function |
Descriptor only; no runner/handler |
tests/handlers/gates/runner.test.ts |
runGateCheck generic runner |
Generic; not specific to external_directory |
tests/handlers/tool-call.test.ts |
handleToolCall wiring |
1 external_directory test (deny only) |
tests/handlers/tool-call-events.test.ts |
Decision event emissions | 1 infra auto-allowed test; no external_directory policy matrix |
tests/handlers/external-directory-session-dedup.test.ts |
Session-approval deduplication | Stateful session mocks; complementary |
Test seam
Tests mock PermissionSession (the session boundary) while exercising real descriptor + runner code.
This is the established pattern in tests/handlers/tool-call.test.ts and tests/handlers/tool-call-events.test.ts.
Design Overview
Create a single new test file tests/handlers/external-directory-integration.test.ts that exercises PermissionGateHandler.handleToolCall with the external-directory gate.
Test harness
Reuse the mock-session pattern from tests/handlers/tool-call.test.ts:
makeSession()— stubPermissionSessionwith controllablecheckPermission,canPrompt,prompt,getInfrastructureDirs,getInfrastructureReadPaths.makeCtx()— stubExtensionContextwithcwd,hasUI,ui.makeEvents()— stub event bus capturingemitcalls.makeToolRegistry()— stub tool list including allPATH_BEARING_TOOLSmembers.
Configure checkPermission to return different states depending on the surface argument:
- When called with
"external_directory"→ return the desired test state. - When called with the tool name → return
"allow"(so the tool gate does not interfere).
Test groups
1. Path scope (gate applicability)
Test that handleToolCall correctly skips or fires the external-directory gate based on the tool and path:
- Tool with
input.pathinsidectx.cwd→ no block (external-directory check skipped, falls through to tool gate). - Tool with
input.pathoutsidectx.cwd→ external-directory check fires (policy decides outcome). - Non-path-bearing tool (
bash) with a path-shaped input → external-directory check skipped. - Each
PATH_BEARING_TOOLSmember (read,write,edit,find,grep,ls) gates correctly with an out-of-cwd path. - Tools with optional path (
find,grep,ls) wherepathis omitted → external-directory check skipped.
2. Policy state matrix (out-of-cwd path)
For a read tool with an external path:
external_directory: allow→ no block, no block-type review-log entry, decision event withresolution: "policy_allow"on theexternal_directorysurface.external_directory: deny→{ block: true, reason }wherereasoncontains the external path and the hard-stop hint; review-log entry withresolution: "policy_denied"; decision event withresolution: "policy_deny".external_directory: ask, user approves → no block; decision event withresolution: "user_approved".external_directory: ask, user denies → block with user-denied reason; decision event withresolution: "user_denied".external_directory: ask, user denies withdenialReason→ block reason includes the denial reason.external_directory: ask, no UI → block withconfirmation_unavailablereason; review-log entry withresolution: "confirmation_unavailable"; decision event.
3. Per-agent override
Configure checkPermission to vary its return based on the agentName argument — when the agent name is passed, return allow; otherwise return deny.
Assert that the agent-specific override is honored.
4. Regression guard (helper presence)
Import the four helpers directly and assert they are callable functions:
formatExternalDirectoryDenyReasonformatExternalDirectoryAskPromptformatExternalDirectoryUserDeniedReasonformatExternalDirectoryHardStopHint
If any are removed, the import fails and the entire test file errors.
Mock configuration for surface-aware checkPermission
function makeCheckPermission(
externalDirectoryState: PermissionState,
toolState: PermissionState = "allow",
) {
return vi.fn().mockImplementation(
(surface: string): PermissionCheckResult => {
const state = surface === "external_directory"
? externalDirectoryState
: toolState;
return { state, toolName: surface, source: "tool", origin: "builtin" };
},
);
}
This separates the external-directory policy from the per-tool policy so tests can verify gate ordering.
Module-Level Changes
New files
tests/handlers/external-directory-integration.test.ts— all new integration tests described above.
Unchanged files (verification only)
src/handlers/gates/external-directory.ts— no changes; tests exercise it indirectly viahandleToolCall.src/handlers/gates/external-directory-messages.ts— no changes; regression guard imports its exports.src/handlers/gates/runner.ts— no changes; exercised indirectly.src/handlers/permission-gate-handler.ts— no changes; the SUT.src/path-utils.ts— no changes;PATH_BEARING_TOOLSused in test assertions.
No architecture doc changes
No architecture docs describe the external-directory gate flow in isolation.
The living architecture doc (docs/architecture/architecture.md) does not need updating for test-only changes.
Test Impact Analysis
- New unit tests enabled: The new file covers handler-level integration that was previously impractical because the handler was a 1800-line monolith. The refactored descriptor + runner architecture makes it possible to test gate wiring without mocking internal functions.
- Existing tests that become redundant: None.
The single test in
tests/handlers/tool-call.test.ts("blocks a read of a path outside cwd when policy is deny") is a subset of the new matrix, but it exercises the same layer and is cheap to keep. - Existing tests that must stay: All existing tests in
tests/handlers/gates/external-directory.test.ts(descriptor unit tests),tests/handlers/gates/runner.test.ts(runner unit tests),tests/permission-system.test.ts(policy resolution), andtests/handlers/external-directory-session-dedup.test.ts(session dedup) remain valid — they test different layers.
TDD Order
Cycle 1 — Regression guard: helper imports
Write tests that import the four external-directory-messages helpers and assert they are functions.
These fail if any helper is removed.
- Test surface:
tests/handlers/external-directory-integration.test.ts - Covers: Regression guard — presence of
formatExternalDirectoryDenyReason,formatExternalDirectoryAskPrompt,formatExternalDirectoryUserDeniedReason,formatExternalDirectoryHardStopHint. - Commit:
test: add regression guard for external_directory helper imports (#1)
Cycle 2 — Path scope: gate applicability
Add tests verifying the external-directory gate is skipped or fired based on tool name and path:
- Path inside CWD → not blocked.
- Path outside CWD → blocked when policy is
deny. - Non-path-bearing tool (
bash) → not blocked. - Each
PATH_BEARING_TOOLSmember → blocked when policy isdenyand path is external. - Optional-path tools without
path→ not blocked. - Test surface:
tests/handlers/external-directory-integration.test.ts - Covers: Path scope — gate applicability matrix.
- Commit:
test: add external_directory path-scope integration tests (#1)
Cycle 3 — Policy state matrix: allow and deny
Add tests for external_directory policy states allow and deny with out-of-cwd paths:
allow→ falls through to tool gate, no block.deny→ blocks with deny reason containing the path, review-log entry, decision event.- Test surface:
tests/handlers/external-directory-integration.test.ts - Covers: Policy state —
allowanddenypaths. - Commit:
test: add external_directory allow/deny policy state tests (#1)
Cycle 4 — Policy state matrix: ask (user approves, user denies, no UI)
Add tests for external_directory: ask with out-of-cwd paths:
- User approves → no block, decision event with
user_approved. - User denies → block with user-denied reason, decision event.
- User denies with
denialReason→ block reason includes the denial reason. - No UI available → block with
confirmation_unavailable, review-log entry, decision event. - Test surface:
tests/handlers/external-directory-integration.test.ts - Covers: Policy state —
askpaths (all outcomes). - Commit:
test: add external_directory ask-state integration tests (#1)
Cycle 5 — Per-agent override and decision events
Add tests verifying:
- Per-agent override of
external_directoryis honored (agent-specificcheckPermissionreturn). - Decision events emitted on the
external_directorysurface with correctresolutionfor each code path (consolidate any missing event assertions). - Test surface:
tests/handlers/external-directory-integration.test.ts - Covers: Per-agent override; decision event emissions.
- Commit:
test: add external_directory per-agent override and decision event tests (#1)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No. This is a test-only change; no production code is modified. |
| Tests pass even when helpers are broken | Cycle 1 imports helpers directly — removing them fails the import. Cycles 3–4 assert on message content (deny reason text contains the path), so broken formatting is caught. |
Mock session diverges from real PermissionSession |
Use the same mock pattern as existing tool-call.test.ts and tool-call-events.test.ts. If PermissionSession changes, all three files break together. |
checkPermission mock returns same state for all surfaces, hiding gate ordering bugs |
The makeCheckPermission helper returns different states per surface, so the external-directory gate and tool gate are independently controllable. |
| New test file adds maintenance burden | The file is focused on one gate; the mock factory is reusable. The test matrix matches the issue's acceptance criteria 1:1. |
Open Questions
- None.
The issue is specific about what to test, and the current architecture provides a clean test seam at
handleToolCall.