@gotgenes/pi-permission-system 4.1.1 → 4.3.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 +19 -10
- package/package.json +3 -3
- package/src/external-directory.ts +208 -11
- package/src/forwarded-permissions/polling.ts +7 -1
- package/src/handlers/tool-call.ts +37 -1
- package/src/handlers/types.ts +2 -0
- package/src/pattern-suggest.ts +91 -0
- package/src/permission-dialog.ts +16 -2
- package/src/permission-gate.ts +11 -1
- package/src/permission-manager.ts +59 -0
- package/src/runtime.ts +1 -0
- package/tests/bash-external-directory.test.ts +244 -94
- package/tests/handlers/tool-call.test.ts +212 -0
- package/tests/pattern-suggest.test.ts +139 -0
- package/tests/permission-dialog.test.ts +39 -0
- package/tests/permission-gate.test.ts +68 -0
- package/tests/permission-system.test.ts +181 -0
|
@@ -447,3 +447,215 @@ describe("handleToolCall — bash external-directory gate", () => {
|
|
|
447
447
|
expect(result).toEqual({});
|
|
448
448
|
});
|
|
449
449
|
});
|
|
450
|
+
|
|
451
|
+
// ── session approval ───────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
describe("handleToolCall — session-hit detection (normal gate)", () => {
|
|
454
|
+
it("skips gate and logs session_approved when bash check returns source=session", async () => {
|
|
455
|
+
const sessionRules = {
|
|
456
|
+
approve: vi.fn(),
|
|
457
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
458
|
+
clear: vi.fn(),
|
|
459
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
460
|
+
const deps = makeDeps({
|
|
461
|
+
runtime: makeRuntime({
|
|
462
|
+
permissionManager: {
|
|
463
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
464
|
+
state: "allow",
|
|
465
|
+
toolName: "bash",
|
|
466
|
+
source: "session",
|
|
467
|
+
command: "git status",
|
|
468
|
+
matchedPattern: "git *",
|
|
469
|
+
}),
|
|
470
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
471
|
+
sessionRules,
|
|
472
|
+
}),
|
|
473
|
+
});
|
|
474
|
+
const event = makeToolCallEvent("bash", {
|
|
475
|
+
input: { command: "git status" },
|
|
476
|
+
});
|
|
477
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
478
|
+
expect(result).toEqual({});
|
|
479
|
+
expect(deps.runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
480
|
+
"permission_request.session_approved",
|
|
481
|
+
expect.objectContaining({
|
|
482
|
+
resolution: "session_approved",
|
|
483
|
+
toolName: "bash",
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("skips gate and logs session_approved when mcp check returns source=session", async () => {
|
|
489
|
+
const deps = makeDeps({
|
|
490
|
+
runtime: makeRuntime({
|
|
491
|
+
permissionManager: {
|
|
492
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
493
|
+
state: "allow",
|
|
494
|
+
toolName: "mcp",
|
|
495
|
+
source: "session",
|
|
496
|
+
target: "exa:search",
|
|
497
|
+
matchedPattern: "exa:*",
|
|
498
|
+
}),
|
|
499
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
500
|
+
}),
|
|
501
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "mcp" }]),
|
|
502
|
+
});
|
|
503
|
+
const event = makeToolCallEvent("mcp", { input: { tool: "exa:search" } });
|
|
504
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
505
|
+
expect(result).toEqual({});
|
|
506
|
+
expect(deps.runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
507
|
+
"permission_request.session_approved",
|
|
508
|
+
expect.objectContaining({
|
|
509
|
+
resolution: "session_approved",
|
|
510
|
+
toolName: "mcp",
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("does NOT call sessionRules.approve when source is session (already recorded)", async () => {
|
|
516
|
+
const sessionRules = {
|
|
517
|
+
approve: vi.fn(),
|
|
518
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
519
|
+
clear: vi.fn(),
|
|
520
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
521
|
+
const deps = makeDeps({
|
|
522
|
+
runtime: makeRuntime({
|
|
523
|
+
permissionManager: {
|
|
524
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
525
|
+
state: "allow",
|
|
526
|
+
toolName: "bash",
|
|
527
|
+
source: "session",
|
|
528
|
+
command: "git status",
|
|
529
|
+
matchedPattern: "git *",
|
|
530
|
+
}),
|
|
531
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
532
|
+
sessionRules,
|
|
533
|
+
}),
|
|
534
|
+
});
|
|
535
|
+
const event = makeToolCallEvent("bash", {
|
|
536
|
+
input: { command: "git status" },
|
|
537
|
+
});
|
|
538
|
+
await handleToolCall(deps, event, makeCtx());
|
|
539
|
+
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("handleToolCall — session recording on approved_for_session", () => {
|
|
544
|
+
it("records bash session approval with suggestBashPattern result", async () => {
|
|
545
|
+
const sessionRules = {
|
|
546
|
+
approve: vi.fn(),
|
|
547
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
548
|
+
clear: vi.fn(),
|
|
549
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
550
|
+
const deps = makeDeps({
|
|
551
|
+
runtime: makeRuntime({
|
|
552
|
+
permissionManager: {
|
|
553
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
554
|
+
state: "ask",
|
|
555
|
+
toolName: "bash",
|
|
556
|
+
source: "bash",
|
|
557
|
+
command: "git status",
|
|
558
|
+
}),
|
|
559
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
560
|
+
sessionRules,
|
|
561
|
+
}),
|
|
562
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
563
|
+
promptPermission: vi
|
|
564
|
+
.fn()
|
|
565
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
566
|
+
});
|
|
567
|
+
const event = makeToolCallEvent("bash", {
|
|
568
|
+
input: { command: "git status" },
|
|
569
|
+
});
|
|
570
|
+
await handleToolCall(deps, event, makeCtx());
|
|
571
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("bash", "git *");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("records mcp session approval with suggestMcpPattern result", async () => {
|
|
575
|
+
const sessionRules = {
|
|
576
|
+
approve: vi.fn(),
|
|
577
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
578
|
+
clear: vi.fn(),
|
|
579
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
580
|
+
const deps = makeDeps({
|
|
581
|
+
runtime: makeRuntime({
|
|
582
|
+
permissionManager: {
|
|
583
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
584
|
+
state: "ask",
|
|
585
|
+
toolName: "mcp",
|
|
586
|
+
source: "mcp",
|
|
587
|
+
target: "exa:search",
|
|
588
|
+
}),
|
|
589
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
590
|
+
sessionRules,
|
|
591
|
+
}),
|
|
592
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "mcp" }]),
|
|
593
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
594
|
+
promptPermission: vi
|
|
595
|
+
.fn()
|
|
596
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
597
|
+
});
|
|
598
|
+
const event = makeToolCallEvent("mcp", { input: { tool: "exa:search" } });
|
|
599
|
+
await handleToolCall(deps, event, makeCtx());
|
|
600
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("mcp", "exa:*");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("records tool session approval with * pattern for read surface", async () => {
|
|
604
|
+
const sessionRules = {
|
|
605
|
+
approve: vi.fn(),
|
|
606
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
607
|
+
clear: vi.fn(),
|
|
608
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
609
|
+
const deps = makeDeps({
|
|
610
|
+
runtime: makeRuntime({
|
|
611
|
+
permissionManager: {
|
|
612
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
613
|
+
state: "ask",
|
|
614
|
+
toolName: "read",
|
|
615
|
+
source: "tool",
|
|
616
|
+
}),
|
|
617
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
618
|
+
sessionRules,
|
|
619
|
+
}),
|
|
620
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
621
|
+
promptPermission: vi
|
|
622
|
+
.fn()
|
|
623
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
624
|
+
});
|
|
625
|
+
const event = makeToolCallEvent("read", {
|
|
626
|
+
input: { path: "/test/project/foo.ts" },
|
|
627
|
+
});
|
|
628
|
+
await handleToolCall(deps, event, makeCtx());
|
|
629
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("read", "*");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("does NOT call sessionRules.approve when user approves once (not for session)", async () => {
|
|
633
|
+
const sessionRules = {
|
|
634
|
+
approve: vi.fn(),
|
|
635
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
636
|
+
clear: vi.fn(),
|
|
637
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
638
|
+
const deps = makeDeps({
|
|
639
|
+
runtime: makeRuntime({
|
|
640
|
+
permissionManager: {
|
|
641
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
642
|
+
state: "ask",
|
|
643
|
+
toolName: "bash",
|
|
644
|
+
source: "bash",
|
|
645
|
+
command: "git status",
|
|
646
|
+
}),
|
|
647
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
648
|
+
sessionRules,
|
|
649
|
+
}),
|
|
650
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
651
|
+
promptPermission: vi
|
|
652
|
+
.fn()
|
|
653
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
654
|
+
});
|
|
655
|
+
const event = makeToolCallEvent("bash", {
|
|
656
|
+
input: { command: "git status" },
|
|
657
|
+
});
|
|
658
|
+
await handleToolCall(deps, event, makeCtx());
|
|
659
|
+
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
suggestBashPattern,
|
|
4
|
+
suggestMcpPattern,
|
|
5
|
+
suggestSessionPattern,
|
|
6
|
+
} from "../src/pattern-suggest";
|
|
7
|
+
|
|
8
|
+
describe("suggestBashPattern", () => {
|
|
9
|
+
it("returns <command> * for a multi-word command", () => {
|
|
10
|
+
expect(suggestBashPattern("git status --short")).toBe("git *");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses only the first word as the base", () => {
|
|
14
|
+
expect(suggestBashPattern("npm run build")).toBe("npm *");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns the exact command when there are no arguments", () => {
|
|
18
|
+
expect(suggestBashPattern("ls")).toBe("ls");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("trims leading and trailing whitespace", () => {
|
|
22
|
+
expect(suggestBashPattern(" git log ")).toBe("git *");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles empty string gracefully", () => {
|
|
26
|
+
expect(suggestBashPattern("")).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("suggestMcpPattern", () => {
|
|
31
|
+
it("suggests server:* for a qualified target (colon-separated)", () => {
|
|
32
|
+
expect(suggestMcpPattern("exa:search")).toBe("exa:*");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("suggests server_* for a munged target (underscore-separated)", () => {
|
|
36
|
+
expect(suggestMcpPattern("exa_search")).toBe("exa_*");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("suggests * for a bare 'mcp' target", () => {
|
|
40
|
+
expect(suggestMcpPattern("mcp")).toBe("*");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("suggests * for a plain tool name with no server prefix", () => {
|
|
44
|
+
expect(suggestMcpPattern("search")).toBe("*");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("prefers colon over underscore when both are present", () => {
|
|
48
|
+
// Qualified names contain ':'; the colon check runs first.
|
|
49
|
+
expect(suggestMcpPattern("my-server:some_tool")).toBe("my-server:*");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("suggestSessionPattern", () => {
|
|
54
|
+
describe("bash surface", () => {
|
|
55
|
+
it("returns bash surface with <command> * pattern for multi-word command", () => {
|
|
56
|
+
const result = suggestSessionPattern("bash", "git status --short");
|
|
57
|
+
expect(result).toMatchObject({
|
|
58
|
+
surface: "bash",
|
|
59
|
+
pattern: "git *",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns exact command for single-word bash command", () => {
|
|
64
|
+
const result = suggestSessionPattern("bash", "ls");
|
|
65
|
+
expect(result).toMatchObject({ surface: "bash", pattern: "ls" });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("mcp surface", () => {
|
|
70
|
+
it("returns mcp surface with server:* for qualified target", () => {
|
|
71
|
+
const result = suggestSessionPattern("mcp", "exa:search");
|
|
72
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "exa:*" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns mcp surface with server_* for munged target", () => {
|
|
76
|
+
const result = suggestSessionPattern("mcp", "exa_search");
|
|
77
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "exa_*" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns * for bare mcp target", () => {
|
|
81
|
+
const result = suggestSessionPattern("mcp", "mcp");
|
|
82
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "*" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("skill surface", () => {
|
|
87
|
+
it("returns exact skill name as pattern", () => {
|
|
88
|
+
const result = suggestSessionPattern("skill", "librarian");
|
|
89
|
+
expect(result).toMatchObject({ surface: "skill", pattern: "librarian" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("external_directory surface", () => {
|
|
94
|
+
it("returns parent-directory glob from deriveApprovalPattern", () => {
|
|
95
|
+
const result = suggestSessionPattern(
|
|
96
|
+
"external_directory",
|
|
97
|
+
"/tmp/foo.txt",
|
|
98
|
+
);
|
|
99
|
+
expect(result).toMatchObject({
|
|
100
|
+
surface: "external_directory",
|
|
101
|
+
pattern: "/tmp/*",
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("tool surfaces", () => {
|
|
107
|
+
it("returns * for read surface", () => {
|
|
108
|
+
const result = suggestSessionPattern("read", "*");
|
|
109
|
+
expect(result).toMatchObject({ surface: "read", pattern: "*" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns * for write surface", () => {
|
|
113
|
+
const result = suggestSessionPattern("write", "*");
|
|
114
|
+
expect(result).toMatchObject({ surface: "write", pattern: "*" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns * for edit surface", () => {
|
|
118
|
+
const result = suggestSessionPattern("edit", "*");
|
|
119
|
+
expect(result).toMatchObject({ surface: "edit", pattern: "*" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("label field", () => {
|
|
124
|
+
it("includes the suggested pattern in the label", () => {
|
|
125
|
+
const result = suggestSessionPattern("bash", "git status");
|
|
126
|
+
expect(result.label).toContain("git *");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("wraps the pattern in quotes in the label", () => {
|
|
130
|
+
const result = suggestSessionPattern("mcp", "exa:search");
|
|
131
|
+
expect(result.label).toContain('"exa:*"');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("label reads as a natural session-approval option", () => {
|
|
135
|
+
const result = suggestSessionPattern("skill", "librarian");
|
|
136
|
+
expect(result.label).toBe('Yes, allow "librarian" for this session');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -132,6 +132,45 @@ describe("requestPermissionDecisionFromUi", () => {
|
|
|
132
132
|
"No, provide reason",
|
|
133
133
|
]);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("uses custom sessionLabel when provided", async () => {
|
|
137
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
138
|
+
const ui: PermissionDecisionUi = {
|
|
139
|
+
select: selectFn,
|
|
140
|
+
input: vi.fn(),
|
|
141
|
+
};
|
|
142
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
|
|
143
|
+
sessionLabel: 'Yes, allow "git *" for this session',
|
|
144
|
+
});
|
|
145
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
146
|
+
expect(options[1]).toBe('Yes, allow "git *" for this session');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("still returns approved_for_session when user selects the custom session label", async () => {
|
|
150
|
+
const customLabel = 'Yes, allow "git *" for this session';
|
|
151
|
+
const ui: PermissionDecisionUi = {
|
|
152
|
+
select: vi.fn().mockResolvedValue(customLabel),
|
|
153
|
+
input: vi.fn(),
|
|
154
|
+
};
|
|
155
|
+
const result = await requestPermissionDecisionFromUi(
|
|
156
|
+
ui,
|
|
157
|
+
"Title",
|
|
158
|
+
"Message",
|
|
159
|
+
{ sessionLabel: customLabel },
|
|
160
|
+
);
|
|
161
|
+
expect(result).toEqual({ approved: true, state: "approved_for_session" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("falls back to default session label when no options provided", async () => {
|
|
165
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
166
|
+
const ui: PermissionDecisionUi = {
|
|
167
|
+
select: selectFn,
|
|
168
|
+
input: vi.fn(),
|
|
169
|
+
};
|
|
170
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message");
|
|
171
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
172
|
+
expect(options[1]).toBe("Yes, for this session");
|
|
173
|
+
});
|
|
135
174
|
});
|
|
136
175
|
|
|
137
176
|
describe("normalizePermissionDenialReason", () => {
|
|
@@ -179,6 +179,74 @@ describe("applyPermissionGate", () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
describe("ask branch — approved_for_session with sessionApproval", () => {
|
|
183
|
+
it("attaches sessionApproval to result when decision is approved_for_session and param provided", async () => {
|
|
184
|
+
const decision: PermissionPromptDecision = {
|
|
185
|
+
approved: true,
|
|
186
|
+
state: "approved_for_session",
|
|
187
|
+
};
|
|
188
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
189
|
+
const params = makeParams({
|
|
190
|
+
state: "ask",
|
|
191
|
+
canConfirm: true,
|
|
192
|
+
promptForApproval,
|
|
193
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
194
|
+
});
|
|
195
|
+
const result = await applyPermissionGate(params);
|
|
196
|
+
expect(result).toEqual({
|
|
197
|
+
action: "allow",
|
|
198
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not attach sessionApproval when decision is approved (once)", async () => {
|
|
203
|
+
const decision: PermissionPromptDecision = {
|
|
204
|
+
approved: true,
|
|
205
|
+
state: "approved",
|
|
206
|
+
};
|
|
207
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
208
|
+
const params = makeParams({
|
|
209
|
+
state: "ask",
|
|
210
|
+
canConfirm: true,
|
|
211
|
+
promptForApproval,
|
|
212
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
213
|
+
});
|
|
214
|
+
const result = await applyPermissionGate(params);
|
|
215
|
+
expect(result).toEqual({ action: "allow" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not attach sessionApproval when no sessionApproval param", async () => {
|
|
219
|
+
const decision: PermissionPromptDecision = {
|
|
220
|
+
approved: true,
|
|
221
|
+
state: "approved_for_session",
|
|
222
|
+
};
|
|
223
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
224
|
+
const params = makeParams({
|
|
225
|
+
state: "ask",
|
|
226
|
+
canConfirm: true,
|
|
227
|
+
promptForApproval,
|
|
228
|
+
});
|
|
229
|
+
const result = await applyPermissionGate(params);
|
|
230
|
+
expect(result).toEqual({ action: "allow" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not attach sessionApproval when user denies", async () => {
|
|
234
|
+
const decision: PermissionPromptDecision = {
|
|
235
|
+
approved: false,
|
|
236
|
+
state: "denied",
|
|
237
|
+
};
|
|
238
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
239
|
+
const params = makeParams({
|
|
240
|
+
state: "ask",
|
|
241
|
+
canConfirm: true,
|
|
242
|
+
promptForApproval,
|
|
243
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
244
|
+
});
|
|
245
|
+
const result = await applyPermissionGate(params);
|
|
246
|
+
expect(result).toEqual({ action: "block", reason: "User denied." });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
182
250
|
describe("allow branch", () => {
|
|
183
251
|
it("returns allow immediately when state is allow", async () => {
|
|
184
252
|
const params = makeParams({ state: "allow" });
|
|
@@ -2620,6 +2620,187 @@ test("session rules override config deny for external_directory", () => {
|
|
|
2620
2620
|
}
|
|
2621
2621
|
});
|
|
2622
2622
|
|
|
2623
|
+
// ── Session rule evaluation for all surfaces ─────────────────────────────
|
|
2624
|
+
|
|
2625
|
+
test("checkPermission returns source 'session' for bash when session rules match", () => {
|
|
2626
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2627
|
+
|
|
2628
|
+
try {
|
|
2629
|
+
const sessionRules = [
|
|
2630
|
+
{
|
|
2631
|
+
surface: "bash",
|
|
2632
|
+
pattern: "git *",
|
|
2633
|
+
action: "allow" as const,
|
|
2634
|
+
layer: "session" as const,
|
|
2635
|
+
},
|
|
2636
|
+
];
|
|
2637
|
+
|
|
2638
|
+
const result = manager.checkPermission(
|
|
2639
|
+
"bash",
|
|
2640
|
+
{ command: "git status --short" },
|
|
2641
|
+
undefined,
|
|
2642
|
+
sessionRules,
|
|
2643
|
+
);
|
|
2644
|
+
assert.equal(result.state, "allow");
|
|
2645
|
+
assert.equal(result.source, "session");
|
|
2646
|
+
assert.equal(result.matchedPattern, "git *");
|
|
2647
|
+
} finally {
|
|
2648
|
+
cleanup();
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
|
|
2653
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2654
|
+
|
|
2655
|
+
try {
|
|
2656
|
+
const sessionRules = [
|
|
2657
|
+
{
|
|
2658
|
+
surface: "bash",
|
|
2659
|
+
pattern: "ls",
|
|
2660
|
+
action: "allow" as const,
|
|
2661
|
+
layer: "session" as const,
|
|
2662
|
+
},
|
|
2663
|
+
];
|
|
2664
|
+
|
|
2665
|
+
const result = manager.checkPermission(
|
|
2666
|
+
"bash",
|
|
2667
|
+
{ command: "ls" },
|
|
2668
|
+
undefined,
|
|
2669
|
+
sessionRules,
|
|
2670
|
+
);
|
|
2671
|
+
assert.equal(result.state, "allow");
|
|
2672
|
+
assert.equal(result.source, "session");
|
|
2673
|
+
} finally {
|
|
2674
|
+
cleanup();
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
|
|
2679
|
+
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
|
|
2680
|
+
|
|
2681
|
+
try {
|
|
2682
|
+
const sessionRules = [
|
|
2683
|
+
{
|
|
2684
|
+
surface: "bash",
|
|
2685
|
+
pattern: "git *",
|
|
2686
|
+
action: "allow" as const,
|
|
2687
|
+
layer: "session" as const,
|
|
2688
|
+
},
|
|
2689
|
+
];
|
|
2690
|
+
|
|
2691
|
+
const result = manager.checkPermission(
|
|
2692
|
+
"bash",
|
|
2693
|
+
{ command: "npm run build" },
|
|
2694
|
+
undefined,
|
|
2695
|
+
sessionRules,
|
|
2696
|
+
);
|
|
2697
|
+
assert.equal(result.state, "deny");
|
|
2698
|
+
assert.equal(result.source, "bash");
|
|
2699
|
+
} finally {
|
|
2700
|
+
cleanup();
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
|
|
2705
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2706
|
+
|
|
2707
|
+
try {
|
|
2708
|
+
const sessionRules = [
|
|
2709
|
+
{
|
|
2710
|
+
surface: "mcp",
|
|
2711
|
+
pattern: "exa:*",
|
|
2712
|
+
action: "allow" as const,
|
|
2713
|
+
layer: "session" as const,
|
|
2714
|
+
},
|
|
2715
|
+
];
|
|
2716
|
+
|
|
2717
|
+
const result = manager.checkPermission(
|
|
2718
|
+
"mcp",
|
|
2719
|
+
{ tool: "exa:search" },
|
|
2720
|
+
undefined,
|
|
2721
|
+
sessionRules,
|
|
2722
|
+
);
|
|
2723
|
+
assert.equal(result.state, "allow");
|
|
2724
|
+
assert.equal(result.source, "session");
|
|
2725
|
+
} finally {
|
|
2726
|
+
cleanup();
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
test("checkPermission returns source 'session' for skill when session rules match", () => {
|
|
2731
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2732
|
+
|
|
2733
|
+
try {
|
|
2734
|
+
const sessionRules = [
|
|
2735
|
+
{
|
|
2736
|
+
surface: "skill",
|
|
2737
|
+
pattern: "librarian",
|
|
2738
|
+
action: "allow" as const,
|
|
2739
|
+
layer: "session" as const,
|
|
2740
|
+
},
|
|
2741
|
+
];
|
|
2742
|
+
|
|
2743
|
+
const result = manager.checkPermission(
|
|
2744
|
+
"skill",
|
|
2745
|
+
{ name: "librarian" },
|
|
2746
|
+
undefined,
|
|
2747
|
+
sessionRules,
|
|
2748
|
+
);
|
|
2749
|
+
assert.equal(result.state, "allow");
|
|
2750
|
+
assert.equal(result.source, "session");
|
|
2751
|
+
assert.equal(result.matchedPattern, "librarian");
|
|
2752
|
+
} finally {
|
|
2753
|
+
cleanup();
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
|
|
2758
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2759
|
+
|
|
2760
|
+
try {
|
|
2761
|
+
const sessionRules = [
|
|
2762
|
+
{
|
|
2763
|
+
surface: "read",
|
|
2764
|
+
pattern: "*",
|
|
2765
|
+
action: "allow" as const,
|
|
2766
|
+
layer: "session" as const,
|
|
2767
|
+
},
|
|
2768
|
+
];
|
|
2769
|
+
|
|
2770
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
2771
|
+
assert.equal(result.state, "allow");
|
|
2772
|
+
assert.equal(result.source, "session");
|
|
2773
|
+
} finally {
|
|
2774
|
+
cleanup();
|
|
2775
|
+
}
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
test("bash session rules do not bleed into mcp checks", () => {
|
|
2779
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2780
|
+
|
|
2781
|
+
try {
|
|
2782
|
+
const sessionRules = [
|
|
2783
|
+
{
|
|
2784
|
+
surface: "bash",
|
|
2785
|
+
pattern: "git *",
|
|
2786
|
+
action: "allow" as const,
|
|
2787
|
+
layer: "session" as const,
|
|
2788
|
+
},
|
|
2789
|
+
];
|
|
2790
|
+
|
|
2791
|
+
const result = manager.checkPermission(
|
|
2792
|
+
"mcp",
|
|
2793
|
+
{ tool: "exa:search" },
|
|
2794
|
+
undefined,
|
|
2795
|
+
sessionRules,
|
|
2796
|
+
);
|
|
2797
|
+
// bash session rule must not affect mcp surface
|
|
2798
|
+
assert.notEqual(result.source, "session");
|
|
2799
|
+
} finally {
|
|
2800
|
+
cleanup();
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2623
2804
|
// Suppress unused import warning — PermissionState used in type annotations
|
|
2624
2805
|
const _unused: PermissionState = "ask";
|
|
2625
2806
|
void _unused;
|