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,300 @@
|
||||
import { beforeEach, describe, expect, test, 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 },
|
||||
};
|
||||
});
|
||||
|
||||
// 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 { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
describe("AccessPath.forPath", () => {
|
||||
const cwd = "/projects/my-app";
|
||||
|
||||
beforeEach(() => {
|
||||
realpathSync.mockReset();
|
||||
realpathSync.mockImplementation((p: string) => p);
|
||||
});
|
||||
|
||||
describe("matchValues()", () => {
|
||||
test("adds the symlink-resolved alias alongside the typed path", () => {
|
||||
// /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p.startsWith("/tmp") ? `/private${p}` : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("/tmp/x", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).toEqual(["/tmp/x", "/private/tmp/x"]);
|
||||
});
|
||||
|
||||
test("deduplicates when the canonical form equals the lexical form", () => {
|
||||
expect(
|
||||
AccessPath.forPath("/etc/hosts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).toEqual(["/etc/hosts"]);
|
||||
});
|
||||
|
||||
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
||||
expect(
|
||||
AccessPath.forPath("src/foo.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
|
||||
});
|
||||
|
||||
test("includes only the lexical aliases when canonical is empty", () => {
|
||||
// Force canonicalizePath to return the original (no-op symlink resolution
|
||||
// effectively means canonical === lexical, handled by dedup).
|
||||
expect(
|
||||
AccessPath.forPath("/etc/hosts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
test("resolves a relative token against an explicit resolveBase", () => {
|
||||
// The cd-folded effective base differs from cwd (the bash-path case).
|
||||
expect(
|
||||
AccessPath.forPath("foo.ts", {
|
||||
cwd,
|
||||
resolveBase: "/projects/my-app/sub",
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).toEqual(["/projects/my-app/sub/foo.ts", "sub/foo.ts", "foo.ts"]);
|
||||
});
|
||||
|
||||
test("adds the canonical alias resolved against resolveBase", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "/projects/my-app/sub/foo.ts" ? "/real/foo.ts" : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("foo.ts", {
|
||||
cwd,
|
||||
resolveBase: "/projects/my-app/sub",
|
||||
flavor: posixPathFlavor,
|
||||
}).matchValues(),
|
||||
).toEqual([
|
||||
"/projects/my-app/sub/foo.ts",
|
||||
"sub/foo.ts",
|
||||
"foo.ts",
|
||||
"/real/foo.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform option", () => {
|
||||
test("win32: builds lexical/match/boundary values with win32 rules", () => {
|
||||
const ap = AccessPath.forPath("src\\foo.ts", {
|
||||
cwd: "C:\\Projects\\App",
|
||||
flavor: win32PathFlavor,
|
||||
});
|
||||
expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts");
|
||||
expect(ap.boundaryValue()).toBe("c:\\projects\\app\\src\\foo.ts");
|
||||
expect(ap.matchValues()).toEqual([
|
||||
"c:\\projects\\app\\src\\foo.ts",
|
||||
"src\\foo.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("win32: lowercases the symlink-resolved boundary value", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("link", {
|
||||
cwd: "C:\\Projects\\App",
|
||||
flavor: win32PathFlavor,
|
||||
}).boundaryValue(),
|
||||
).toBe("c:\\real\\app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundaryValue()", () => {
|
||||
test("returns the canonical (symlink-resolved) form", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p.startsWith("/tmp") ? `/private${p}` : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("/tmp/x", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).boundaryValue(),
|
||||
).toBe("/private/tmp/x");
|
||||
});
|
||||
|
||||
test("returns the lexical form when path has no symlinks", () => {
|
||||
expect(
|
||||
AccessPath.forPath("/etc/hosts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).boundaryValue(),
|
||||
).toBe("/etc/hosts");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(
|
||||
AccessPath.forPath("", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).boundaryValue(),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("value()", () => {
|
||||
test("returns the lexical (as-typed, normalized) form", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p.startsWith("/tmp") ? `/private${p}` : p,
|
||||
);
|
||||
// Even when the path resolves to a different canonical, value() stays lexical.
|
||||
expect(
|
||||
AccessPath.forPath("/tmp/x", { cwd, flavor: posixPathFlavor }).value(),
|
||||
).toBe("/tmp/x");
|
||||
});
|
||||
|
||||
test("normalizes the path against cwd", () => {
|
||||
// A relative path becomes an absolute lexical value.
|
||||
expect(
|
||||
AccessPath.forPath("src/foo.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).value(),
|
||||
).toBe("/projects/my-app/src/foo.ts");
|
||||
});
|
||||
|
||||
test("normalizes a relative path against an explicit resolveBase", () => {
|
||||
expect(
|
||||
AccessPath.forPath("foo.ts", {
|
||||
cwd,
|
||||
resolveBase: "/projects/my-app/sub",
|
||||
flavor: posixPathFlavor,
|
||||
}).value(),
|
||||
).toBe("/projects/my-app/sub/foo.ts");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(
|
||||
AccessPath.forPath("", { cwd, flavor: posixPathFlavor }).value(),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvedAlias()", () => {
|
||||
const cwd = "/projects/my-app";
|
||||
|
||||
beforeEach(() => {
|
||||
realpathSync.mockReset();
|
||||
realpathSync.mockImplementation((p: string) => p);
|
||||
});
|
||||
|
||||
test("returns the canonical form when a symlink resolves elsewhere", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "/projects/my-app/demo-symlink-passwd" ? "/etc/passwd" : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("demo-symlink-passwd", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).resolvedAlias(),
|
||||
).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
test("returns undefined when the path has no symlinks (canonical equals lexical)", () => {
|
||||
expect(
|
||||
AccessPath.forPath("/etc/hosts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
}).resolvedAlias(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for a literal-only path (no canonical)", () => {
|
||||
expect(AccessPath.forLiteral("foo.ts").resolvedAlias()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty input", () => {
|
||||
expect(
|
||||
AccessPath.forPath("", { cwd, flavor: posixPathFlavor }).resolvedAlias(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("win32: returns the lowercased canonical form for a real symlink target", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "c:\\projects\\app\\link" ? "C:\\Real\\App" : p,
|
||||
);
|
||||
expect(
|
||||
AccessPath.forPath("link", {
|
||||
cwd: "C:\\Projects\\App",
|
||||
flavor: win32PathFlavor,
|
||||
}).resolvedAlias(),
|
||||
).toBe("c:\\real\\app");
|
||||
});
|
||||
|
||||
test("win32: returns undefined for a case-only difference (both forms lowercased)", () => {
|
||||
expect(
|
||||
AccessPath.forPath("src\\foo.ts", {
|
||||
cwd: "C:\\Projects\\App",
|
||||
flavor: win32PathFlavor,
|
||||
}).resolvedAlias(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccessPath.forDevice", () => {
|
||||
test("lexical, boundary, and match values are all the device path", () => {
|
||||
const ap = AccessPath.forDevice("/dev/null");
|
||||
expect(ap.value()).toBe("/dev/null");
|
||||
expect(ap.boundaryValue()).toBe("/dev/null");
|
||||
expect(ap.matchValues()).toEqual(["/dev/null"]);
|
||||
});
|
||||
|
||||
test("resolvedAlias is undefined (canonical equals lexical)", () => {
|
||||
expect(AccessPath.forDevice("/dev/null").resolvedAlias()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccessPath.forLiteral", () => {
|
||||
beforeEach(() => {
|
||||
realpathSync.mockReset();
|
||||
realpathSync.mockImplementation((p: string) => p);
|
||||
});
|
||||
|
||||
test("matchValues() carries only the literal — no canonical, no absolute", () => {
|
||||
expect(AccessPath.forLiteral("foo.ts").matchValues()).toEqual(["foo.ts"]);
|
||||
});
|
||||
|
||||
test("boundaryValue() is empty (no outside-cwd notion for an unknown base)", () => {
|
||||
expect(AccessPath.forLiteral("foo.ts").boundaryValue()).toBe("");
|
||||
});
|
||||
|
||||
test("value() returns the literal", () => {
|
||||
expect(AccessPath.forLiteral("foo.ts").value()).toBe("foo.ts");
|
||||
});
|
||||
|
||||
test("an empty literal yields no match values", () => {
|
||||
expect(AccessPath.forLiteral("").matchValues()).toEqual([]);
|
||||
expect(AccessPath.forLiteral("").value()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { classifyWin32BashToken } from "#src/access-intent/bash/msys-bash-tokens";
|
||||
|
||||
describe("classifyWin32BashToken", () => {
|
||||
describe("device paths", () => {
|
||||
test.each([
|
||||
"/dev/null",
|
||||
"/dev/stdin",
|
||||
"/dev/stdout",
|
||||
"/dev/stderr",
|
||||
])("%s is a device", (token) => {
|
||||
expect(classifyWin32BashToken(token)).toEqual({ kind: "device" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSYS drive mounts", () => {
|
||||
test("translates /c/Users/x to C:\\Users\\x", () => {
|
||||
expect(classifyWin32BashToken("/c/Users/x")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "C:\\Users\\x",
|
||||
});
|
||||
});
|
||||
|
||||
test("uppercases the drive letter", () => {
|
||||
expect(classifyWin32BashToken("/d/secrets/pw.txt")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "D:\\secrets\\pw.txt",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts an already-uppercase mount letter", () => {
|
||||
expect(classifyWin32BashToken("/C/x")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "C:\\x",
|
||||
});
|
||||
});
|
||||
|
||||
test("bare /c translates to the drive root C:\\", () => {
|
||||
expect(classifyWin32BashToken("/c")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "C:\\",
|
||||
});
|
||||
});
|
||||
|
||||
test("trailing-slash /c/ translates to the drive root C:\\", () => {
|
||||
expect(classifyWin32BashToken("/c/")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "C:\\",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("other POSIX absolutes", () => {
|
||||
test.each([
|
||||
"/tmp/foo",
|
||||
"/usr/bin",
|
||||
"/etc/hosts",
|
||||
"/mingw64/bin",
|
||||
])("%s is a posix-absolute", (token) => {
|
||||
expect(classifyWin32BashToken(token)).toEqual({
|
||||
kind: "posix-absolute",
|
||||
});
|
||||
});
|
||||
|
||||
test("a two-letter first segment is not a drive mount", () => {
|
||||
expect(classifyWin32BashToken("/cc/x")).toEqual({
|
||||
kind: "posix-absolute",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plain tokens", () => {
|
||||
test.each([
|
||||
"src/foo.ts",
|
||||
"foo.ts",
|
||||
"C:\\Users\\x",
|
||||
"C:/Users/x",
|
||||
"../up",
|
||||
])("%s is plain", (token) => {
|
||||
expect(classifyWin32BashToken(token)).toEqual({ kind: "plain" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
forEachNestedExecution,
|
||||
NESTED_EXECUTION_CONTEXTS,
|
||||
} from "#src/access-intent/bash/nested-execution";
|
||||
import { getParser, type TSNode } from "#src/access-intent/bash/parser";
|
||||
import type { BashCommandContext } from "#src/types";
|
||||
|
||||
/** Parse a bash snippet and collect every nested execution context found. */
|
||||
async function visitContexts(
|
||||
command: string,
|
||||
): Promise<{ text: string; context: BashCommandContext }[]> {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(command);
|
||||
if (!tree) throw new Error("parser.parse returned null");
|
||||
const found: { text: string; context: BashCommandContext }[] = [];
|
||||
try {
|
||||
forEachNestedExecution(tree.rootNode, (node: TSNode, context) => {
|
||||
found.push({ text: node.text, context });
|
||||
});
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("NESTED_EXECUTION_CONTEXTS", () => {
|
||||
it("maps the substitution node types to their execution context", () => {
|
||||
expect([...NESTED_EXECUTION_CONTEXTS]).toEqual([
|
||||
["command_substitution", "command_substitution"],
|
||||
["process_substitution", "process_substitution"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits subshell, which the command enumerator emits whole", () => {
|
||||
expect(NESTED_EXECUTION_CONTEXTS.has("subshell")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forEachNestedExecution", () => {
|
||||
it("finds a substitution in argument position", async () => {
|
||||
expect(await visitContexts("echo $(rm x)")).toEqual([
|
||||
{ text: "$(rm x)", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a backtick substitution", async () => {
|
||||
expect(await visitContexts("echo `rm x`")).toEqual([
|
||||
{ text: "`rm x`", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a process substitution", async () => {
|
||||
expect(await visitContexts("diff <(cat /etc/shadow)")).toEqual([
|
||||
{ text: "<(cat /etc/shadow)", context: "process_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a substitution hosted in a redirect destination", async () => {
|
||||
expect(await visitContexts("echo hi > $(rm x)")).toEqual([
|
||||
{ text: "$(rm x)", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a substitution hosted in an interpolating heredoc body", async () => {
|
||||
expect(await visitContexts("cat <<EOF\n$(rm e)\nEOF")).toEqual([
|
||||
{ text: "$(rm e)", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds nothing in a quoted heredoc body, which does not interpolate", async () => {
|
||||
expect(await visitContexts("cat <<'EOF'\n$(rm e)\nEOF")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not descend past a context it finds", async () => {
|
||||
// The outer substitution is visited; the inner one is left to the visitor.
|
||||
expect(await visitContexts("echo $(echo $(rm x))")).toEqual([
|
||||
{ text: "$(echo $(rm x))", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds each substitution of a chain in source order", async () => {
|
||||
expect(await visitContexts("echo $(rm a) && echo `rm b`")).toEqual([
|
||||
{ text: "$(rm a)", context: "command_substitution" },
|
||||
{ text: "`rm b`", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds nothing in a command with no nested execution", async () => {
|
||||
expect(await visitContexts("npm install pkg > out.txt")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { homedir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveNodeText,
|
||||
SKIP_SUBTREE_TYPES,
|
||||
} from "#src/access-intent/bash/node-text";
|
||||
import { makeTSNode } from "#test/helpers/fake-ts-node";
|
||||
|
||||
describe("SKIP_SUBTREE_TYPES", () => {
|
||||
it("contains the three node types that must not be descended", () => {
|
||||
expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
|
||||
expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
|
||||
expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not contain common argument node types", () => {
|
||||
expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
|
||||
expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
|
||||
expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNodeText", () => {
|
||||
describe("word nodes", () => {
|
||||
it("returns the node text unchanged", () => {
|
||||
expect(resolveNodeText(makeTSNode("word", "hello"))).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("raw_string nodes (single-quoted)", () => {
|
||||
it("strips surrounding single quotes", () => {
|
||||
expect(resolveNodeText(makeTSNode("raw_string", "'content'"))).toBe(
|
||||
"content",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips single quotes around a path", () => {
|
||||
expect(resolveNodeText(makeTSNode("raw_string", "'/etc/hosts'"))).toBe(
|
||||
"/etc/hosts",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns text as-is when not fully single-quoted", () => {
|
||||
// A raw_string node without enclosing quotes (defensive fallback)
|
||||
expect(resolveNodeText(makeTSNode("raw_string", "noquotes"))).toBe(
|
||||
"noquotes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("string nodes (double-quoted)", () => {
|
||||
it("concatenates inner word children, skipping quote delimiters", () => {
|
||||
const quoteOpen = makeTSNode('"', '"');
|
||||
const content = makeTSNode("string_content", "hello world");
|
||||
const quoteClose = makeTSNode('"', '"');
|
||||
const node = makeTSNode("string", '"hello world"', [
|
||||
quoteOpen,
|
||||
content,
|
||||
quoteClose,
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("concatenates multiple inner children", () => {
|
||||
const quoteOpen = makeTSNode('"', '"');
|
||||
const part1 = makeTSNode("string_content", "foo");
|
||||
const part2 = makeTSNode("simple_expansion", "$BAR");
|
||||
const quoteClose = makeTSNode('"', '"');
|
||||
const node = makeTSNode("string", '"foo$BAR"', [
|
||||
quoteOpen,
|
||||
part1,
|
||||
part2,
|
||||
quoteClose,
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe("foo$BAR");
|
||||
});
|
||||
|
||||
it("returns empty string for an empty double-quoted string", () => {
|
||||
const quoteOpen = makeTSNode('"', '"');
|
||||
const quoteClose = makeTSNode('"', '"');
|
||||
const node = makeTSNode("string", '""', [quoteOpen, quoteClose]);
|
||||
expect(resolveNodeText(node)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("string_content, simple_expansion, and expansion nodes", () => {
|
||||
it("returns text as-is for string_content", () => {
|
||||
expect(resolveNodeText(makeTSNode("string_content", "plain text"))).toBe(
|
||||
"plain text",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a plain $HOME reference to the home directory", () => {
|
||||
// The children matter: the resolver discriminates a plain reference from
|
||||
// an operator-bearing expansion structurally, not by text prefix (#694).
|
||||
const node = makeTSNode("simple_expansion", "$HOME", [
|
||||
makeTSNode("$", "$"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe(homedir());
|
||||
});
|
||||
|
||||
it("resolves a plain ${HOME} reference to the home directory", () => {
|
||||
const node = makeTSNode("expansion", "${HOME}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe(homedir());
|
||||
});
|
||||
|
||||
it("returns text as-is for a variable outside the resolvable set", () => {
|
||||
const node = makeTSNode("expansion", "${VAR}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("variable_name", "VAR"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe("${VAR}");
|
||||
});
|
||||
|
||||
it("returns text as-is for an expansion carrying an operator", () => {
|
||||
const node = makeTSNode("expansion", "${HOME:-/tmp}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
makeTSNode(":-", ":-"),
|
||||
makeTSNode("word", "/tmp"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe("${HOME:-/tmp}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("concatenation nodes", () => {
|
||||
it("concatenates resolved children", () => {
|
||||
const word = makeTSNode("word", "/etc/");
|
||||
const expansion = makeTSNode("simple_expansion", "$FILE", [
|
||||
makeTSNode("$", "$"),
|
||||
makeTSNode("variable_name", "FILE"),
|
||||
]);
|
||||
const node = makeTSNode("concatenation", "/etc/$FILE", [word, expansion]);
|
||||
expect(resolveNodeText(node)).toBe("/etc/$FILE");
|
||||
});
|
||||
|
||||
it("concatenates a resolved $HOME reference with its suffix", () => {
|
||||
const expansion = makeTSNode("simple_expansion", "$HOME", [
|
||||
makeTSNode("$", "$"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
]);
|
||||
const suffix = makeTSNode("word", "/sub");
|
||||
const node = makeTSNode("concatenation", "$HOME/sub", [
|
||||
expansion,
|
||||
suffix,
|
||||
]);
|
||||
expect(resolveNodeText(node)).toBe(`${homedir()}/sub`);
|
||||
});
|
||||
|
||||
it("handles nested concatenation-of-string", () => {
|
||||
// A concatenation whose child is a double-quoted string
|
||||
const quoteOpen = makeTSNode('"', '"');
|
||||
const content = makeTSNode("string_content", "bar");
|
||||
const quoteClose = makeTSNode('"', '"');
|
||||
const inner = makeTSNode("string", '"bar"', [
|
||||
quoteOpen,
|
||||
content,
|
||||
quoteClose,
|
||||
]);
|
||||
const prefix = makeTSNode("word", "foo");
|
||||
const node = makeTSNode("concatenation", 'foo"bar"', [prefix, inner]);
|
||||
expect(resolveNodeText(node)).toBe("foobar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default fallback", () => {
|
||||
it("returns the raw text for unknown node types", () => {
|
||||
expect(resolveNodeText(makeTSNode("unknown_type", "rawtext"))).toBe(
|
||||
"rawtext",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getParser,
|
||||
getWarmBashParser,
|
||||
resetWarmBashParser,
|
||||
warmBashParser,
|
||||
} from "#src/access-intent/bash/parser";
|
||||
|
||||
describe("getParser", () => {
|
||||
it("parses a simple bash command and returns a non-null root node", async () => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse("echo hi");
|
||||
expect(tree).not.toBeNull();
|
||||
expect(tree?.rootNode).toBeDefined();
|
||||
expect(tree?.rootNode.type).toBe("program");
|
||||
tree?.delete();
|
||||
});
|
||||
|
||||
it("returns the same memoized parser instance on repeated calls", async () => {
|
||||
const first = await getParser();
|
||||
const second = await getParser();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warm parser", () => {
|
||||
beforeEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
afterEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
|
||||
it("returns null before the parser is warmed", () => {
|
||||
expect(getWarmBashParser()).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the parser synchronously after warm-up", async () => {
|
||||
await warmBashParser();
|
||||
const parser = getWarmBashParser();
|
||||
expect(parser).not.toBeNull();
|
||||
const tree = parser?.parse("echo hi");
|
||||
expect(tree?.rootNode.type).toBe("program");
|
||||
tree?.delete();
|
||||
});
|
||||
|
||||
it("hands out the same memoized parser as getParser", async () => {
|
||||
await warmBashParser();
|
||||
expect(getWarmBashParser()).toBe(await getParser());
|
||||
});
|
||||
|
||||
it("resetWarmBashParser clears the cached parser", async () => {
|
||||
await warmBashParser();
|
||||
expect(getWarmBashParser()).not.toBeNull();
|
||||
resetWarmBashParser();
|
||||
expect(getWarmBashParser()).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import { homedir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getParser, type TSNode } from "#src/access-intent/bash/parser";
|
||||
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
|
||||
import { makeTSNode } from "#test/helpers/fake-ts-node";
|
||||
|
||||
/** `$NAME` as tree-sitter-bash builds it: a `$` delimiter plus the name. */
|
||||
function simpleExpansion(name: string): TSNode {
|
||||
return makeTSNode("simple_expansion", `$${name}`, [
|
||||
makeTSNode("$", "$"),
|
||||
makeTSNode("variable_name", name),
|
||||
]);
|
||||
}
|
||||
|
||||
/** `${NAME}` as tree-sitter-bash builds it: brace delimiters plus the name. */
|
||||
function bracedExpansion(name: string): TSNode {
|
||||
return makeTSNode("expansion", `\${${name}}`, [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("variable_name", name),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
}
|
||||
|
||||
function findNodeOfType(node: TSNode, type: string): TSNode | null {
|
||||
if (node.type === type) return node;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
const found = child ? findNodeOfType(child, type) : null;
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("resolvePlainVariableExpansion", () => {
|
||||
describe("resolvable variables", () => {
|
||||
it("resolves $HOME to the OS home directory", () => {
|
||||
expect(resolvePlainVariableExpansion(simpleExpansion("HOME"))).toBe(
|
||||
homedir(),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves ${HOME} to the OS home directory", () => {
|
||||
expect(resolvePlainVariableExpansion(bracedExpansion("HOME"))).toBe(
|
||||
homedir(),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves $PWD to the base-relative marker", () => {
|
||||
// The shell's working directory is the projection's effective base, so
|
||||
// the base-relative form resolves correctly after any `cd` folding
|
||||
// without threading a base into this pure function.
|
||||
expect(resolvePlainVariableExpansion(simpleExpansion("PWD"))).toBe(".");
|
||||
});
|
||||
|
||||
it("resolves ${PWD} to the base-relative marker", () => {
|
||||
expect(resolvePlainVariableExpansion(bracedExpansion("PWD"))).toBe(".");
|
||||
});
|
||||
});
|
||||
|
||||
describe("variables outside the resolvable set", () => {
|
||||
it.each([
|
||||
"HOMEDIR",
|
||||
"CURRENT",
|
||||
"PATH",
|
||||
"PWDX",
|
||||
"TMPDIR",
|
||||
])("leaves $%s unresolved", (name) => {
|
||||
expect(resolvePlainVariableExpansion(simpleExpansion(name))).toBeNull();
|
||||
expect(resolvePlainVariableExpansion(bracedExpansion(name))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expansions carrying an operator", () => {
|
||||
it("leaves ${HOME:-/tmp} unresolved", () => {
|
||||
const node = makeTSNode("expansion", "${HOME:-/tmp}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
makeTSNode(":-", ":-"),
|
||||
makeTSNode("word", "/tmp"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolvePlainVariableExpansion(node)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves ${#HOME} unresolved", () => {
|
||||
const node = makeTSNode("expansion", "${#HOME}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("#", "#"),
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolvePlainVariableExpansion(node)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes that are not a plain variable reference", () => {
|
||||
it("returns null for a node with no children", () => {
|
||||
expect(
|
||||
resolvePlainVariableExpansion(makeTSNode("simple_expansion", "$HOME")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a node with no variable_name child", () => {
|
||||
const node = makeTSNode("expansion", "${}", [
|
||||
makeTSNode("${", "${"),
|
||||
makeTSNode("}", "}"),
|
||||
]);
|
||||
expect(resolvePlainVariableExpansion(node)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a variable_assignment naming a resolvable variable", () => {
|
||||
// `HOME=/tmp` binds the name; it is not a reference to its value.
|
||||
const node = makeTSNode("variable_assignment", "HOME=/tmp", [
|
||||
makeTSNode("variable_name", "HOME"),
|
||||
makeTSNode("=", "="),
|
||||
makeTSNode("word", "/tmp"),
|
||||
]);
|
||||
expect(resolvePlainVariableExpansion(node)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fidelity to the shapes tree-sitter-bash actually produces", () => {
|
||||
it.each([
|
||||
["ls $HOME", "simple_expansion", homedir()],
|
||||
["ls ${HOME}", "expansion", homedir()],
|
||||
["ls $PWD", "simple_expansion", "."],
|
||||
["ls ${PWD}", "expansion", "."],
|
||||
["ls ${HOME:-/tmp}", "expansion", null],
|
||||
["ls ${#HOME}", "expansion", null],
|
||||
["ls $HOMEDIR", "simple_expansion", null],
|
||||
])("resolves %s to %s", async (command, nodeType, expected) => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(command);
|
||||
expect(tree).not.toBeNull();
|
||||
if (!tree) return;
|
||||
try {
|
||||
const node = findNodeOfType(tree.rootNode, nodeType);
|
||||
expect(node).not.toBeNull();
|
||||
if (!node) return;
|
||||
expect(resolvePlainVariableExpansion(node)).toBe(expected);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resetWarmBashParser,
|
||||
warmBashParser,
|
||||
} from "#src/access-intent/bash/parser";
|
||||
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
|
||||
|
||||
describe("parseBashCommandsSync", () => {
|
||||
beforeEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
afterEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
|
||||
it("returns null when the parser is not warm", () => {
|
||||
expect(parseBashCommandsSync("echo hi")).toBeNull();
|
||||
});
|
||||
|
||||
describe("once warm", () => {
|
||||
beforeEach(async () => {
|
||||
await warmBashParser();
|
||||
});
|
||||
|
||||
it("returns a single unit for a lone command", () => {
|
||||
expect(parseBashCommandsSync("echo hi")).toEqual([{ text: "echo hi" }]);
|
||||
});
|
||||
|
||||
it("decomposes a chained command into its units", () => {
|
||||
expect(parseBashCommandsSync("cd /repo && npm install x")).toEqual([
|
||||
{ text: "cd /repo" },
|
||||
{ text: "npm install x" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("descends into a command substitution, tagging its context", () => {
|
||||
expect(parseBashCommandsSync("echo $(rm -rf /)")).toEqual([
|
||||
{ text: "echo $(rm -rf /)" },
|
||||
{ text: "rm -rf /", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags an opaque wrapper", () => {
|
||||
expect(parseBashCommandsSync('bash -c "rm -rf /"')).toEqual([
|
||||
{
|
||||
text: 'bash -c "rm -rf /"',
|
||||
wrapperKind: "opaque-payload",
|
||||
executedUnit: "rm -rf /",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for a comment-only command", () => {
|
||||
expect(parseBashCommandsSync("# just a comment")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty command", () => {
|
||||
expect(parseBashCommandsSync("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
classifyBareTokenCandidate,
|
||||
classifyTokenAsPathCandidate,
|
||||
classifyTokenAsRuleCandidate,
|
||||
} from "#src/access-intent/bash/token-classification";
|
||||
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
// ── Shared rejection behaviour ─────────────────────────────────────────────
|
||||
//
|
||||
// Both classifiers delegate to the private `rejectNonPathToken` predicate for
|
||||
// the six shared rejection cases tested below. Testing via both exports
|
||||
// pins that predicate through each caller.
|
||||
|
||||
describe("classifyTokenAsPathCandidate", () => {
|
||||
describe("shared rejection: rejectNonPathToken", () => {
|
||||
test("empty string → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("")).toBeNull();
|
||||
});
|
||||
|
||||
test("flag (leading dash) → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("-r")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
|
||||
});
|
||||
|
||||
test("env assignment (= before any /) → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
|
||||
});
|
||||
|
||||
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
||||
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
|
||||
// Starts with /, so path candidate accepts it.
|
||||
expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
|
||||
});
|
||||
|
||||
test("URL → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("@scope/package → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
|
||||
});
|
||||
|
||||
test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
|
||||
// @/ passes the @ guard; then for path candidate it doesn't start with /
|
||||
// or ~/, and doesn't contain .., so it returns null anyway from the
|
||||
// acceptance gate — but the rejection is not due to the @ guard.
|
||||
// This test documents that @/ is not rejected by the shared rejection.
|
||||
// The path classifier then rejects it for not matching any acceptance shape.
|
||||
expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
test("regex metacharacters → null", () => {
|
||||
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
|
||||
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path-candidate acceptance gate", () => {
|
||||
test("absolute path (starts with /) → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
|
||||
expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
|
||||
expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
|
||||
"/home/user/file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
test("bare-slash token (filesystem root) → returned as-is", () => {
|
||||
// `find /` scans the whole filesystem from root — a deliberate
|
||||
// external-directory access the gate must see, not drop (#583).
|
||||
expect(classifyTokenAsPathCandidate("/")).toBe("/");
|
||||
expect(classifyTokenAsPathCandidate("//")).toBe("//");
|
||||
expect(classifyTokenAsPathCandidate("///")).toBe("///");
|
||||
});
|
||||
|
||||
test("home-relative path (starts with ~/) → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
|
||||
expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
|
||||
"~/.ssh/config",
|
||||
);
|
||||
});
|
||||
|
||||
test("parent-traversal (contains ..) → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
|
||||
"../../etc/passwd",
|
||||
);
|
||||
expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
|
||||
expect(classifyTokenAsPathCandidate("..")).toBe("..");
|
||||
});
|
||||
|
||||
test("plain word with no path shape → null", () => {
|
||||
expect(classifyTokenAsPathCandidate("hello")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
|
||||
});
|
||||
|
||||
test("dot-file (starts with .) → null (strict path gate)", () => {
|
||||
// Path candidate does NOT accept dot-files; rule candidate does.
|
||||
expect(classifyTokenAsPathCandidate(".env")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
|
||||
});
|
||||
|
||||
test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
|
||||
// Path candidate does NOT accept bare relative paths; rule candidate does.
|
||||
expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows drive-letter acceptance gate", () => {
|
||||
test("forward-slash drive path → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
|
||||
"C:/Windows/win.ini",
|
||||
);
|
||||
expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
|
||||
"D:/secrets/password.txt",
|
||||
);
|
||||
});
|
||||
|
||||
test("backslash drive path → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
|
||||
"C:\\Windows\\win.ini",
|
||||
);
|
||||
expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
|
||||
"D:\\secrets\\password.txt",
|
||||
);
|
||||
});
|
||||
|
||||
test("lowercase drive letter → returned as-is", () => {
|
||||
expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
|
||||
});
|
||||
|
||||
test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
|
||||
// c:// matches URL_PATTERN before the drive-letter check runs.
|
||||
expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
|
||||
});
|
||||
|
||||
test("drive-relative path without separator (C:foo) → null", () => {
|
||||
// No / or \ after the colon — not an absolute drive path per node:path.
|
||||
expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyTokenAsRuleCandidate", () => {
|
||||
describe("shared rejection: rejectNonPathToken", () => {
|
||||
test("empty string → null", () => {
|
||||
expect(classifyTokenAsRuleCandidate("", posixPathFlavor)).toBeNull();
|
||||
});
|
||||
|
||||
test("flag (leading dash) → null", () => {
|
||||
expect(classifyTokenAsRuleCandidate("-r", posixPathFlavor)).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("--recursive", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("env assignment (= before any /) → null", () => {
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("FOO=/bar", posixPathFlavor),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("HOME=/home/user", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
||||
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
|
||||
// Contains /, so rule candidate accepts it.
|
||||
expect(classifyTokenAsRuleCandidate("/foo=bar", posixPathFlavor)).toBe(
|
||||
"/foo=bar",
|
||||
);
|
||||
});
|
||||
|
||||
test("URL → null", () => {
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("https://example.com", posixPathFlavor),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("http://localhost:3000", posixPathFlavor),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("file:///tmp/foo", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("@scope/package → null", () => {
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("@foo/bar", posixPathFlavor),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("@scope/pkg", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("regex metacharacters → null", () => {
|
||||
expect(classifyTokenAsRuleCandidate("foo.*", posixPathFlavor)).toBeNull();
|
||||
expect(classifyTokenAsRuleCandidate("bar.+", posixPathFlavor)).toBeNull();
|
||||
expect(classifyTokenAsRuleCandidate("a\\|b", posixPathFlavor)).toBeNull();
|
||||
expect(classifyTokenAsRuleCandidate("[abc]", posixPathFlavor)).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("^/start", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rule-candidate acceptance gate (broader than path)", () => {
|
||||
test("absolute path (starts with /) → returned as-is", () => {
|
||||
expect(classifyTokenAsRuleCandidate("/etc/hosts", posixPathFlavor)).toBe(
|
||||
"/etc/hosts",
|
||||
);
|
||||
});
|
||||
|
||||
test("bare-slash token (filesystem root) → returned as-is", () => {
|
||||
// Root is a path-shaped token via `hasPathSeparator`; a `path` rule for
|
||||
// `/` must be able to match it, same as any other absolute (#583).
|
||||
expect(classifyTokenAsRuleCandidate("/", posixPathFlavor)).toBe("/");
|
||||
expect(classifyTokenAsRuleCandidate("//", posixPathFlavor)).toBe("//");
|
||||
});
|
||||
|
||||
test("home-relative path (starts with ~/) → returned as-is", () => {
|
||||
expect(classifyTokenAsRuleCandidate("~/Documents", posixPathFlavor)).toBe(
|
||||
"~/Documents",
|
||||
);
|
||||
});
|
||||
|
||||
test("parent-traversal (contains ..) → returned as-is", () => {
|
||||
expect(classifyTokenAsRuleCandidate("../foo", posixPathFlavor)).toBe(
|
||||
"../foo",
|
||||
);
|
||||
expect(classifyTokenAsRuleCandidate("..", posixPathFlavor)).toBe("..");
|
||||
});
|
||||
|
||||
test("dot-file (starts with .) → returned as-is", () => {
|
||||
// Rule candidate accepts dot-files; path candidate does not.
|
||||
expect(classifyTokenAsRuleCandidate(".env", posixPathFlavor)).toBe(
|
||||
".env",
|
||||
);
|
||||
expect(classifyTokenAsRuleCandidate(".gitignore", posixPathFlavor)).toBe(
|
||||
".gitignore",
|
||||
);
|
||||
});
|
||||
|
||||
test("current-dir relative (starts with ./) → returned as-is", () => {
|
||||
expect(classifyTokenAsRuleCandidate("./src", posixPathFlavor)).toBe(
|
||||
"./src",
|
||||
);
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("./build/output.js", posixPathFlavor),
|
||||
).toBe("./build/output.js");
|
||||
});
|
||||
|
||||
test("relative path containing / → returned as-is", () => {
|
||||
// Rule candidate accepts any token with / (not already rejected).
|
||||
expect(classifyTokenAsRuleCandidate("src/foo.ts", posixPathFlavor)).toBe(
|
||||
"src/foo.ts",
|
||||
);
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate(
|
||||
"packages/pi-foo/index.ts",
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe("packages/pi-foo/index.ts");
|
||||
});
|
||||
|
||||
test("plain word with no path shape → null", () => {
|
||||
expect(classifyTokenAsRuleCandidate("hello", posixPathFlavor)).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("myfile.txt", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows drive-letter acceptance gate", () => {
|
||||
test("forward-slash drive path → returned as-is", () => {
|
||||
// Forward-slash form was already accepted via token.includes("/").
|
||||
// The explicit branch makes it first-class and order-independent.
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("C:/Windows/win.ini", posixPathFlavor),
|
||||
).toBe("C:/Windows/win.ini");
|
||||
});
|
||||
|
||||
test("backslash drive path → returned as-is (new: no forward slash)", () => {
|
||||
// Previously dropped by both classifiers; the backslash form has no /
|
||||
// so the includes("/") branch could not catch it.
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate(
|
||||
"D:\\secrets\\password.txt",
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe("D:\\secrets\\password.txt");
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("C:\\Windows\\win.ini", posixPathFlavor),
|
||||
).toBe("C:\\Windows\\win.ini");
|
||||
});
|
||||
|
||||
test("lowercase drive letter (backslash) → returned as-is", () => {
|
||||
expect(classifyTokenAsRuleCandidate("c:\\foo", posixPathFlavor)).toBe(
|
||||
"c:\\foo",
|
||||
);
|
||||
});
|
||||
|
||||
test("drive-relative path without separator (C:foo) → null", () => {
|
||||
expect(classifyTokenAsRuleCandidate("C:foo", posixPathFlavor)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows backslash-relative acceptance gate (win32 flavor, #520)", () => {
|
||||
test("backslash-relative token accepted under the win32 flavor", () => {
|
||||
expect(classifyTokenAsRuleCandidate("dir\\file", win32PathFlavor)).toBe(
|
||||
"dir\\file",
|
||||
);
|
||||
});
|
||||
|
||||
test("backslash-relative token rejected under the posix flavor", () => {
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("dir\\file", posixPathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("backslash regex-metacharacter token still rejected under the win32 flavor", () => {
|
||||
// rejectNonPathToken's REGEX_METACHAR_PATTERN fires before the separator
|
||||
// branch is reached, regardless of flavor.
|
||||
expect(classifyTokenAsRuleCandidate("a\\|b", win32PathFlavor)).toBeNull();
|
||||
expect(
|
||||
classifyTokenAsRuleCandidate("\\(group\\)", win32PathFlavor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("backslash traversal accepted regardless of flavor (already via ..)", () => {
|
||||
expect(classifyTokenAsRuleCandidate("..\\secret", posixPathFlavor)).toBe(
|
||||
"..\\secret",
|
||||
);
|
||||
expect(classifyTokenAsRuleCandidate("..\\secret", win32PathFlavor)).toBe(
|
||||
"..\\secret",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rule-vs-path divergence", () => {
|
||||
const dotFiles = [".env", ".gitignore", ".eslintrc"];
|
||||
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
|
||||
|
||||
for (const tok of dotFiles) {
|
||||
test(`dot-file "${tok}": rule accepts, path rejects`, () => {
|
||||
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
|
||||
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
for (const tok of relPaths) {
|
||||
test(`relative path "${tok}": rule accepts, path rejects`, () => {
|
||||
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
|
||||
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
|
||||
for (const tok of sharedAccepted) {
|
||||
test(`"${tok}": both classifiers accept`, () => {
|
||||
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
|
||||
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
||||
});
|
||||
}
|
||||
|
||||
const winDrivePaths = [
|
||||
"C:/Windows/win.ini",
|
||||
"D:\\secrets\\password.txt",
|
||||
"c:/foo",
|
||||
];
|
||||
for (const tok of winDrivePaths) {
|
||||
test(`Windows drive path "${tok}": both classifiers accept`, () => {
|
||||
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
|
||||
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
||||
});
|
||||
}
|
||||
|
||||
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
|
||||
for (const tok of sharedRejected) {
|
||||
test(`"${tok}": both classifiers reject`, () => {
|
||||
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBeNull();
|
||||
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyBareTokenCandidate", () => {
|
||||
// Prelude-only: returns the token when nothing about its *shape* rules out
|
||||
// being a path. Whether it names a real entry is the existence probe's
|
||||
// question, decided by the resolver (ADR 0009), not by this classifier.
|
||||
|
||||
test("bare word → returned unchanged", () => {
|
||||
expect(classifyBareTokenCandidate("id_rsa")).toBe("id_rsa");
|
||||
expect(classifyBareTokenCandidate("key.pem")).toBe("key.pem");
|
||||
expect(classifyBareTokenCandidate("outside-link")).toBe("outside-link");
|
||||
});
|
||||
|
||||
test("bare word that names no file is still returned — existence is not its question", () => {
|
||||
expect(classifyBareTokenCandidate("status")).toBe("status");
|
||||
expect(classifyBareTokenCandidate("build")).toBe("build");
|
||||
});
|
||||
|
||||
test("consults no policy — identical result for every token of the same shape", () => {
|
||||
expect(classifyBareTokenCandidate("anything")).toBe("anything");
|
||||
});
|
||||
|
||||
describe("shared rejection prelude", () => {
|
||||
test("flag (leading dash) → null", () => {
|
||||
expect(classifyBareTokenCandidate("-r")).toBeNull();
|
||||
expect(classifyBareTokenCandidate("--recursive")).toBeNull();
|
||||
});
|
||||
|
||||
test("env assignment → null", () => {
|
||||
expect(classifyBareTokenCandidate("FOO=/bar")).toBeNull();
|
||||
});
|
||||
|
||||
test("URL → null", () => {
|
||||
expect(classifyBareTokenCandidate("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
test("@scope/package → null", () => {
|
||||
expect(classifyBareTokenCandidate("@foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
test("regex metacharacters → null", () => {
|
||||
expect(classifyBareTokenCandidate("foo.*")).toBeNull();
|
||||
});
|
||||
|
||||
test("empty string → null", () => {
|
||||
expect(classifyBareTokenCandidate("")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import { getParser } from "#src/access-intent/bash/parser";
|
||||
import {
|
||||
collectCommandTokens,
|
||||
collectPathCandidateTokens,
|
||||
collectRedirectTokens,
|
||||
extractCommandName,
|
||||
} from "#src/access-intent/bash/token-collection";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Depth-first search for the first node of the given type. */
|
||||
function findNode(node: TSNode, type: string): TSNode | null {
|
||||
if (node.type === type) return node;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
const found = findNode(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a bash snippet and return the first `command` node. */
|
||||
async function parseCommandNode(cmd: string): Promise<{
|
||||
node: TSNode;
|
||||
tree: { rootNode: TSNode; delete(): void };
|
||||
}> {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(cmd);
|
||||
if (!tree) throw new Error("parser.parse returned null");
|
||||
const node = findNode(tree.rootNode, "command");
|
||||
if (!node) throw new Error(`no command node found in: ${cmd}`);
|
||||
return { node, tree };
|
||||
}
|
||||
|
||||
/** Parse a bash snippet and return the first `file_redirect` node. */
|
||||
async function parseRedirectNode(cmd: string): Promise<{
|
||||
node: TSNode;
|
||||
tree: { rootNode: TSNode; delete(): void };
|
||||
}> {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(cmd);
|
||||
if (!tree) throw new Error("parser.parse returned null");
|
||||
const node = findNode(tree.rootNode, "file_redirect");
|
||||
if (!node) throw new Error(`no file_redirect node found in: ${cmd}`);
|
||||
return { node, tree };
|
||||
}
|
||||
|
||||
// ── extractCommandName ────────────────────────────────────────────────────────
|
||||
|
||||
describe("extractCommandName", () => {
|
||||
it("returns the basename for a bare command", async () => {
|
||||
const { node, tree } = await parseCommandNode("sed 's/x/y/' file.txt");
|
||||
try {
|
||||
expect(extractCommandName(node)).toBe("sed");
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the directory prefix from an absolute command path", async () => {
|
||||
const { node, tree } = await parseCommandNode(
|
||||
"/usr/bin/sed 's/x/y/' file.txt",
|
||||
);
|
||||
try {
|
||||
expect(extractCommandName(node)).toBe("sed");
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the substitution text when the command name is a command substitution", async () => {
|
||||
// $(which sed) parses with a command_name child whose text is "$(which sed)";
|
||||
// resolveNodeText returns that text, so extractCommandName returns its basename.
|
||||
// PATTERN_FIRST_COMMANDS.get("$(which sed)") returns undefined, so
|
||||
// collectCommandTokens falls back to generic collection — correct behaviour.
|
||||
const { node, tree } = await parseCommandNode(
|
||||
"$(which sed) 's/x/y/' file.txt",
|
||||
);
|
||||
try {
|
||||
expect(extractCommandName(node)).toBe("$(which sed)");
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectCommandTokens — pattern-first commands ─────────────────────────────
|
||||
|
||||
describe("collectCommandTokens — pattern-first commands", () => {
|
||||
it("sed: skips the first positional (inline pattern) and collects the rest", async () => {
|
||||
const { node, tree } = await parseCommandNode("sed 's/x/y/' a.txt b.txt");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["a.txt", "b.txt"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("sed -e: skips the explicit script arg-consuming flag and collects positionals", async () => {
|
||||
const { node, tree } = await parseCommandNode("sed -e 's/x/y/' file.txt");
|
||||
try {
|
||||
// -e consumes the next argument (the script), so file.txt is the first positional
|
||||
// Since hasExplicitScript is set by -e, the positional is not skipped
|
||||
expect(collectCommandTokens(node)).toEqual(["file.txt"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("sed -f: treats the next argument as a file path (file-consuming flag)", async () => {
|
||||
const { node, tree } = await parseCommandNode(
|
||||
"sed -f /scripts/script.sed file.txt",
|
||||
);
|
||||
try {
|
||||
// -f consumes the next arg as a file path (extracted), and sets hasExplicitScript
|
||||
expect(collectCommandTokens(node)).toEqual([
|
||||
"/scripts/script.sed",
|
||||
"file.txt",
|
||||
]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("grep: skips the first positional (pattern) and collects file arguments", async () => {
|
||||
const { node, tree } = await parseCommandNode(
|
||||
"grep pattern /etc/hosts /etc/passwd",
|
||||
);
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("grep -e: with explicit -e flag, all positionals are file arguments", async () => {
|
||||
const { node, tree } = await parseCommandNode("grep -e pattern /etc/hosts");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("grep: end-of-flags (--) causes subsequent args to be treated as positionals", async () => {
|
||||
const { node, tree } = await parseCommandNode("grep -- pattern /etc/hosts");
|
||||
try {
|
||||
// After --, both 'pattern' (first positional) and '/etc/hosts' are positionals.
|
||||
// pattern is the pattern positional and is skipped; /etc/hosts is collected.
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("sd: skips the first two positionals (FIND and REPLACE_WITH) as patterns", async () => {
|
||||
const { node, tree } = await parseCommandNode(
|
||||
"sd find replace file.txt other.txt",
|
||||
);
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["file.txt", "other.txt"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("rg: skips the pattern positional and collects file/dir arguments", async () => {
|
||||
const { node, tree } = await parseCommandNode("rg pattern /etc/");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectCommandTokens — generic commands ───────────────────────────────────
|
||||
|
||||
describe("collectCommandTokens — generic commands", () => {
|
||||
it("collects all argument tokens after the command name", async () => {
|
||||
const { node, tree } = await parseCommandNode("cat /etc/hosts /etc/passwd");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips variable assignment prefixes", async () => {
|
||||
const { node, tree } = await parseCommandNode("FOO=/bar cat /etc/hosts");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects no tokens for a bare command with no arguments", async () => {
|
||||
const { node, tree } = await parseCommandNode("ls");
|
||||
try {
|
||||
expect(collectCommandTokens(node)).toEqual([]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectRedirectTokens ─────────────────────────────────────────────────────
|
||||
|
||||
describe("collectRedirectTokens", () => {
|
||||
it("collects the destination path from a stdout redirect", async () => {
|
||||
const { node, tree } = await parseRedirectNode(
|
||||
"cat /etc/hosts > /tmp/out.txt",
|
||||
);
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual(["/tmp/out.txt"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects the destination path from an append redirect", async () => {
|
||||
const { node, tree } = await parseRedirectNode(
|
||||
"echo hello >> /tmp/log.txt",
|
||||
);
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual(["/tmp/log.txt"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects the source path from a stdin redirect", async () => {
|
||||
const { node, tree } = await parseRedirectNode("cat < /etc/hosts");
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual(["/etc/hosts"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
describe("operands of a hosted nested command (#741)", () => {
|
||||
it("collects the operand of a substitution used as the destination", async () => {
|
||||
const { node, tree } = await parseRedirectNode(
|
||||
"echo hi > $(cat /etc/shadow)",
|
||||
);
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects the operand of a process substitution read as input", async () => {
|
||||
const { node, tree } = await parseRedirectNode(
|
||||
"cat < <(cat /etc/shadow)",
|
||||
);
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects both the destination text and a concatenated operand", async () => {
|
||||
const { node, tree } = await parseRedirectNode(
|
||||
"echo hi > /tmp/$(cat /etc/shadow)",
|
||||
);
|
||||
try {
|
||||
expect(collectRedirectTokens(node)).toEqual([
|
||||
"/tmp/$(cat /etc/shadow)",
|
||||
"/etc/shadow",
|
||||
]);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectPathCandidateTokens ────────────────────────────────────────────────
|
||||
|
||||
describe("collectPathCandidateTokens", () => {
|
||||
it("collects all argument tokens from a simple command via the program root", async () => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse("cat /etc/hosts");
|
||||
try {
|
||||
if (!tree) throw new Error("parse returned null");
|
||||
expect(collectPathCandidateTokens(tree.rootNode)).toEqual(["/etc/hosts"]);
|
||||
} finally {
|
||||
tree?.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects redirect destinations as well as command arguments", async () => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse("cat /etc/hosts > /tmp/out.txt");
|
||||
try {
|
||||
if (!tree) throw new Error("parse returned null");
|
||||
expect(collectPathCandidateTokens(tree.rootNode)).toEqual([
|
||||
"/etc/hosts",
|
||||
"/tmp/out.txt",
|
||||
]);
|
||||
} finally {
|
||||
tree?.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array for heredoc-only content (SKIP_SUBTREE_TYPES)", async () => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse("cat <<EOF\nhello\nEOF");
|
||||
try {
|
||||
if (!tree) throw new Error("parse returned null");
|
||||
// heredoc_body is in SKIP_SUBTREE_TYPES — its text must not be collected
|
||||
const tokens = collectPathCandidateTokens(tree.rootNode);
|
||||
expect(tokens).not.toContain("hello");
|
||||
} finally {
|
||||
tree?.delete();
|
||||
}
|
||||
});
|
||||
|
||||
describe("operands hosted in a heredoc body (#741)", () => {
|
||||
async function collectFrom(command: string): Promise<string[]> {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(command);
|
||||
if (!tree) throw new Error("parse returned null");
|
||||
try {
|
||||
return collectPathCandidateTokens(tree.rootNode);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
|
||||
it("collects the operand of an interpolating heredoc body", async () => {
|
||||
expect(await collectFrom("cat <<EOF\n$(cat /etc/shadow)\nEOF")).toEqual([
|
||||
"/etc/shadow",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["single-quoted", "cat <<'EOF'\n$(cat /etc/shadow)\nEOF"],
|
||||
["double-quoted", 'cat <<"EOF"\n$(cat /etc/shadow)\nEOF'],
|
||||
])("collects nothing from a %s heredoc body", async (_label, command) => {
|
||||
expect(await collectFrom(command)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never collects heredoc prose, even alongside a substitution", async () => {
|
||||
expect(
|
||||
await collectFrom(
|
||||
"cat <<EOF\n/etc/passwd is prose\n$(cat /etc/shadow)\nEOF",
|
||||
),
|
||||
).toEqual(["/etc/shadow"]);
|
||||
});
|
||||
|
||||
it("collects the operand of a herestring substitution", async () => {
|
||||
expect(await collectFrom("cat <<< $(cat /etc/shadow)")).toEqual([
|
||||
"/etc/shadow",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("recurses into command substitution to collect nested tokens", async () => {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse("cat $(echo /etc/hosts)");
|
||||
try {
|
||||
if (!tree) throw new Error("parse returned null");
|
||||
// The command_substitution is a non-command, non-redirect node — recurse
|
||||
const tokens = collectPathCandidateTokens(tree.rootNode);
|
||||
// /etc/hosts is inside the substitution, collected by recursion
|
||||
expect(tokens).toContain("/etc/hosts");
|
||||
} finally {
|
||||
tree?.delete();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded --opt=value extraction (#645)", () => {
|
||||
async function tokensOf(cmd: string): Promise<string[]> {
|
||||
const { node, tree } = await parseCommandNode(cmd);
|
||||
try {
|
||||
return collectCommandTokens(node);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
|
||||
it("emits the value of a long option carrying an inline path", async () => {
|
||||
// The issue's second repro: the flag token itself is rejected by the
|
||||
// shape prelude, so the embedded path had to be split out to be seen.
|
||||
expect(await tokensOf("grep --file=/tmp/patterns target")).toContain(
|
||||
"/tmp/patterns",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the embedded value for a non-pattern-first command too", async () => {
|
||||
expect(await tokensOf("tar --directory=/etc -xf a.tar")).toContain("/etc");
|
||||
});
|
||||
|
||||
it("preserves the original flag token", async () => {
|
||||
expect(await tokensOf("cat --file=/tmp/x")).toContain("--file=/tmp/x");
|
||||
});
|
||||
|
||||
it("emits a bare value, leaving it for the shape gates to drop", async () => {
|
||||
// --format=json yields "json", which names nothing and is dropped later.
|
||||
expect(await tokensOf("cat --format=json")).toContain("json");
|
||||
});
|
||||
|
||||
it("splits the single-dash form", async () => {
|
||||
expect(await tokensOf("cat -o=/tmp/out")).toContain("/tmp/out");
|
||||
});
|
||||
|
||||
it("does not split a flag with no value", async () => {
|
||||
const tokens = await tokensOf("grep --recursive target");
|
||||
expect(tokens).not.toContain("");
|
||||
expect(tokens).not.toContain("--recursive");
|
||||
});
|
||||
|
||||
it("does not split a non-flag token containing '='", async () => {
|
||||
// FOO=bar is a variable_assignment, never an argument token.
|
||||
expect(await tokensOf("cat a=b")).toEqual(["a=b"]);
|
||||
});
|
||||
|
||||
it("keeps only the first '=' as the separator", async () => {
|
||||
expect(await tokensOf("cat --opt=/tmp/a=b")).toContain("/tmp/a=b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CommandWord,
|
||||
classifyWrapperWords,
|
||||
executedUnitOf,
|
||||
} from "#src/access-intent/bash/wrapper-analysis";
|
||||
|
||||
/**
|
||||
* Split a command unit into words the way the AST walk does: whitespace
|
||||
* separated, but a quoted span is one word carrying its quotes — tree-sitter
|
||||
* emits a `string`/`raw_string` argument as a single named child.
|
||||
*
|
||||
* `program.test.ts` pins the real node adapter end to end; this stands in for it
|
||||
* so the extraction rules can be exercised without a parse.
|
||||
*/
|
||||
function words(unitText: string): CommandWord[] {
|
||||
const out: CommandWord[] = [];
|
||||
const pattern = /"[^"]*"|'[^']*'|\S+/g;
|
||||
let match = pattern.exec(unitText);
|
||||
while (match !== null) {
|
||||
out.push({ text: match[0], offset: match.index });
|
||||
match = pattern.exec(unitText);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("classifyWrapperWords", () => {
|
||||
describe("opaque payloads", () => {
|
||||
it.each([
|
||||
"eval rm",
|
||||
"bash -c rm",
|
||||
"sh -c rm",
|
||||
"dash -c rm",
|
||||
"zsh -c rm",
|
||||
"ksh -c rm",
|
||||
"bash -ec rm",
|
||||
"bash -xc rm",
|
||||
"/bin/bash -c rm",
|
||||
])("flags %s", (unit) => {
|
||||
expect(classifyWrapperWords(words(unit))).toBe("opaque-payload");
|
||||
});
|
||||
|
||||
it("does not flag a shell running a script file", () => {
|
||||
expect(classifyWrapperWords(words("bash script.sh"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag a -c cluster after the end-of-options marker", () => {
|
||||
expect(classifyWrapperWords(words("bash -- -c"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("indirection wrappers", () => {
|
||||
it.each([
|
||||
"sudo aws s3 ls",
|
||||
"env FOO=bar aws",
|
||||
"xargs grep foo",
|
||||
"timeout 10 grep foo",
|
||||
"nice -n 5 make",
|
||||
"doas ls",
|
||||
"flock /tmp/lock ls",
|
||||
])("flags %s", (unit) => {
|
||||
expect(classifyWrapperWords(words(unit))).toBe("indirection");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"find . -exec grep foo {} ;",
|
||||
"find . -execdir rm {} ;",
|
||||
"fd -x rm",
|
||||
"fd --exec-batch rm",
|
||||
])("flags the exec-conditional %s", (unit) => {
|
||||
expect(classifyWrapperWords(words(unit))).toBe("indirection");
|
||||
});
|
||||
|
||||
it("does not flag a bare search", () => {
|
||||
expect(classifyWrapperWords(words("find . -name x"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordinary commands", () => {
|
||||
it.each([
|
||||
"ls -la",
|
||||
"grep -c foo file",
|
||||
"git status",
|
||||
])("does not flag %s", (unit) => {
|
||||
expect(classifyWrapperWords(words(unit))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag an empty word list", () => {
|
||||
expect(classifyWrapperWords([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("executedUnitOf", () => {
|
||||
/** Extract from a unit spelled as plain whitespace-separated words. */
|
||||
function executedUnit(unitText: string): string | null {
|
||||
return executedUnitOf(unitText, words(unitText));
|
||||
}
|
||||
|
||||
describe("opaque payloads", () => {
|
||||
it.each([
|
||||
['bash -c "rm -rf /"', "rm -rf /"],
|
||||
["bash -c 'rm -rf /'", "rm -rf /"],
|
||||
['sh -ec "make build"', "make build"],
|
||||
['/bin/bash -c "ls"', "ls"],
|
||||
['eval "rm x"', "rm x"],
|
||||
])("names the inner program of %s", (unit, expected) => {
|
||||
expect(executedUnit(unit)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null when the payload argument is missing", () => {
|
||||
expect(executedUnit("bash -c")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("indirection wrappers", () => {
|
||||
it.each([
|
||||
["sudo aws s3 rm", "aws s3 rm"],
|
||||
["sudo -u root aws s3 rm", "aws s3 rm"],
|
||||
["sudo -- ls -la", "ls -la"],
|
||||
["xargs grep foo", "grep foo"],
|
||||
["xargs -0 -n1 grep foo", "grep foo"],
|
||||
["xargs -I{} rm {}", "rm {}"],
|
||||
["timeout 10 grep foo", "grep foo"],
|
||||
["timeout -s KILL 10 grep foo", "grep foo"],
|
||||
["nice -n 5 make build", "make build"],
|
||||
["env FOO=bar grep foo", "grep foo"],
|
||||
["flock /tmp/lock aws s3 ls", "aws s3 ls"],
|
||||
["watch -n 2 ls", "ls"],
|
||||
])("names the inner command of %s", (unit, expected) => {
|
||||
expect(executedUnit(unit)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves the inner command's original spacing and quoting", () => {
|
||||
expect(executedUnit("sudo grep 'a b' x")).toBe("grep 'a b' x");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"xargs",
|
||||
"sudo",
|
||||
"sudo -u root",
|
||||
"timeout 10",
|
||||
])("returns null when %s names no inner command", (unit) => {
|
||||
expect(executedUnit(unit)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null rather than guessing past an unknown trailing option", () => {
|
||||
expect(executedUnit("xargs --unknown-opt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec-conditional wrappers", () => {
|
||||
it.each([
|
||||
["find . -name x -exec grep foo {} ;", "grep foo {}"],
|
||||
["find . -exec rm {} +", "rm {}"],
|
||||
["find . -execdir grep foo {} ;", "grep foo {}"],
|
||||
["fd -x rm", "rm"],
|
||||
["fd --exec-batch rm -f", "rm -f"],
|
||||
])("names the per-result command of %s", (unit, expected) => {
|
||||
expect(executedUnit(unit)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null when the exec flag ends the command", () => {
|
||||
expect(executedUnit("find . -exec")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested wrappers", () => {
|
||||
it.each([
|
||||
["sudo timeout 5 xargs grep foo", "grep foo"],
|
||||
["sudo bash -c 'rm x'", "rm x"],
|
||||
["timeout 10 sudo -u root aws s3 rm", "aws s3 rm"],
|
||||
])("unwraps %s to its innermost command", (unit, expected) => {
|
||||
expect(executedUnit(unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing to add", () => {
|
||||
it("returns null for an ordinary command", () => {
|
||||
expect(executedUnit("grep foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty word list", () => {
|
||||
expect(executedUnitOf("", [])).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => "/mock/home"));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: mockHomedir,
|
||||
default: { homedir: mockHomedir },
|
||||
}));
|
||||
|
||||
// Mock node:fs so realpathSync (used by the canonical alias) 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 {
|
||||
buildAccessIntentForSurface,
|
||||
buildResolvedIntentFromMatchValues,
|
||||
normalizeInput,
|
||||
} from "#src/access-intent/input-normalizer";
|
||||
import { createMcpPermissionTargets } from "#src/access-intent/mcp-targets";
|
||||
import { posixPathFlavor } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
|
||||
afterEach(() => {
|
||||
mockHomedir.mockClear();
|
||||
realpathSync.mockReset();
|
||||
realpathSync.mockImplementation((p: string) => p);
|
||||
});
|
||||
|
||||
describe("normalizeInput — non-MCP surfaces", () => {
|
||||
// Path-bearing and special surfaces no longer derive path lookup values
|
||||
// through normalizeInput — that is now done by the access-path gate (#502)
|
||||
// and the service/RPC builder (#503). normalizeInput's tool branch collapses
|
||||
// every path-bearing or special surface to the catch-all ["*"] exactly as it
|
||||
// does for any unrecognised extension tool.
|
||||
describe("path-bearing and special surfaces collapse to '*'", () => {
|
||||
it("path surface ignores input.path and returns ['*']", () => {
|
||||
// After #504 removal: path no longer has a special branch.
|
||||
const result = normalizeInput("path", { path: ".env" }, []);
|
||||
expect(result.surface).toBe("path");
|
||||
expect(result.values).toEqual(["*"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("external_directory surface ignores input.path and returns ['*']", () => {
|
||||
const result = normalizeInput(
|
||||
"external_directory",
|
||||
{ path: "/other/project" },
|
||||
[],
|
||||
);
|
||||
expect(result.surface).toBe("external_directory");
|
||||
expect(result.values).toEqual(["*"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("read surface ignores input.path and returns ['*']", () => {
|
||||
const result = normalizeInput("read", { path: ".env" }, []);
|
||||
expect(result.surface).toBe("read");
|
||||
expect(result.values).toEqual(["*"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("missing path also returns ['*'] (unchanged fallback)", () => {
|
||||
for (const surface of [
|
||||
"path",
|
||||
"external_directory",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
]) {
|
||||
const result = normalizeInput(surface, {}, []);
|
||||
expect(result.values).toEqual(["*"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill", () => {
|
||||
it("uses skill name from input.name", () => {
|
||||
const result = normalizeInput("skill", { name: "librarian" }, []);
|
||||
expect(result.surface).toBe("skill");
|
||||
expect(result.values).toEqual(["librarian"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to '*' when name is missing", () => {
|
||||
const result = normalizeInput("skill", {}, []);
|
||||
expect(result.values).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("falls back to '*' when name is not a string", () => {
|
||||
const result = normalizeInput("skill", { name: 99 }, []);
|
||||
expect(result.values).toEqual(["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bash", () => {
|
||||
it("uses command from input.command", () => {
|
||||
const result = normalizeInput("bash", { command: "git status" }, []);
|
||||
expect(result.surface).toBe("bash");
|
||||
expect(result.values).toEqual(["git status"]);
|
||||
expect(result.resultExtras).toEqual({ command: "git status" });
|
||||
});
|
||||
|
||||
it("uses empty string when command is missing", () => {
|
||||
const result = normalizeInput("bash", {}, []);
|
||||
expect(result.values).toEqual([""]);
|
||||
expect(result.resultExtras).toEqual({ command: "" });
|
||||
});
|
||||
|
||||
it("uses empty string when command is not a string", () => {
|
||||
const result = normalizeInput("bash", { command: 42 }, []);
|
||||
expect(result.values).toEqual([""]);
|
||||
expect(result.resultExtras).toEqual({ command: "" });
|
||||
});
|
||||
|
||||
it("strips leading comment lines from values but keeps original in resultExtras", () => {
|
||||
const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
|
||||
const result = normalizeInput("bash", { command: cmd }, []);
|
||||
expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
|
||||
expect(result.resultExtras).toEqual({ command: cmd });
|
||||
});
|
||||
|
||||
it("strips multiple comment lines", () => {
|
||||
const cmd = "# Step 1\n# Step 2\ngit status --short";
|
||||
const result = normalizeInput("bash", { command: cmd }, []);
|
||||
expect(result.values).toEqual(["git status --short"]);
|
||||
});
|
||||
|
||||
it("preserves command when no comment lines present", () => {
|
||||
const result = normalizeInput(
|
||||
"bash",
|
||||
{ command: "grep -rn foo src/" },
|
||||
[],
|
||||
);
|
||||
expect(result.values).toEqual(["grep -rn foo src/"]);
|
||||
});
|
||||
|
||||
it("falls back to original when all lines are comments", () => {
|
||||
const cmd = "# just a comment";
|
||||
const result = normalizeInput("bash", { command: cmd }, []);
|
||||
expect(result.values).toEqual(["# just a comment"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension tools (non-path-bearing)", () => {
|
||||
it("uses '*' as the lookup value for extension tools", () => {
|
||||
const result = normalizeInput("my_extension_tool", { some: "input" }, []);
|
||||
expect(result.surface).toBe("my_extension_tool");
|
||||
expect(result.values).toEqual(["*"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("uses '*' even when extension tool has a path field", () => {
|
||||
const result = normalizeInput(
|
||||
"my_extension_tool",
|
||||
{ path: "/some/path" },
|
||||
[],
|
||||
);
|
||||
expect(result.values).toEqual(["*"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInput — MCP surface", () => {
|
||||
it("surface is 'mcp'", () => {
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
||||
expect(result.surface).toBe("mcp");
|
||||
});
|
||||
|
||||
it("values end with the catch-all 'mcp' target", () => {
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
||||
expect(result.values.at(-1)).toBe("mcp");
|
||||
});
|
||||
|
||||
it("values include specific targets before the catch-all for a qualified tool call", () => {
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
||||
expect(result.values).toContain("exa_search");
|
||||
expect(result.values).toContain("exa:search");
|
||||
expect(result.values).toContain("exa");
|
||||
expect(result.values).toContain("mcp_call");
|
||||
// 'mcp' is always last
|
||||
expect(result.values.at(-1)).toBe("mcp");
|
||||
});
|
||||
|
||||
it("matches createMcpPermissionTargets output + 'mcp' appended", () => {
|
||||
const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
|
||||
"exa",
|
||||
]);
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
|
||||
expect(result.values).toEqual([...rawTargets, "mcp"]);
|
||||
});
|
||||
|
||||
it("resultExtras.target is the first specific target (most-specific)", () => {
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
||||
expect(result.resultExtras.target).toBe(result.values[0]);
|
||||
});
|
||||
|
||||
it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
|
||||
// Empty input → only mcp_status then mcp appended
|
||||
const result = normalizeInput("mcp", {}, []);
|
||||
expect(result.resultExtras.target).toBe("mcp_status");
|
||||
});
|
||||
|
||||
it("values contain no duplicates", () => {
|
||||
const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
|
||||
const unique = [...new Set(result.values)];
|
||||
expect(result.values).toEqual(unique);
|
||||
});
|
||||
|
||||
it("produces mcp_status + mcp for status input", () => {
|
||||
const result = normalizeInput("mcp", {}, []);
|
||||
expect(result.values).toEqual(["mcp_status", "mcp"]);
|
||||
});
|
||||
|
||||
it("produces connect targets + mcp for connect input", () => {
|
||||
const result = normalizeInput("mcp", { connect: "exa" }, []);
|
||||
expect(result.values).toContain("mcp_connect_exa");
|
||||
expect(result.values).toContain("mcp_connect");
|
||||
expect(result.values.at(-1)).toBe("mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAccessIntentForSurface", () => {
|
||||
const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
|
||||
|
||||
it("emits an access-path intent carrying the canonical alias for the path surface", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "/test/project/link" ? "/test/project/real" : p,
|
||||
);
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"path",
|
||||
"link",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.surface).toBe("path");
|
||||
expect(intent.path.matchValues()).toContain("/test/project/real");
|
||||
expect(intent.path.value()).toBe("/test/project/link");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits an access-path intent for the external_directory surface", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"external_directory",
|
||||
"/outside/dir",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.surface).toBe("external_directory");
|
||||
expect(intent.path.value()).toBe("/outside/dir");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits an access-path intent for a path-bearing tool surface (read)", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"read",
|
||||
"/test/project/.env",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.surface).toBe("read");
|
||||
expect(intent.path.value()).toBe("/test/project/.env");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a tool intent for a non-path surface (bash)", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"bash",
|
||||
"echo hi",
|
||||
normalizer,
|
||||
"my-agent",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: "echo hi" },
|
||||
agentName: "my-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes agentName through on the access-path branch", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"path",
|
||||
"/some/file",
|
||||
normalizer,
|
||||
"Explore",
|
||||
);
|
||||
expect(intent.agentName).toBe("Explore");
|
||||
});
|
||||
|
||||
it("falls back to a tool intent for a value-less path surface", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"path",
|
||||
undefined,
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("tool");
|
||||
if (intent.kind === "tool") {
|
||||
expect(intent.surface).toBe("path");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a tool intent for a whitespace-only path value", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"external_directory",
|
||||
" ",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResolvedIntentFromMatchValues", () => {
|
||||
// The forwarded-serving wire's sole producer of a pre-fixed ResolvedAccessIntent
|
||||
// (#597): match values arrive already fixed at the child (matchValues()), so
|
||||
// this never touches a PathNormalizer or rebuilds an AccessPath.
|
||||
it("emits a path-values intent carrying the given match values as-is for the path surface", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
"path",
|
||||
["/worktree/issue-42/src/foo.ts", "src/foo.ts", "/main/src/foo.ts"],
|
||||
"Explore",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "path-values",
|
||||
surface: "path",
|
||||
values: [
|
||||
"/worktree/issue-42/src/foo.ts",
|
||||
"src/foo.ts",
|
||||
"/main/src/foo.ts",
|
||||
],
|
||||
agentName: "Explore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a path-values intent for the external_directory surface", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
"external_directory",
|
||||
["/tmp/x", "/real/tmp/x"],
|
||||
"Explore",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "path-values",
|
||||
surface: "external_directory",
|
||||
values: ["/tmp/x", "/real/tmp/x"],
|
||||
agentName: "Explore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a tool intent from the single portable value for a non-path surface (bash)", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
"bash",
|
||||
["git status"],
|
||||
"Explore",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: "git status" },
|
||||
agentName: "Explore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a tool intent from the single portable value for a skill surface", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
"skill",
|
||||
["librarian"],
|
||||
"Explore",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "tool",
|
||||
surface: "skill",
|
||||
input: { name: "librarian" },
|
||||
agentName: "Explore",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads an empty agentName through for agent-neutral resolution", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues("bash", ["ls"], "");
|
||||
expect(intent.agentName).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMcpPermissionTargets,
|
||||
McpTargetList,
|
||||
parseQualifiedMcpToolName,
|
||||
} from "#src/access-intent/mcp-targets";
|
||||
|
||||
describe("parseQualifiedMcpToolName", () => {
|
||||
it("returns server and tool for a valid qualified name", () => {
|
||||
expect(parseQualifiedMcpToolName("exa:search")).toEqual({
|
||||
server: "exa",
|
||||
tool: "search",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns server and tool with surrounding whitespace trimmed", () => {
|
||||
expect(parseQualifiedMcpToolName(" exa : search ")).toEqual({
|
||||
server: "exa",
|
||||
tool: "search",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseQualifiedMcpToolName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(parseQualifiedMcpToolName(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when colon is the first character", () => {
|
||||
expect(parseQualifiedMcpToolName(":search")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when colon is the last character", () => {
|
||||
expect(parseQualifiedMcpToolName("exa:")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a plain tool name with no colon", () => {
|
||||
expect(parseQualifiedMcpToolName("exa_search")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when server part is empty after trimming", () => {
|
||||
expect(parseQualifiedMcpToolName(" :search")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when tool part is empty after trimming", () => {
|
||||
expect(parseQualifiedMcpToolName("exa: ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMcpPermissionTargets", () => {
|
||||
describe("tool call (input.tool)", () => {
|
||||
it("produces targets for a bare tool name with no configured servers", () => {
|
||||
const targets = createMcpPermissionTargets({ tool: "exa_search" }, []);
|
||||
expect(targets).toContain("exa_search");
|
||||
expect(targets).toContain("mcp_call");
|
||||
});
|
||||
|
||||
it("produces targets for a qualified tool name (server:tool)", () => {
|
||||
const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
|
||||
expect(targets).toContain("exa_search");
|
||||
expect(targets).toContain("exa:search");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_call");
|
||||
});
|
||||
|
||||
it("produces targets for a tool call with explicit server field", () => {
|
||||
const targets = createMcpPermissionTargets(
|
||||
{ tool: "search", server: "exa" },
|
||||
[],
|
||||
);
|
||||
expect(targets).toContain("exa_search");
|
||||
expect(targets).toContain("exa:search");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_call");
|
||||
});
|
||||
|
||||
it("derives server targets from configured server names when tool name ends with _<server>", () => {
|
||||
const targets = createMcpPermissionTargets({ tool: "exa_search" }, [
|
||||
"exa",
|
||||
]);
|
||||
// exa_search ends with _exa? No — it ends with _search. This tool name
|
||||
// does NOT trigger server derivation because it does not end with _exa.
|
||||
expect(targets).toContain("exa_search");
|
||||
});
|
||||
|
||||
it("does not include duplicate entries", () => {
|
||||
const targets = createMcpPermissionTargets({ tool: "exa:search" }, [
|
||||
"exa",
|
||||
]);
|
||||
const unique = [...new Set(targets)];
|
||||
expect(targets).toEqual(unique);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connect call (input.connect)", () => {
|
||||
it("produces targets for a connect operation", () => {
|
||||
const targets = createMcpPermissionTargets({ connect: "exa" }, []);
|
||||
expect(targets).toContain("mcp_connect_exa");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_connect");
|
||||
});
|
||||
|
||||
it("does not include mcp_call for connect operations", () => {
|
||||
const targets = createMcpPermissionTargets({ connect: "exa" }, []);
|
||||
expect(targets).not.toContain("mcp_call");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describe operation (input.describe)", () => {
|
||||
it("produces targets for a describe operation on a qualified tool", () => {
|
||||
const targets = createMcpPermissionTargets(
|
||||
{ describe: "exa:search" },
|
||||
[],
|
||||
);
|
||||
expect(targets).toContain("exa_search");
|
||||
expect(targets).toContain("exa:search");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_describe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search operation (input.search)", () => {
|
||||
it("produces mcp_search and the search string as targets", () => {
|
||||
const targets = createMcpPermissionTargets({ search: "weather" }, []);
|
||||
expect(targets).toContain("weather");
|
||||
expect(targets).toContain("mcp_search");
|
||||
});
|
||||
|
||||
it("includes server targets when server is provided alongside search", () => {
|
||||
const targets = createMcpPermissionTargets(
|
||||
{ search: "weather", server: "exa" },
|
||||
[],
|
||||
);
|
||||
expect(targets).toContain("mcp_server_exa");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_search");
|
||||
});
|
||||
});
|
||||
|
||||
describe("server listing (input.server only)", () => {
|
||||
it("produces mcp_list and server-specific targets", () => {
|
||||
const targets = createMcpPermissionTargets({ server: "exa" }, []);
|
||||
expect(targets).toContain("mcp_server_exa");
|
||||
expect(targets).toContain("exa");
|
||||
expect(targets).toContain("mcp_list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status (no meaningful input)", () => {
|
||||
it("produces mcp_status for empty input", () => {
|
||||
const targets = createMcpPermissionTargets({}, []);
|
||||
expect(targets).toContain("mcp_status");
|
||||
});
|
||||
|
||||
it("produces mcp_status for null input", () => {
|
||||
const targets = createMcpPermissionTargets(null, []);
|
||||
expect(targets).toContain("mcp_status");
|
||||
});
|
||||
|
||||
it("produces mcp_status when no server/tool/connect/describe/search present", () => {
|
||||
const targets = createMcpPermissionTargets({ unrelated: "value" }, [
|
||||
"exa",
|
||||
]);
|
||||
expect(targets).toContain("mcp_status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority ordering", () => {
|
||||
it("tool targets appear before mcp_call", () => {
|
||||
const targets = createMcpPermissionTargets({ tool: "exa:search" }, []);
|
||||
const mcpCallIdx = targets.indexOf("mcp_call");
|
||||
const exaSearchIdx = targets.indexOf("exa_search");
|
||||
expect(exaSearchIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(mcpCallIdx).toBeGreaterThan(exaSearchIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpTargetList", () => {
|
||||
describe("add", () => {
|
||||
it("ignores null", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add(null);
|
||||
expect(list.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores empty string", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add("");
|
||||
expect(list.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
it("appends a new value", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add("exa");
|
||||
expect(list.toArray()).toEqual(["exa"]);
|
||||
});
|
||||
|
||||
it("dedups repeated values", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add("exa");
|
||||
list.add("exa");
|
||||
expect(list.toArray()).toEqual(["exa"]);
|
||||
});
|
||||
|
||||
it("preserves first-insertion order across a mix of values", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add("exa_search");
|
||||
list.add("exa:search");
|
||||
list.add("exa");
|
||||
list.add("exa_search"); // duplicate — must not change order
|
||||
list.add("mcp_call");
|
||||
expect(list.toArray()).toEqual([
|
||||
"exa_search",
|
||||
"exa:search",
|
||||
"exa",
|
||||
"mcp_call",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toArray", () => {
|
||||
it("returns an independent copy that does not mutate the list", () => {
|
||||
const list = new McpTargetList();
|
||||
list.add("exa");
|
||||
const first = list.toArray();
|
||||
first.push("mutated");
|
||||
expect(list.toArray()).toEqual(["exa"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
PATH_BEARING_TOOLS,
|
||||
PATH_SURFACES,
|
||||
READ_ONLY_PATH_BEARING_TOOLS,
|
||||
} from "#src/access-intent/path-surfaces";
|
||||
|
||||
describe("PATH_BEARING_TOOLS", () => {
|
||||
test("contains the expected tool names", () => {
|
||||
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
|
||||
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not contain bash or mcp", () => {
|
||||
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
|
||||
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
|
||||
test("contains read, find, grep, ls", () => {
|
||||
for (const tool of ["read", "find", "grep", "ls"]) {
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not contain write or edit", () => {
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATH_SURFACES", () => {
|
||||
test("contains the path-bearing tools plus the cross-cutting gates", () => {
|
||||
for (const surface of [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"find",
|
||||
"grep",
|
||||
"ls",
|
||||
"external_directory",
|
||||
"path",
|
||||
]) {
|
||||
expect(PATH_SURFACES.has(surface)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not contain bash or mcp", () => {
|
||||
expect(PATH_SURFACES.has("bash")).toBe(false);
|
||||
expect(PATH_SURFACES.has("mcp")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getPathBearingToolPath,
|
||||
getToolInputPath,
|
||||
} from "#src/access-intent/tool-input-path";
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
|
||||
describe("getPathBearingToolPath", () => {
|
||||
test("returns path for a path-bearing tool", () => {
|
||||
expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
|
||||
"/src/foo.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for a non-path-bearing tool", () => {
|
||||
expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
||||
expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
|
||||
expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when input has no path", () => {
|
||||
expect(getPathBearingToolPath("read", {})).toBeNull();
|
||||
expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
|
||||
expect(getPathBearingToolPath("read", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getToolInputPath", () => {
|
||||
function lookupOf(
|
||||
toolName: string,
|
||||
extractor: (input: Record<string, unknown>) => string | undefined,
|
||||
): ToolAccessExtractorLookup {
|
||||
return {
|
||||
get: (name) => (name === toolName ? extractor : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns input.path for a built-in path-bearing tool", () => {
|
||||
expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
|
||||
"/src/foo.ts",
|
||||
);
|
||||
expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
|
||||
"/src/bar.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for bash", () => {
|
||||
expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
||||
});
|
||||
|
||||
test("returns the MCP arguments.path for an mcp call", () => {
|
||||
expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
|
||||
"/etc/hosts",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for an mcp call without an arguments.path", () => {
|
||||
expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
|
||||
expect(getToolInputPath("mcp", {})).toBeNull();
|
||||
});
|
||||
|
||||
test("defaults to input.path for an unregistered extension tool", () => {
|
||||
expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
|
||||
"/work/file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for an extension tool without a path", () => {
|
||||
expect(getToolInputPath("my-ext", { other: true })).toBeNull();
|
||||
expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
|
||||
expect(getToolInputPath("my-ext", null)).toBeNull();
|
||||
});
|
||||
|
||||
test("uses a registered extractor's path over the default convention", () => {
|
||||
const extractors = lookupOf("ffgrep", (input) =>
|
||||
typeof input.target === "string" ? input.target : undefined,
|
||||
);
|
||||
expect(
|
||||
getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
|
||||
).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
test("returns null when a registered extractor declines", () => {
|
||||
const extractors = lookupOf("ffgrep", () => undefined);
|
||||
expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
|
||||
import {
|
||||
classifyToolKind,
|
||||
isMcpCheck,
|
||||
resolveShellInvocation,
|
||||
} from "#src/access-intent/tool-kind";
|
||||
import type { ShellToolsConfig } from "#src/config-schema";
|
||||
|
||||
describe("classifyToolKind", () => {
|
||||
test("classifies bash", () => {
|
||||
expect(classifyToolKind("bash")).toBe("bash");
|
||||
});
|
||||
|
||||
test("classifies mcp", () => {
|
||||
expect(classifyToolKind("mcp")).toBe("mcp");
|
||||
});
|
||||
|
||||
test("classifies skill", () => {
|
||||
expect(classifyToolKind("skill")).toBe("skill");
|
||||
});
|
||||
|
||||
test("classifies every path-bearing built-in tool as path", () => {
|
||||
for (const tool of PATH_BEARING_TOOLS) {
|
||||
expect(classifyToolKind(tool)).toBe("path");
|
||||
}
|
||||
});
|
||||
|
||||
test("classifies an arbitrary extension tool as extension", () => {
|
||||
expect(classifyToolKind("task")).toBe("extension");
|
||||
expect(classifyToolKind("third_party_tool")).toBe("extension");
|
||||
});
|
||||
|
||||
test("classifies the special path surfaces as extension", () => {
|
||||
// `path` and `external_directory` are not tool names — they reach the
|
||||
// classifier only as normalized surface names in `deriveSource`, where the
|
||||
// `SPECIAL_PERMISSION_KEYS` check maps them to `special` before the kind.
|
||||
expect(classifyToolKind("path")).toBe("extension");
|
||||
expect(classifyToolKind("external_directory")).toBe("extension");
|
||||
});
|
||||
|
||||
test("trims surrounding whitespace before classifying", () => {
|
||||
expect(classifyToolKind(" bash ")).toBe("bash");
|
||||
expect(classifyToolKind("\tmcp\n")).toBe("mcp");
|
||||
expect(classifyToolKind(" read ")).toBe("path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMcpCheck", () => {
|
||||
test("is true when the tool itself is mcp", () => {
|
||||
expect(isMcpCheck({ toolName: "mcp", source: "tool" })).toBe(true);
|
||||
});
|
||||
|
||||
test("is true when the winning rule matched on the mcp surface", () => {
|
||||
// The `source` disjunct: a server-qualified toolName still classifies as an
|
||||
// MCP call because `deriveSource` set source to `mcp`.
|
||||
expect(
|
||||
isMcpCheck({ toolName: "some-server:some-tool", source: "mcp" }),
|
||||
).toBe(true);
|
||||
expect(isMcpCheck({ toolName: "read", source: "mcp" })).toBe(true);
|
||||
});
|
||||
|
||||
test("is false for a bash check", () => {
|
||||
expect(isMcpCheck({ toolName: "bash", source: "bash" })).toBe(false);
|
||||
});
|
||||
|
||||
test("is false for a plain tool check", () => {
|
||||
expect(isMcpCheck({ toolName: "read", source: "tool" })).toBe(false);
|
||||
expect(isMcpCheck({ toolName: "task", source: "default" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShellInvocation", () => {
|
||||
const execAlias: ShellToolsConfig = {
|
||||
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
||||
};
|
||||
|
||||
describe("native bash", () => {
|
||||
test("extracts the command with no workdir", () => {
|
||||
expect(
|
||||
resolveShellInvocation("bash", { command: "git status" }, undefined),
|
||||
).toEqual({ command: "git status", workdir: undefined });
|
||||
});
|
||||
|
||||
test("trims the command", () => {
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"bash",
|
||||
{ command: " git status " },
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ command: "git status", workdir: undefined });
|
||||
});
|
||||
|
||||
test("yields an empty command when absent or non-string", () => {
|
||||
expect(resolveShellInvocation("bash", {}, undefined)).toEqual({
|
||||
command: "",
|
||||
workdir: undefined,
|
||||
});
|
||||
expect(
|
||||
resolveShellInvocation("bash", { command: 42 }, undefined),
|
||||
).toEqual({ command: "", workdir: undefined });
|
||||
});
|
||||
|
||||
test("resolves regardless of the shellTools map", () => {
|
||||
expect(
|
||||
resolveShellInvocation("bash", { command: "ls" }, execAlias),
|
||||
).toEqual({ command: "ls", workdir: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliased shell tool", () => {
|
||||
test("extracts command and workdir from the mapped arguments", () => {
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"exec_command",
|
||||
{ cmd: "npm install", workdir: "/etc" },
|
||||
execAlias,
|
||||
),
|
||||
).toEqual({ command: "npm install", workdir: "/etc" });
|
||||
});
|
||||
|
||||
test("omits workdir when the alias declares no workdirArgument", () => {
|
||||
const aliases: ShellToolsConfig = {
|
||||
exec_command: { commandArgument: "cmd" },
|
||||
};
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"exec_command",
|
||||
{ cmd: "npm install", workdir: "/etc" },
|
||||
aliases,
|
||||
),
|
||||
).toEqual({ command: "npm install", workdir: undefined });
|
||||
});
|
||||
|
||||
test("omits workdir when the mapped workdir argument is absent", () => {
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"exec_command",
|
||||
{ cmd: "npm install" },
|
||||
execAlias,
|
||||
),
|
||||
).toEqual({ command: "npm install", workdir: undefined });
|
||||
});
|
||||
|
||||
test("yields an empty command when the mapped command argument is absent", () => {
|
||||
expect(
|
||||
resolveShellInvocation("exec_command", { workdir: "/etc" }, execAlias),
|
||||
).toEqual({ command: "", workdir: "/etc" });
|
||||
});
|
||||
|
||||
test("trims the extracted command and workdir", () => {
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"exec_command",
|
||||
{ cmd: " npm install ", workdir: " /etc " },
|
||||
execAlias,
|
||||
),
|
||||
).toEqual({ command: "npm install", workdir: "/etc" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-shell tools", () => {
|
||||
test("returns null for an unaliased extension tool", () => {
|
||||
expect(
|
||||
resolveShellInvocation(
|
||||
"exec_command",
|
||||
{ cmd: "npm install" },
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveShellInvocation("read", { path: "a.txt" }, execAlias),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the map names a different tool", () => {
|
||||
expect(
|
||||
resolveShellInvocation("other_tool", { cmd: "npm install" }, execAlias),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user