mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
912 lines
25 KiB
TypeScript
912 lines
25 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
|
import type { Rule, RuleOrigin, Ruleset } from "#src/rule";
|
|
import {
|
|
evaluate,
|
|
evaluateAnyValue,
|
|
evaluateFirst,
|
|
evaluateMostRestrictive,
|
|
floorAllowsToAsk,
|
|
rewriteAsksToYolo,
|
|
} from "#src/rule";
|
|
|
|
describe("evaluate", () => {
|
|
const allowBashGit: Rule = {
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
const denyBashGitPush: Rule = {
|
|
surface: "bash",
|
|
pattern: "git push *",
|
|
action: "deny",
|
|
origin: "global",
|
|
};
|
|
const allowRead: Rule = {
|
|
surface: "read",
|
|
pattern: "*",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
const askMcp: Rule = {
|
|
surface: "mcp",
|
|
pattern: "*",
|
|
action: "ask",
|
|
origin: "global",
|
|
};
|
|
const allowSkillLibrarian: Rule = {
|
|
surface: "skill",
|
|
pattern: "librarian",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
const askSpecialExtDir: Rule = {
|
|
surface: "special",
|
|
pattern: "external_directory",
|
|
action: "ask",
|
|
origin: "global",
|
|
};
|
|
|
|
test("returns matching rule when a rule matches", () => {
|
|
const ruleset: Ruleset = [allowBashGit];
|
|
const result = evaluate("bash", "git status", ruleset, posixPathFlavor);
|
|
expect(result).toEqual(allowBashGit);
|
|
});
|
|
|
|
test("returns synthetic rule with 'ask' when no rules match and no defaultAction", () => {
|
|
const result = evaluate(
|
|
"bash",
|
|
"npm install",
|
|
[allowBashGit],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.surface).toBe("bash");
|
|
expect(result.pattern).toBe("npm install");
|
|
expect(result.action).toBe("ask");
|
|
});
|
|
|
|
test("returns synthetic rule with custom defaultAction when no rules match", () => {
|
|
const result = evaluate(
|
|
"bash",
|
|
"npm install",
|
|
[allowBashGit],
|
|
posixPathFlavor,
|
|
"deny",
|
|
);
|
|
expect(result.surface).toBe("bash");
|
|
expect(result.pattern).toBe("npm install");
|
|
expect(result.action).toBe("deny");
|
|
});
|
|
|
|
test("defaultAction does not affect matched rules", () => {
|
|
const result = evaluate(
|
|
"bash",
|
|
"git status",
|
|
[allowBashGit],
|
|
posixPathFlavor,
|
|
"deny",
|
|
);
|
|
expect(result).toEqual(allowBashGit);
|
|
});
|
|
|
|
test("returns synthetic rule for empty ruleset", () => {
|
|
const result = evaluate("mcp", "exa_search", [], posixPathFlavor);
|
|
expect(result.surface).toBe("mcp");
|
|
expect(result.pattern).toBe("exa_search");
|
|
expect(result.action).toBe("ask");
|
|
});
|
|
|
|
test("matches rules for all permission surfaces", () => {
|
|
expect(
|
|
evaluate("read", "src/foo.ts", [allowRead], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
expect(
|
|
evaluate("mcp", "exa_search", [askMcp], posixPathFlavor).action,
|
|
).toBe("ask");
|
|
expect(
|
|
evaluate("skill", "librarian", [allowSkillLibrarian], posixPathFlavor)
|
|
.action,
|
|
).toBe("allow");
|
|
expect(
|
|
evaluate(
|
|
"special",
|
|
"external_directory",
|
|
[askSpecialExtDir],
|
|
posixPathFlavor,
|
|
).action,
|
|
).toBe("ask");
|
|
});
|
|
|
|
test("last-match-wins: later conflicting rule overrides earlier", () => {
|
|
const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
|
|
const result = evaluate(
|
|
"bash",
|
|
"git push origin main",
|
|
ruleset,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).toEqual(denyBashGitPush);
|
|
});
|
|
|
|
test("last-match-wins: broad deny followed by specific allow", () => {
|
|
const denyAll: Rule = {
|
|
surface: "bash",
|
|
pattern: "*",
|
|
action: "deny",
|
|
origin: "global",
|
|
};
|
|
const allowStatus: Rule = {
|
|
surface: "bash",
|
|
pattern: "git status",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate(
|
|
"bash",
|
|
"git status",
|
|
[denyAll, allowStatus],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).toEqual(allowStatus);
|
|
});
|
|
|
|
test("wildcard surface in rule matches any surface value", () => {
|
|
const universalAllow: Rule = {
|
|
surface: "*",
|
|
pattern: "*",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
expect(
|
|
evaluate("bash", "anything", [universalAllow], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
expect(
|
|
evaluate("mcp", "something", [universalAllow], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
expect(
|
|
evaluate("skill", "librarian", [universalAllow], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
});
|
|
|
|
test("specific surface rule does not match a different surface", () => {
|
|
const ruleset: Ruleset = [allowBashGit];
|
|
// bash rule should not match mcp surface
|
|
const result = evaluate("mcp", "git status", ruleset, posixPathFlavor);
|
|
expect(result.action).toBe("ask"); // falls back to default
|
|
});
|
|
|
|
test("merged rulesets: rules from later scope take priority", () => {
|
|
const globalRules: Ruleset = [
|
|
{ surface: "bash", pattern: "git *", action: "ask", origin: "global" },
|
|
];
|
|
const agentRules: Ruleset = [
|
|
{ surface: "bash", pattern: "git *", action: "allow", origin: "agent" },
|
|
];
|
|
const merged = [...globalRules, ...agentRules];
|
|
const result = evaluate("bash", "git status", merged, posixPathFlavor);
|
|
expect(result.action).toBe("allow"); // agent rule wins
|
|
});
|
|
|
|
test("merged rulesets: earlier scope used when later scope has no match", () => {
|
|
const globalRules: Ruleset = [
|
|
{ surface: "bash", pattern: "git *", action: "allow", origin: "global" },
|
|
];
|
|
const agentRules: Ruleset = [
|
|
{ surface: "bash", pattern: "npm *", action: "deny", origin: "agent" },
|
|
];
|
|
// git status matches global but not agent rule
|
|
const merged = [...globalRules, ...agentRules];
|
|
const result = evaluate("bash", "git status", merged, posixPathFlavor);
|
|
expect(result.action).toBe("allow"); // global rule is the last match for this pattern
|
|
});
|
|
|
|
test("empty ruleset returns synthetic default", () => {
|
|
const result = evaluate("bash", "git status", [], posixPathFlavor);
|
|
expect(result.surface).toBe("bash");
|
|
expect(result.pattern).toBe("git status");
|
|
expect(result.action).toBe("ask");
|
|
});
|
|
|
|
test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => {
|
|
const withLayer: Rule = {
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const withoutLayer: Rule = {
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
origin: "global",
|
|
};
|
|
const withDefault: Rule = {
|
|
surface: "bash",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "default",
|
|
origin: "builtin",
|
|
};
|
|
// Both rules with and without layer field produce the same match.
|
|
expect(
|
|
evaluate("bash", "git status", [withLayer], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
expect(
|
|
evaluate("bash", "git status", [withoutLayer], posixPathFlavor).action,
|
|
).toBe("allow");
|
|
// Layer metadata does not affect last-match-wins ordering.
|
|
const ruleset: Rule[] = [withDefault, withLayer];
|
|
expect(evaluate("bash", "git status", ruleset, posixPathFlavor)).toEqual(
|
|
withLayer,
|
|
);
|
|
// A rule with layer: "default" still wins if it is last in the array.
|
|
const reversedRuleset: Rule[] = [withLayer, withDefault];
|
|
expect(
|
|
evaluate("bash", "git status", reversedRuleset, posixPathFlavor),
|
|
).toEqual(withDefault);
|
|
});
|
|
|
|
test("evaluate() preserves origin on a matched rule", () => {
|
|
const origin: RuleOrigin = "project";
|
|
const rule: Rule = {
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin,
|
|
};
|
|
const result = evaluate("bash", "git status", [rule], posixPathFlavor);
|
|
expect(result.origin).toBe("project");
|
|
});
|
|
|
|
test("evaluate() synthetic fallback rule has origin 'builtin'", () => {
|
|
const result = evaluate("bash", "npm install", [], posixPathFlavor);
|
|
expect(result.origin).toBe("builtin");
|
|
});
|
|
|
|
test("evaluate() propagates reason from the matched deny rule", () => {
|
|
const rule: Rule = {
|
|
surface: "bash",
|
|
pattern: "npm *",
|
|
action: "deny",
|
|
reason: "Use pnpm instead",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate("bash", "npm install", [rule], posixPathFlavor);
|
|
expect(result.action).toBe("deny");
|
|
expect(result.reason).toBe("Use pnpm instead");
|
|
});
|
|
|
|
test("evaluate() carries reason through last-match-wins when deny wins", () => {
|
|
const allowAll: Rule = {
|
|
surface: "bash",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const denyNpm: Rule = {
|
|
surface: "bash",
|
|
pattern: "npm *",
|
|
action: "deny",
|
|
reason: "Use pnpm",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate(
|
|
"bash",
|
|
"npm install",
|
|
[allowAll, denyNpm],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.action).toBe("deny");
|
|
expect(result.reason).toBe("Use pnpm");
|
|
});
|
|
|
|
test("evaluate() drops reason when a later allow overrides the deny", () => {
|
|
const denyNpm: Rule = {
|
|
surface: "bash",
|
|
pattern: "npm *",
|
|
action: "deny",
|
|
reason: "Use pnpm",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const allowInstall: Rule = {
|
|
surface: "bash",
|
|
pattern: "npm install",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate(
|
|
"bash",
|
|
"npm install",
|
|
[denyNpm, allowInstall],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.action).toBe("allow");
|
|
expect(result.reason).toBeUndefined();
|
|
});
|
|
|
|
test("evaluate() synthetic fallback rule has no reason", () => {
|
|
const result = evaluate("bash", "npm install", [], posixPathFlavor);
|
|
expect(result.reason).toBeUndefined();
|
|
});
|
|
|
|
test("RuleOrigin covers all seven provenance values", () => {
|
|
const origins: RuleOrigin[] = [
|
|
"global",
|
|
"project",
|
|
"agent",
|
|
"project-agent",
|
|
"builtin",
|
|
"baseline",
|
|
"session",
|
|
];
|
|
for (const origin of origins) {
|
|
const rule: Rule = {
|
|
surface: "read",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin,
|
|
};
|
|
expect(evaluate("read", "*", [rule], posixPathFlavor).origin).toBe(
|
|
origin,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── Windows: path-surface patterns fold case (last-match-wins) ──────────
|
|
|
|
const denyExternalAll: Rule = {
|
|
surface: "external_directory",
|
|
pattern: "*",
|
|
action: "deny",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const allowExternalPi: Rule = {
|
|
surface: "external_directory",
|
|
pattern: "C:\\Users\\Foo\\pi\\*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
|
|
test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => {
|
|
const result = evaluate(
|
|
"external_directory",
|
|
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
[denyExternalAll, allowExternalPi],
|
|
win32PathFlavor,
|
|
);
|
|
expect(result.action).toBe("allow");
|
|
});
|
|
|
|
test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => {
|
|
const result = evaluate(
|
|
"external_directory",
|
|
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
[denyExternalAll, allowExternalPi],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.action).toBe("deny");
|
|
});
|
|
|
|
test("win32: a forward-slash external_directory pattern matches a backslash value", () => {
|
|
const allowForwardSlash: Rule = {
|
|
surface: "external_directory",
|
|
pattern: "C:/Users/Foo/pi/*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate(
|
|
"external_directory",
|
|
"c:\\users\\foo\\pi\\docs\\readme.md",
|
|
[denyExternalAll, allowForwardSlash],
|
|
win32PathFlavor,
|
|
);
|
|
expect(result.action).toBe("allow");
|
|
});
|
|
|
|
test("win32: a forward-slash path pattern matches a forward-slash value (#653)", () => {
|
|
// A Git Bash device token reaches the `path` surface spelled as typed, so
|
|
// the fold has to normalize the value as well as the rule pattern.
|
|
const askAll: Rule = {
|
|
surface: "path",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const allowDevice: Rule = {
|
|
surface: "path",
|
|
pattern: "/dev/null",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const result = evaluate(
|
|
"path",
|
|
"/dev/null",
|
|
[askAll, allowDevice],
|
|
win32PathFlavor,
|
|
);
|
|
expect(result.action).toBe("allow");
|
|
});
|
|
|
|
test("win32: bash surface keeps its separators unfolded (not a path surface)", () => {
|
|
const result = evaluate(
|
|
"bash",
|
|
"cat \\tmp\\x",
|
|
[
|
|
{
|
|
surface: "bash",
|
|
pattern: "cat /tmp/x",
|
|
action: "allow",
|
|
origin: "global",
|
|
},
|
|
],
|
|
win32PathFlavor,
|
|
);
|
|
expect(result.action).toBe("ask");
|
|
});
|
|
|
|
test("win32: bash surface stays case-sensitive (not a path surface)", () => {
|
|
const result = evaluate(
|
|
"bash",
|
|
"GIT push",
|
|
[
|
|
{
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
origin: "global",
|
|
},
|
|
],
|
|
win32PathFlavor,
|
|
);
|
|
expect(result.action).toBe("ask");
|
|
});
|
|
});
|
|
|
|
describe("evaluateFirst", () => {
|
|
const defaultRule: Rule = {
|
|
surface: "*",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "default",
|
|
origin: "builtin",
|
|
};
|
|
const allowBash: Rule = {
|
|
surface: "bash",
|
|
pattern: "git *",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const denyMcp: Rule = {
|
|
surface: "mcp",
|
|
pattern: "exa_search",
|
|
action: "deny",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
|
|
test("returns the first candidate that matches a non-default rule", () => {
|
|
const rules: Ruleset = [defaultRule, allowBash];
|
|
const result = evaluateFirst(
|
|
"bash",
|
|
["git status", "*"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(allowBash);
|
|
expect(result.value).toBe("git status");
|
|
});
|
|
|
|
test("skips candidates that only match the default rule", () => {
|
|
// "npm install" matches only the default; "*" also matches only the
|
|
// default — falls back to first candidate.
|
|
const rules: Ruleset = [defaultRule];
|
|
const result = evaluateFirst(
|
|
"bash",
|
|
["npm install", "*"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule.layer).toBe("default");
|
|
expect(result.value).toBe("npm install");
|
|
});
|
|
|
|
test("falls back to first candidate when all candidates match only the default", () => {
|
|
const rules: Ruleset = [defaultRule];
|
|
const result = evaluateFirst(
|
|
"bash",
|
|
["a", "b", "c"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.value).toBe("a");
|
|
});
|
|
|
|
test("stops at first non-default match, does not continue to remaining candidates", () => {
|
|
// "exa_search" matches denyMcp (non-default). The loop stops there;
|
|
// "mcp" is never evaluated even though it would match a different rule.
|
|
const allowMcpCatchAll: Rule = {
|
|
surface: "mcp",
|
|
pattern: "mcp",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll];
|
|
const result = evaluateFirst(
|
|
"mcp",
|
|
["exa_search", "mcp"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(denyMcp);
|
|
expect(result.value).toBe("exa_search");
|
|
});
|
|
|
|
test("skips candidates that match only the default and continues to next", () => {
|
|
// "unknown_tool" matches only the universal default;
|
|
// "exa_search" matches denyMcp (non-default) — that is the result.
|
|
const rules: Ruleset = [defaultRule, denyMcp];
|
|
const result = evaluateFirst(
|
|
"mcp",
|
|
["unknown_tool", "exa_search"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(denyMcp);
|
|
expect(result.value).toBe("exa_search");
|
|
});
|
|
|
|
test("single-candidate array behaves like evaluate()", () => {
|
|
const rules: Ruleset = [defaultRule, allowBash];
|
|
const result = evaluateFirst(
|
|
"bash",
|
|
["git status"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(allowBash);
|
|
expect(result.value).toBe("git status");
|
|
});
|
|
|
|
test("uses '*' as fallback value when values array is empty", () => {
|
|
const rules: Ruleset = [defaultRule];
|
|
const result = evaluateFirst("bash", [], rules, posixPathFlavor);
|
|
expect(result.value).toBe("*");
|
|
});
|
|
});
|
|
|
|
describe("evaluateAnyValue", () => {
|
|
const catchAllAllow: Rule = {
|
|
surface: "path",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const catchAllAsk: Rule = {
|
|
surface: "path",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const relativeDeny: Rule = {
|
|
surface: "path",
|
|
pattern: "src/*",
|
|
action: "deny",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const absoluteAllow: Rule = {
|
|
surface: "path",
|
|
pattern: "/proj/*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
|
|
test("a later relative rule wins over a catch-all matched by another alias", () => {
|
|
const rules: Ruleset = [catchAllAllow, relativeDeny];
|
|
const result = evaluateAnyValue(
|
|
"path",
|
|
["/proj/src/foo.ts", "src/foo.ts"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(relativeDeny);
|
|
expect(result.value).toBe("src/foo.ts");
|
|
});
|
|
|
|
test("uses an absolute alias when no later relative rule matches", () => {
|
|
const rules: Ruleset = [catchAllAsk, absoluteAllow];
|
|
const result = evaluateAnyValue(
|
|
"path",
|
|
["/proj/src/foo.ts", "src/foo.ts"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule).toEqual(absoluteAllow);
|
|
expect(result.value).toBe("/proj/src/foo.ts");
|
|
});
|
|
|
|
test("falls back to the first value's default when no rule matches", () => {
|
|
const result = evaluateAnyValue(
|
|
"path",
|
|
["/proj/src/foo.ts", "src/foo.ts"],
|
|
[],
|
|
posixPathFlavor,
|
|
);
|
|
expect(result.rule.action).toBe("ask");
|
|
expect(result.value).toBe("/proj/src/foo.ts");
|
|
});
|
|
|
|
test("uses '*' as fallback value when values array is empty", () => {
|
|
const result = evaluateAnyValue("path", [], [], posixPathFlavor);
|
|
expect(result.value).toBe("*");
|
|
});
|
|
});
|
|
|
|
describe("evaluateMostRestrictive", () => {
|
|
const denyEnv: Rule = {
|
|
surface: "path",
|
|
pattern: "*.env",
|
|
action: "deny",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const askSsh: Rule = {
|
|
surface: "path",
|
|
pattern: "/home/user/.ssh/*",
|
|
action: "ask",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const allowAll: Rule = {
|
|
surface: "path",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
|
|
test("deny short-circuits: returns immediately without evaluating remaining values", () => {
|
|
const rules: Ruleset = [allowAll, denyEnv];
|
|
const result = evaluateMostRestrictive(
|
|
"path",
|
|
[".env", "README.md"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.rule.action).toBe("deny");
|
|
expect(result!.value).toBe(".env");
|
|
});
|
|
|
|
test("ask accumulates: returns first ask when no deny found", () => {
|
|
const rules: Ruleset = [allowAll, askSsh];
|
|
const result = evaluateMostRestrictive(
|
|
"path",
|
|
["/home/user/.ssh/id_rsa", "README.md"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.rule.action).toBe("ask");
|
|
expect(result!.value).toBe("/home/user/.ssh/id_rsa");
|
|
});
|
|
|
|
test("all allow: returns null", () => {
|
|
const rules: Ruleset = [allowAll];
|
|
const result = evaluateMostRestrictive(
|
|
"path",
|
|
["README.md", "src/index.ts"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("empty values: returns null", () => {
|
|
const rules: Ruleset = [allowAll, denyEnv];
|
|
const result = evaluateMostRestrictive("path", [], rules, posixPathFlavor);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("deny wins over ask", () => {
|
|
const rules: Ruleset = [allowAll, askSsh, denyEnv];
|
|
const result = evaluateMostRestrictive(
|
|
"path",
|
|
["/home/user/.ssh/id_rsa", ".env"],
|
|
rules,
|
|
posixPathFlavor,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.rule.action).toBe("deny");
|
|
expect(result!.value).toBe(".env");
|
|
});
|
|
});
|
|
|
|
// Shared Rule fixtures for the composition-stage overlay blocks
|
|
// (rewriteAsksToYolo and floorAllowsToAsk), which mirror each other.
|
|
const overlayAskBash: Rule = {
|
|
surface: "bash",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "config",
|
|
origin: "global",
|
|
};
|
|
const overlayDenyEnv: Rule = {
|
|
surface: "path",
|
|
pattern: ".env",
|
|
action: "deny",
|
|
layer: "config",
|
|
origin: "project",
|
|
};
|
|
const overlayAllowRead: Rule = {
|
|
surface: "read",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "agent",
|
|
};
|
|
const overlayAskDefault: Rule = {
|
|
surface: "*",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "default",
|
|
origin: "builtin",
|
|
};
|
|
|
|
describe("rewriteAsksToYolo", () => {
|
|
test("rewrites an ask rule to allow tagged origin 'yolo'", () => {
|
|
const result = rewriteAsksToYolo([overlayAskBash]);
|
|
expect(result).toEqual([
|
|
{
|
|
surface: "bash",
|
|
pattern: "*",
|
|
action: "allow",
|
|
layer: "config",
|
|
origin: "yolo",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("preserves surface, pattern, and layer while flipping ask", () => {
|
|
const [rewritten] = rewriteAsksToYolo([overlayAskBash]);
|
|
expect(rewritten.surface).toBe("bash");
|
|
expect(rewritten.pattern).toBe("*");
|
|
expect(rewritten.layer).toBe("config");
|
|
expect(rewritten.action).toBe("allow");
|
|
expect(rewritten.origin).toBe("yolo");
|
|
});
|
|
|
|
test("rewrites the synthesized universal default ask rule", () => {
|
|
const result = rewriteAsksToYolo([overlayAskDefault]);
|
|
expect(result[0]?.action).toBe("allow");
|
|
expect(result[0]?.origin).toBe("yolo");
|
|
expect(result[0]?.layer).toBe("default");
|
|
});
|
|
|
|
test("passes deny rules through untouched (preserves hard denies)", () => {
|
|
const result = rewriteAsksToYolo([overlayDenyEnv]);
|
|
expect(result).toEqual([overlayDenyEnv]);
|
|
});
|
|
|
|
test("passes allow rules through untouched", () => {
|
|
const result = rewriteAsksToYolo([overlayAllowRead]);
|
|
expect(result).toEqual([overlayAllowRead]);
|
|
});
|
|
|
|
test("rewrites only ask rules in a mixed ruleset, preserving order", () => {
|
|
const ruleset: Ruleset = [
|
|
overlayAskDefault,
|
|
overlayAllowRead,
|
|
overlayAskBash,
|
|
overlayDenyEnv,
|
|
];
|
|
const result = rewriteAsksToYolo(ruleset);
|
|
expect(result.map((r) => r.action)).toEqual([
|
|
"allow",
|
|
"allow",
|
|
"allow",
|
|
"deny",
|
|
]);
|
|
expect(result.map((r) => r.origin)).toEqual([
|
|
"yolo",
|
|
"agent",
|
|
"yolo",
|
|
"project",
|
|
]);
|
|
});
|
|
|
|
test("does not mutate the input ruleset", () => {
|
|
const ruleset: Ruleset = [overlayAskBash];
|
|
rewriteAsksToYolo(ruleset);
|
|
expect(ruleset[0]?.action).toBe("ask");
|
|
expect(ruleset[0]?.origin).toBe("global");
|
|
});
|
|
|
|
test("'yolo' is a valid RuleOrigin", () => {
|
|
const origin: RuleOrigin = "yolo";
|
|
expect(origin).toBe("yolo");
|
|
});
|
|
});
|
|
|
|
describe("floorAllowsToAsk", () => {
|
|
test("floors an allow rule to ask tagged origin 'fail-closed'", () => {
|
|
const result = floorAllowsToAsk([overlayAllowRead]);
|
|
expect(result).toEqual([
|
|
{
|
|
surface: "read",
|
|
pattern: "*",
|
|
action: "ask",
|
|
layer: "config",
|
|
origin: "fail-closed",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("preserves surface, pattern, and layer while flooring allow", () => {
|
|
const [floored] = floorAllowsToAsk([overlayAllowRead]);
|
|
expect(floored.surface).toBe("read");
|
|
expect(floored.pattern).toBe("*");
|
|
expect(floored.layer).toBe("config");
|
|
expect(floored.action).toBe("ask");
|
|
expect(floored.origin).toBe("fail-closed");
|
|
});
|
|
|
|
test("passes deny rules through untouched (preserves hard denies)", () => {
|
|
const result = floorAllowsToAsk([overlayDenyEnv]);
|
|
expect(result).toEqual([overlayDenyEnv]);
|
|
});
|
|
|
|
test("passes ask rules through untouched", () => {
|
|
const result = floorAllowsToAsk([overlayAskBash]);
|
|
expect(result).toEqual([overlayAskBash]);
|
|
});
|
|
|
|
test("floors only allow rules in a mixed ruleset, preserving order", () => {
|
|
const ruleset: Ruleset = [
|
|
overlayAskDefault,
|
|
overlayAllowRead,
|
|
overlayAskBash,
|
|
overlayDenyEnv,
|
|
];
|
|
const result = floorAllowsToAsk(ruleset);
|
|
expect(result.map((r) => r.action)).toEqual(["ask", "ask", "ask", "deny"]);
|
|
expect(result.map((r) => r.origin)).toEqual([
|
|
"builtin",
|
|
"fail-closed",
|
|
"global",
|
|
"project",
|
|
]);
|
|
});
|
|
|
|
test("does not mutate the input ruleset", () => {
|
|
const ruleset: Ruleset = [overlayAllowRead];
|
|
floorAllowsToAsk(ruleset);
|
|
expect(ruleset[0]?.action).toBe("allow");
|
|
expect(ruleset[0]?.origin).toBe("agent");
|
|
});
|
|
|
|
test("'fail-closed' is a valid RuleOrigin", () => {
|
|
const origin: RuleOrigin = "fail-closed";
|
|
expect(origin).toBe("fail-closed");
|
|
});
|
|
});
|