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