@gotgenes/pi-permission-system 5.15.0 → 5.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +14 -0
- package/config/config.example.json +7 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +8 -1
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/helpers.ts +4 -1
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/gates/tool.ts +25 -9
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/input-normalizer.ts +13 -2
- package/src/pattern-suggest.ts +12 -2
- package/src/permission-manager.ts +1 -1
- package/src/rule.ts +27 -0
- package/tests/bash-external-directory.test.ts +81 -1
- package/tests/handlers/external-directory-integration.test.ts +84 -3
- package/tests/handlers/gates/bash-path.test.ts +260 -0
- package/tests/handlers/gates/helpers.test.ts +15 -2
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +65 -4
- package/tests/pattern-suggest.test.ts +40 -12
- package/tests/permission-manager-unified.test.ts +341 -0
- package/tests/rule.test.ts +77 -1
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { prefix } from "./bash-arity";
|
|
2
|
+
import { PATH_BEARING_TOOLS } from "./path-utils";
|
|
2
3
|
import { deriveApprovalPattern } from "./session-rules";
|
|
3
4
|
|
|
4
5
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -69,7 +70,11 @@ function buildLabel(pattern: string, surface: string): string {
|
|
|
69
70
|
case "external_directory":
|
|
70
71
|
return `Yes, allow access to external directory "${pattern}" for this session`;
|
|
71
72
|
default:
|
|
72
|
-
//
|
|
73
|
+
// Path-bearing tools with a specific path pattern show the pattern.
|
|
74
|
+
if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
|
|
75
|
+
return `Yes, allow ${surface} "${pattern}" for this session`;
|
|
76
|
+
}
|
|
77
|
+
// Tool surfaces with catch-all or extension tools.
|
|
73
78
|
return `Yes, allow tool "${surface}" for this session`;
|
|
74
79
|
}
|
|
75
80
|
}
|
|
@@ -100,7 +105,12 @@ export function suggestSessionPattern(
|
|
|
100
105
|
pattern = deriveApprovalPattern(value);
|
|
101
106
|
break;
|
|
102
107
|
default:
|
|
103
|
-
//
|
|
108
|
+
// Path-bearing tools: derive a directory-scoped pattern from the path.
|
|
109
|
+
if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
|
|
110
|
+
pattern = deriveApprovalPattern(value);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
// Extension tools / fallback.
|
|
104
114
|
pattern = "*";
|
|
105
115
|
break;
|
|
106
116
|
}
|
|
@@ -30,7 +30,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
|
30
30
|
"find",
|
|
31
31
|
"ls",
|
|
32
32
|
]);
|
|
33
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
33
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
34
34
|
|
|
35
35
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
36
36
|
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
package/src/rule.ts
CHANGED
|
@@ -79,6 +79,33 @@ export function evaluate(
|
|
|
79
79
|
* evaluating the first candidate so the caller always receives a concrete
|
|
80
80
|
* result.
|
|
81
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* Evaluate a surface against multiple values, returning the most restrictive
|
|
84
|
+
* non-allow result (deny > ask > allow).
|
|
85
|
+
*
|
|
86
|
+
* Used by the cross-cutting `path` surface to aggregate permission decisions
|
|
87
|
+
* across multiple file paths extracted from a single tool call or bash command.
|
|
88
|
+
*
|
|
89
|
+
* Returns `null` when all values evaluate to `allow` (no restriction).
|
|
90
|
+
* Returns the first `deny` immediately (short-circuit).
|
|
91
|
+
* Returns the first `ask` if no `deny` is found.
|
|
92
|
+
*/
|
|
93
|
+
export function evaluateMostRestrictive(
|
|
94
|
+
surface: string,
|
|
95
|
+
values: string[],
|
|
96
|
+
rules: Ruleset,
|
|
97
|
+
): { rule: Rule; value: string } | null {
|
|
98
|
+
let worst: { rule: Rule; value: string } | null = null;
|
|
99
|
+
for (const value of values) {
|
|
100
|
+
const rule = evaluate(surface, value, rules);
|
|
101
|
+
if (rule.action === "deny") return { rule, value };
|
|
102
|
+
if (rule.action === "ask" && worst?.rule.action !== "ask") {
|
|
103
|
+
worst = { rule, value };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return worst;
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
export function evaluateFirst(
|
|
83
110
|
surface: string,
|
|
84
111
|
values: string[],
|
|
@@ -9,7 +9,10 @@ vi.mock("node:os", () => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
extractExternalPathsFromBashCommand,
|
|
14
|
+
extractTokensForPathRules,
|
|
15
|
+
} from "../src/handlers/gates/bash-path-extractor";
|
|
13
16
|
import {
|
|
14
17
|
formatBashExternalDirectoryAskPrompt,
|
|
15
18
|
formatBashExternalDirectoryDenyReason,
|
|
@@ -887,3 +890,80 @@ describe("formatBashExternalDirectoryDenyReason", () => {
|
|
|
887
890
|
expect(result).toContain("my-agent");
|
|
888
891
|
});
|
|
889
892
|
});
|
|
893
|
+
|
|
894
|
+
describe("extractTokensForPathRules", () => {
|
|
895
|
+
test("extracts dot-files: cat .env", async () => {
|
|
896
|
+
const tokens = await extractTokensForPathRules("cat .env");
|
|
897
|
+
expect(tokens).toContain(".env");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("extracts relative dot-paths: git add src/.env", async () => {
|
|
901
|
+
const tokens = await extractTokensForPathRules("git add src/.env");
|
|
902
|
+
expect(tokens).toContain("src/.env");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("extracts nothing from plain words: echo hello", async () => {
|
|
906
|
+
const tokens = await extractTokensForPathRules("echo hello");
|
|
907
|
+
expect(tokens).toHaveLength(0);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("extracts ./src and skips flags: rm -rf ./src", async () => {
|
|
911
|
+
const tokens = await extractTokensForPathRules("rm -rf ./src");
|
|
912
|
+
expect(tokens).toContain("./src");
|
|
913
|
+
expect(tokens).not.toContain("-rf");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("extracts absolute paths: cat /etc/hosts", async () => {
|
|
917
|
+
const tokens = await extractTokensForPathRules("cat /etc/hosts");
|
|
918
|
+
expect(tokens).toContain("/etc/hosts");
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("skips URLs: curl https://example.com", async () => {
|
|
922
|
+
const tokens = await extractTokensForPathRules("curl https://example.com");
|
|
923
|
+
expect(tokens).not.toContain("https://example.com");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test("extracts slash-containing tokens: cat src/foo.ts", async () => {
|
|
927
|
+
const tokens = await extractTokensForPathRules("cat src/foo.ts");
|
|
928
|
+
expect(tokens).toContain("src/foo.ts");
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("skips heredoc content", async () => {
|
|
932
|
+
const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
|
|
933
|
+
expect(tokens).not.toContain(".env");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("skips @scope/package patterns", async () => {
|
|
937
|
+
const tokens = await extractTokensForPathRules(
|
|
938
|
+
"npm install @scope/package",
|
|
939
|
+
);
|
|
940
|
+
expect(tokens).not.toContain("@scope/package");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("skips env assignments", async () => {
|
|
944
|
+
const tokens = await extractTokensForPathRules("FOO=/bar command");
|
|
945
|
+
expect(tokens).not.toContain("FOO=/bar");
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test("skips bare-slash tokens", async () => {
|
|
949
|
+
const tokens = await extractTokensForPathRules("ls /");
|
|
950
|
+
expect(tokens).not.toContain("/");
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("extracts redirect targets: echo test > .env", async () => {
|
|
954
|
+
const tokens = await extractTokensForPathRules("echo test > .env");
|
|
955
|
+
expect(tokens).toContain(".env");
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("extracts multiple path tokens: cp .env .env.backup", async () => {
|
|
959
|
+
const tokens = await extractTokensForPathRules("cp .env .env.backup");
|
|
960
|
+
expect(tokens).toContain(".env");
|
|
961
|
+
expect(tokens).toContain(".env.backup");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test("deduplicates repeated tokens", async () => {
|
|
965
|
+
const tokens = await extractTokensForPathRules("cat .env && rm .env");
|
|
966
|
+
const envCount = tokens.filter((t) => t === ".env").length;
|
|
967
|
+
expect(envCount).toBe(1);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
@@ -47,9 +47,29 @@ function makeCheckPermission(
|
|
|
47
47
|
return vi
|
|
48
48
|
.fn()
|
|
49
49
|
.mockImplementation((surface: string): PermissionCheckResult => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
if (surface === "external_directory") {
|
|
51
|
+
return {
|
|
52
|
+
state: externalDirectoryState,
|
|
53
|
+
toolName: surface,
|
|
54
|
+
source: "tool",
|
|
55
|
+
origin: "builtin",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// The cross-cutting path gate runs before ext-dir; keep it transparent.
|
|
59
|
+
if (surface === "path") {
|
|
60
|
+
return {
|
|
61
|
+
state: "allow",
|
|
62
|
+
toolName: surface,
|
|
63
|
+
source: "special",
|
|
64
|
+
origin: "builtin",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
state: toolState,
|
|
69
|
+
toolName: surface,
|
|
70
|
+
source: "tool",
|
|
71
|
+
origin: "builtin",
|
|
72
|
+
};
|
|
53
73
|
});
|
|
54
74
|
}
|
|
55
75
|
|
|
@@ -294,6 +314,67 @@ describe("external_directory policy state — allow", () => {
|
|
|
294
314
|
});
|
|
295
315
|
});
|
|
296
316
|
|
|
317
|
+
// #144: allow external reads, gate external writes
|
|
318
|
+
describe("external_directory — allow external reads, gate external writes (#144)", () => {
|
|
319
|
+
it("allows read of external path when external_directory and read are both allow", async () => {
|
|
320
|
+
const { handler } = makeHandler({
|
|
321
|
+
session: { checkPermission: makeCheckPermission("allow", "allow") },
|
|
322
|
+
});
|
|
323
|
+
const event = makeToolCallEvent("read", { path: EXTERNAL_PATH });
|
|
324
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
325
|
+
expect(result).toEqual({});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("prompts for write to external path when external_directory allows but write is ask", async () => {
|
|
329
|
+
const prompt = vi
|
|
330
|
+
.fn()
|
|
331
|
+
.mockResolvedValue({ approved: true, state: "approved" });
|
|
332
|
+
const { handler } = makeHandler({
|
|
333
|
+
session: {
|
|
334
|
+
checkPermission: makeCheckPermission("allow", "ask"),
|
|
335
|
+
prompt,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
339
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
340
|
+
// external_directory passes; write gate prompts and user approves
|
|
341
|
+
expect(result).toEqual({});
|
|
342
|
+
expect(prompt).toHaveBeenCalledOnce();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
|
346
|
+
const { handler } = makeHandler({
|
|
347
|
+
session: { checkPermission: makeCheckPermission("allow", "deny") },
|
|
348
|
+
});
|
|
349
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
350
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
351
|
+
expect(result.block).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("emits separate decision events for external_directory and write surfaces", async () => {
|
|
355
|
+
const { handler, events } = makeHandler({
|
|
356
|
+
session: { checkPermission: makeCheckPermission("allow", "deny") },
|
|
357
|
+
});
|
|
358
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
359
|
+
await handler.handleToolCall(event, makeCtx());
|
|
360
|
+
const decisions = getDecisionEvents(events);
|
|
361
|
+
const extDirDecision = decisions.find(
|
|
362
|
+
(d) => d.surface === "external_directory",
|
|
363
|
+
);
|
|
364
|
+
const writeDecision = decisions.find((d) => d.surface === "write");
|
|
365
|
+
expect(extDirDecision).toMatchObject({
|
|
366
|
+
surface: "external_directory",
|
|
367
|
+
result: "allow",
|
|
368
|
+
resolution: "policy_allow",
|
|
369
|
+
});
|
|
370
|
+
expect(writeDecision).toMatchObject({
|
|
371
|
+
surface: "write",
|
|
372
|
+
result: "deny",
|
|
373
|
+
resolution: "policy_deny",
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
297
378
|
describe("external_directory policy state — deny", () => {
|
|
298
379
|
it("blocks with reason containing the external path", async () => {
|
|
299
380
|
const { handler } = makeHandler({
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
import { describeBashPathGate } from "../../../src/handlers/gates/bash-path";
|
|
13
|
+
import type {
|
|
14
|
+
GateBypass,
|
|
15
|
+
GateDescriptor,
|
|
16
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
17
|
+
import {
|
|
18
|
+
isGateBypass,
|
|
19
|
+
isGateDescriptor,
|
|
20
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
21
|
+
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
22
|
+
import type { Rule } from "../../../src/rule";
|
|
23
|
+
import type { PermissionCheckResult } from "../../../src/types";
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
32
|
+
return {
|
|
33
|
+
toolName: "bash",
|
|
34
|
+
agentName: null,
|
|
35
|
+
input: { command: "cat .env" },
|
|
36
|
+
toolCallId: "tc-1",
|
|
37
|
+
cwd: "/test/project",
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeCheckResult(
|
|
43
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
44
|
+
): PermissionCheckResult {
|
|
45
|
+
return {
|
|
46
|
+
toolName: "path",
|
|
47
|
+
state: "allow",
|
|
48
|
+
source: "special",
|
|
49
|
+
origin: "global",
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CheckPermissionFn = (
|
|
55
|
+
surface: string,
|
|
56
|
+
input: unknown,
|
|
57
|
+
agentName?: string,
|
|
58
|
+
sessionRules?: Rule[],
|
|
59
|
+
) => PermissionCheckResult;
|
|
60
|
+
|
|
61
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("describeBashPathGate", () => {
|
|
64
|
+
it("returns null for non-bash tools", async () => {
|
|
65
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
66
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
67
|
+
const result = await describeBashPathGate(
|
|
68
|
+
makeTcc({ toolName: "read", input: { path: ".env" } }),
|
|
69
|
+
checkPermission,
|
|
70
|
+
getSessionRuleset,
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null when no tokens are extracted", async () => {
|
|
76
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
77
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
78
|
+
const result = await describeBashPathGate(
|
|
79
|
+
makeTcc({ input: { command: "echo hello" } }),
|
|
80
|
+
checkPermission,
|
|
81
|
+
getSessionRuleset,
|
|
82
|
+
);
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when all tokens evaluate to allow", async () => {
|
|
87
|
+
const checkPermission = vi
|
|
88
|
+
.fn<CheckPermissionFn>()
|
|
89
|
+
.mockReturnValue(makeCheckResult({ state: "allow" }));
|
|
90
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
91
|
+
const result = await describeBashPathGate(
|
|
92
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
93
|
+
checkPermission,
|
|
94
|
+
getSessionRuleset,
|
|
95
|
+
);
|
|
96
|
+
expect(result).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns GateDescriptor when a token evaluates to deny", async () => {
|
|
100
|
+
const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
|
|
101
|
+
makeCheckResult({
|
|
102
|
+
state: "deny",
|
|
103
|
+
matchedPattern: "*.env",
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
107
|
+
const result = await describeBashPathGate(
|
|
108
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
109
|
+
checkPermission,
|
|
110
|
+
getSessionRuleset,
|
|
111
|
+
);
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
114
|
+
const desc = result as GateDescriptor;
|
|
115
|
+
expect(desc.surface).toBe("path");
|
|
116
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns GateDescriptor when a token evaluates to ask", async () => {
|
|
120
|
+
const checkPermission = vi
|
|
121
|
+
.fn<CheckPermissionFn>()
|
|
122
|
+
.mockReturnValue(makeCheckResult({ state: "ask" }));
|
|
123
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
124
|
+
const result = await describeBashPathGate(
|
|
125
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
126
|
+
checkPermission,
|
|
127
|
+
getSessionRuleset,
|
|
128
|
+
);
|
|
129
|
+
expect(result).not.toBeNull();
|
|
130
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
131
|
+
const desc = result as GateDescriptor;
|
|
132
|
+
expect(desc.preCheck?.state).toBe("ask");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("descriptor includes triggering token in prompt message", async () => {
|
|
136
|
+
const checkPermission = vi
|
|
137
|
+
.fn<CheckPermissionFn>()
|
|
138
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
139
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
140
|
+
const result = (await describeBashPathGate(
|
|
141
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
142
|
+
checkPermission,
|
|
143
|
+
getSessionRuleset,
|
|
144
|
+
)) as GateDescriptor;
|
|
145
|
+
expect(result.messages.denyReason).toContain(".env");
|
|
146
|
+
expect(result.promptDetails.message).toContain(".env");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("descriptor decision uses surface 'path'", async () => {
|
|
150
|
+
const checkPermission = vi
|
|
151
|
+
.fn<CheckPermissionFn>()
|
|
152
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
153
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
154
|
+
const result = (await describeBashPathGate(
|
|
155
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
156
|
+
checkPermission,
|
|
157
|
+
getSessionRuleset,
|
|
158
|
+
)) as GateDescriptor;
|
|
159
|
+
expect(result.decision.surface).toBe("path");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns GateBypass when session rule covers the path", async () => {
|
|
163
|
+
const checkPermission = vi
|
|
164
|
+
.fn<CheckPermissionFn>()
|
|
165
|
+
.mockReturnValue(makeCheckResult({ state: "allow", source: "session" }));
|
|
166
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([
|
|
167
|
+
{
|
|
168
|
+
surface: "path",
|
|
169
|
+
pattern: "*",
|
|
170
|
+
action: "allow",
|
|
171
|
+
layer: "session",
|
|
172
|
+
origin: "session",
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
const result = await describeBashPathGate(
|
|
176
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
177
|
+
checkPermission,
|
|
178
|
+
getSessionRuleset,
|
|
179
|
+
);
|
|
180
|
+
expect(result).not.toBeNull();
|
|
181
|
+
expect(isGateBypass(result)).toBe(true);
|
|
182
|
+
expect((result as GateBypass).action).toBe("allow");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns null when command is missing", async () => {
|
|
186
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
187
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
188
|
+
const result = await describeBashPathGate(
|
|
189
|
+
makeTcc({ input: {} }),
|
|
190
|
+
checkPermission,
|
|
191
|
+
getSessionRuleset,
|
|
192
|
+
);
|
|
193
|
+
expect(result).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("evaluates most restrictive across multiple tokens", async () => {
|
|
197
|
+
const checkPermission = vi
|
|
198
|
+
.fn<CheckPermissionFn>()
|
|
199
|
+
.mockImplementation((_surface, input) => {
|
|
200
|
+
const record = input as Record<string, unknown>;
|
|
201
|
+
if (record.path === "src/foo.ts") {
|
|
202
|
+
return makeCheckResult({ state: "allow" });
|
|
203
|
+
}
|
|
204
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
205
|
+
});
|
|
206
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
207
|
+
const result = await describeBashPathGate(
|
|
208
|
+
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
209
|
+
checkPermission,
|
|
210
|
+
getSessionRuleset,
|
|
211
|
+
);
|
|
212
|
+
expect(result).not.toBeNull();
|
|
213
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
214
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("deny wins in multi-token: cp .env README.md", async () => {
|
|
218
|
+
const checkPermission = vi
|
|
219
|
+
.fn<CheckPermissionFn>()
|
|
220
|
+
.mockImplementation((_surface, input) => {
|
|
221
|
+
const record = input as Record<string, unknown>;
|
|
222
|
+
if (record.path === ".env") {
|
|
223
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
224
|
+
}
|
|
225
|
+
return makeCheckResult({ state: "allow" });
|
|
226
|
+
});
|
|
227
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
228
|
+
const result = await describeBashPathGate(
|
|
229
|
+
makeTcc({ input: { command: "cp .env README.md" } }),
|
|
230
|
+
checkPermission,
|
|
231
|
+
getSessionRuleset,
|
|
232
|
+
);
|
|
233
|
+
expect(result).not.toBeNull();
|
|
234
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
235
|
+
const desc = result as GateDescriptor;
|
|
236
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
237
|
+
expect(desc.decision.value).toBe(".env");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("extracts redirect target: echo test > .env triggers deny", async () => {
|
|
241
|
+
const checkPermission = vi
|
|
242
|
+
.fn<CheckPermissionFn>()
|
|
243
|
+
.mockImplementation((_surface, input) => {
|
|
244
|
+
const record = input as Record<string, unknown>;
|
|
245
|
+
if (record.path === ".env") {
|
|
246
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
247
|
+
}
|
|
248
|
+
return makeCheckResult({ state: "allow" });
|
|
249
|
+
});
|
|
250
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
251
|
+
const result = await describeBashPathGate(
|
|
252
|
+
makeTcc({ input: { command: "echo test > .env" } }),
|
|
253
|
+
checkPermission,
|
|
254
|
+
getSessionRuleset,
|
|
255
|
+
);
|
|
256
|
+
expect(result).not.toBeNull();
|
|
257
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
258
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -26,9 +26,22 @@ describe("deriveDecisionValue", () => {
|
|
|
26
26
|
expect(deriveDecisionValue("mcp", {})).toBe("mcp");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("returns toolName for
|
|
29
|
+
it("returns toolName for non-path-bearing tools", () => {
|
|
30
|
+
expect(deriveDecisionValue("my_extension_tool", {})).toBe(
|
|
31
|
+
"my_extension_tool",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns path for path-bearing tools when path is provided", () => {
|
|
36
|
+
expect(deriveDecisionValue("read", {}, "/project/src/main.ts")).toBe(
|
|
37
|
+
"/project/src/main.ts",
|
|
38
|
+
);
|
|
39
|
+
expect(deriveDecisionValue("write", {}, "src/.env")).toBe("src/.env");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to toolName for path-bearing tools when path is missing", () => {
|
|
30
43
|
expect(deriveDecisionValue("read", {})).toBe("read");
|
|
31
|
-
expect(deriveDecisionValue("write", {
|
|
44
|
+
expect(deriveDecisionValue("write", {}, undefined)).toBe("write");
|
|
32
45
|
});
|
|
33
46
|
});
|
|
34
47
|
|