@gotgenes/pi-permission-system 8.2.0 → 8.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/builtin-tool-input-formatters.ts +82 -0
  4. package/src/config-loader.ts +53 -46
  5. package/src/handlers/gates/bash-path-extractor.ts +135 -169
  6. package/src/handlers/gates/bash-token-classification.ts +105 -0
  7. package/src/handlers/permission-gate-handler.ts +3 -0
  8. package/src/index.ts +13 -1
  9. package/src/permission-prompts.ts +5 -1
  10. package/src/service.ts +21 -1
  11. package/src/tool-input-formatter-registry.ts +57 -0
  12. package/src/tool-preview-formatter.ts +18 -1
  13. package/test/builtin-tool-input-formatters.test.ts +109 -0
  14. package/test/config-loader.test.ts +82 -0
  15. package/test/handlers/before-agent-start.test.ts +2 -20
  16. package/test/handlers/external-directory-integration.test.ts +43 -81
  17. package/test/handlers/external-directory-session-dedup.test.ts +2 -29
  18. package/test/handlers/gates/bash-path.test.ts +5 -26
  19. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  20. package/test/handlers/gates/path.test.ts +3 -12
  21. package/test/handlers/gates/runner.test.ts +78 -91
  22. package/test/handlers/input-events.test.ts +42 -95
  23. package/test/handlers/input.test.ts +3 -71
  24. package/test/handlers/lifecycle.test.ts +3 -20
  25. package/test/handlers/tool-call-events.test.ts +30 -127
  26. package/test/handlers/tool-call.test.ts +21 -110
  27. package/test/helpers/gate-fixtures.ts +105 -0
  28. package/test/helpers/handler-fixtures.ts +141 -0
  29. package/test/helpers/manager-harness.ts +51 -0
  30. package/test/permission-prompts.test.ts +53 -7
  31. package/test/permission-session.test.ts +1 -19
  32. package/test/permission-system.test.ts +4 -40
  33. package/test/service.test.ts +52 -0
  34. package/test/tool-input-formatter-registry.test.ts +75 -0
  35. package/test/tool-preview-formatter.test.ts +73 -0
@@ -0,0 +1,241 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ classifyTokenAsPathCandidate,
5
+ classifyTokenAsRuleCandidate,
6
+ } from "#src/handlers/gates/bash-token-classification";
7
+
8
+ // ── Shared rejection behaviour ─────────────────────────────────────────────
9
+ //
10
+ // Both classifiers delegate to the private `rejectNonPathToken` predicate for
11
+ // the seven shared rejection cases tested below. Testing via both exports
12
+ // pins that predicate through each caller.
13
+
14
+ describe("classifyTokenAsPathCandidate", () => {
15
+ describe("shared rejection: rejectNonPathToken", () => {
16
+ test("empty string → null", () => {
17
+ expect(classifyTokenAsPathCandidate("")).toBeNull();
18
+ });
19
+
20
+ test("flag (leading dash) → null", () => {
21
+ expect(classifyTokenAsPathCandidate("-r")).toBeNull();
22
+ expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
23
+ });
24
+
25
+ test("env assignment (= before any /) → null", () => {
26
+ expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
27
+ expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
28
+ });
29
+
30
+ test("env-like token where = comes after / is NOT rejected as assignment", () => {
31
+ // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
32
+ // Starts with /, so path candidate accepts it.
33
+ expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
34
+ });
35
+
36
+ test("URL → null", () => {
37
+ expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
38
+ expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
39
+ expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
40
+ expect(
41
+ classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
42
+ ).toBeNull();
43
+ });
44
+
45
+ test("@scope/package → null", () => {
46
+ expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
47
+ expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
48
+ });
49
+
50
+ test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
51
+ // @/ passes the @ guard; then for path candidate it doesn't start with /
52
+ // or ~/, and doesn't contain .., so it returns null anyway from the
53
+ // acceptance gate — but the rejection is not due to the @ guard.
54
+ // This test documents that @/ is not rejected by the shared rejection.
55
+ // The path classifier then rejects it for not matching any acceptance shape.
56
+ expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
57
+ });
58
+
59
+ test("bare-slash token → null", () => {
60
+ expect(classifyTokenAsPathCandidate("/")).toBeNull();
61
+ expect(classifyTokenAsPathCandidate("//")).toBeNull();
62
+ expect(classifyTokenAsPathCandidate("///")).toBeNull();
63
+ });
64
+
65
+ test("regex metacharacters → null", () => {
66
+ // REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
67
+ expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
68
+ expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
69
+ expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
70
+ expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
71
+ expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
72
+ expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe("path-candidate acceptance gate", () => {
77
+ test("absolute path (starts with /) → returned as-is", () => {
78
+ expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
79
+ expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
80
+ expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
81
+ "/home/user/file.txt",
82
+ );
83
+ });
84
+
85
+ test("home-relative path (starts with ~/) → returned as-is", () => {
86
+ expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
87
+ expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
88
+ "~/.ssh/config",
89
+ );
90
+ });
91
+
92
+ test("parent-traversal (contains ..) → returned as-is", () => {
93
+ expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
94
+ "../../etc/passwd",
95
+ );
96
+ expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
97
+ expect(classifyTokenAsPathCandidate("..")).toBe("..");
98
+ });
99
+
100
+ test("plain word with no path shape → null", () => {
101
+ expect(classifyTokenAsPathCandidate("hello")).toBeNull();
102
+ expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
103
+ });
104
+
105
+ test("dot-file (starts with .) → null (strict path gate)", () => {
106
+ // Path candidate does NOT accept dot-files; rule candidate does.
107
+ expect(classifyTokenAsPathCandidate(".env")).toBeNull();
108
+ expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
109
+ });
110
+
111
+ test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
112
+ // Path candidate does NOT accept bare relative paths; rule candidate does.
113
+ expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
114
+ expect(classifyTokenAsPathCandidate("./build")).toBeNull();
115
+ });
116
+ });
117
+ });
118
+
119
+ describe("classifyTokenAsRuleCandidate", () => {
120
+ describe("shared rejection: rejectNonPathToken", () => {
121
+ test("empty string → null", () => {
122
+ expect(classifyTokenAsRuleCandidate("")).toBeNull();
123
+ });
124
+
125
+ test("flag (leading dash) → null", () => {
126
+ expect(classifyTokenAsRuleCandidate("-r")).toBeNull();
127
+ expect(classifyTokenAsRuleCandidate("--recursive")).toBeNull();
128
+ });
129
+
130
+ test("env assignment (= before any /) → null", () => {
131
+ expect(classifyTokenAsRuleCandidate("FOO=/bar")).toBeNull();
132
+ expect(classifyTokenAsRuleCandidate("HOME=/home/user")).toBeNull();
133
+ });
134
+
135
+ test("env-like token where = comes after / is NOT rejected as assignment", () => {
136
+ // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
137
+ // Contains /, so rule candidate accepts it.
138
+ expect(classifyTokenAsRuleCandidate("/foo=bar")).toBe("/foo=bar");
139
+ });
140
+
141
+ test("URL → null", () => {
142
+ expect(classifyTokenAsRuleCandidate("https://example.com")).toBeNull();
143
+ expect(classifyTokenAsRuleCandidate("http://localhost:3000")).toBeNull();
144
+ expect(classifyTokenAsRuleCandidate("file:///tmp/foo")).toBeNull();
145
+ });
146
+
147
+ test("@scope/package → null", () => {
148
+ expect(classifyTokenAsRuleCandidate("@foo/bar")).toBeNull();
149
+ expect(classifyTokenAsRuleCandidate("@scope/pkg")).toBeNull();
150
+ });
151
+
152
+ test("bare-slash token → null", () => {
153
+ expect(classifyTokenAsRuleCandidate("/")).toBeNull();
154
+ expect(classifyTokenAsRuleCandidate("//")).toBeNull();
155
+ });
156
+
157
+ test("regex metacharacters → null", () => {
158
+ expect(classifyTokenAsRuleCandidate("foo.*")).toBeNull();
159
+ expect(classifyTokenAsRuleCandidate("bar.+")).toBeNull();
160
+ expect(classifyTokenAsRuleCandidate("a\\|b")).toBeNull();
161
+ expect(classifyTokenAsRuleCandidate("[abc]")).toBeNull();
162
+ expect(classifyTokenAsRuleCandidate("^/start")).toBeNull();
163
+ });
164
+ });
165
+
166
+ describe("rule-candidate acceptance gate (broader than path)", () => {
167
+ test("absolute path (starts with /) → returned as-is", () => {
168
+ expect(classifyTokenAsRuleCandidate("/etc/hosts")).toBe("/etc/hosts");
169
+ });
170
+
171
+ test("home-relative path (starts with ~/) → returned as-is", () => {
172
+ expect(classifyTokenAsRuleCandidate("~/Documents")).toBe("~/Documents");
173
+ });
174
+
175
+ test("parent-traversal (contains ..) → returned as-is", () => {
176
+ expect(classifyTokenAsRuleCandidate("../foo")).toBe("../foo");
177
+ expect(classifyTokenAsRuleCandidate("..")).toBe("..");
178
+ });
179
+
180
+ test("dot-file (starts with .) → returned as-is", () => {
181
+ // Rule candidate accepts dot-files; path candidate does not.
182
+ expect(classifyTokenAsRuleCandidate(".env")).toBe(".env");
183
+ expect(classifyTokenAsRuleCandidate(".gitignore")).toBe(".gitignore");
184
+ });
185
+
186
+ test("current-dir relative (starts with ./) → returned as-is", () => {
187
+ expect(classifyTokenAsRuleCandidate("./src")).toBe("./src");
188
+ expect(classifyTokenAsRuleCandidate("./build/output.js")).toBe(
189
+ "./build/output.js",
190
+ );
191
+ });
192
+
193
+ test("relative path containing / → returned as-is", () => {
194
+ // Rule candidate accepts any token with / (not already rejected).
195
+ expect(classifyTokenAsRuleCandidate("src/foo.ts")).toBe("src/foo.ts");
196
+ expect(classifyTokenAsRuleCandidate("packages/pi-foo/index.ts")).toBe(
197
+ "packages/pi-foo/index.ts",
198
+ );
199
+ });
200
+
201
+ test("plain word with no path shape → null", () => {
202
+ expect(classifyTokenAsRuleCandidate("hello")).toBeNull();
203
+ expect(classifyTokenAsRuleCandidate("myfile.txt")).toBeNull();
204
+ });
205
+ });
206
+
207
+ describe("rule-vs-path divergence", () => {
208
+ const dotFiles = [".env", ".gitignore", ".eslintrc"];
209
+ const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
210
+
211
+ for (const tok of dotFiles) {
212
+ test(`dot-file "${tok}": rule accepts, path rejects`, () => {
213
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
214
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
215
+ });
216
+ }
217
+
218
+ for (const tok of relPaths) {
219
+ test(`relative path "${tok}": rule accepts, path rejects`, () => {
220
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
221
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
222
+ });
223
+ }
224
+
225
+ const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
226
+ for (const tok of sharedAccepted) {
227
+ test(`"${tok}": both classifiers accept`, () => {
228
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
229
+ expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
230
+ });
231
+ }
232
+
233
+ const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
234
+ for (const tok of sharedRejected) {
235
+ test(`"${tok}": both classifiers reject`, () => {
236
+ expect(classifyTokenAsRuleCandidate(tok)).toBeNull();
237
+ expect(classifyTokenAsPathCandidate(tok)).toBeNull();
238
+ });
239
+ }
240
+ });
241
+ });
@@ -7,8 +7,11 @@ import type { ToolCallContext } from "#src/handlers/gates/types";
7
7
  import type { Rule } from "#src/rule";
8
8
  import type { PermissionCheckResult } from "#src/types";
9
9
 
10
+ import { makeGateCheckResult as makeCheckResult } from "#test/helpers/gate-fixtures";
11
+
10
12
  // ── helpers ────────────────────────────────────────────────────────────────
11
13
 
14
+ // path.test.ts uses read-tool defaults; the shared makeTcc uses bash defaults.
12
15
  function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
13
16
  return {
14
17
  toolName: "read",
@@ -20,18 +23,6 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
20
23
  };
21
24
  }
22
25
 
23
- function makeCheckResult(
24
- overrides: Partial<PermissionCheckResult> = {},
25
- ): PermissionCheckResult {
26
- return {
27
- toolName: "path",
28
- state: "allow",
29
- source: "special",
30
- origin: "global",
31
- ...overrides,
32
- };
33
- }
34
-
35
26
  type CheckPermissionFn = (
36
27
  surface: string,
37
28
  input: unknown,
@@ -2,76 +2,11 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import type { DenialContext } from "#src/denial-messages";
4
4
  import { EXTENSION_TAG } from "#src/denial-messages";
5
- import type {
6
- GateDescriptor,
7
- GateRunnerDeps,
8
- } from "#src/handlers/gates/descriptor";
5
+ import type { GateDescriptor } from "#src/handlers/gates/descriptor";
9
6
  import { runGateCheck } from "#src/handlers/gates/runner";
10
7
  import { SessionApproval } from "#src/session-approval";
11
- import type { PermissionCheckResult } from "#src/types";
12
-
13
- // ── helpers ────────────────────────────────────────────────────────────────
14
-
15
- function makeDescriptor(
16
- overrides: Partial<GateDescriptor> = {},
17
- ): GateDescriptor {
18
- return {
19
- surface: "read",
20
- input: {},
21
- denialContext: {
22
- kind: "tool",
23
- check: makeCheckResult("deny"),
24
- },
25
- promptDetails: {
26
- source: "tool_call",
27
- agentName: null,
28
- message: "Allow tool 'read'?",
29
- toolCallId: "tc-1",
30
- toolName: "read",
31
- },
32
- logContext: {
33
- source: "tool_call",
34
- toolCallId: "tc-1",
35
- toolName: "read",
36
- },
37
- decision: {
38
- surface: "read",
39
- value: "read",
40
- },
41
- ...overrides,
42
- };
43
- }
44
-
45
- function makeCheckResult(
46
- state: "allow" | "deny" | "ask",
47
- overrides: Partial<PermissionCheckResult> = {},
48
- ): PermissionCheckResult {
49
- return {
50
- state,
51
- toolName: "read",
52
- source: "tool",
53
- origin: "builtin",
54
- matchedPattern: "*",
55
- ...overrides,
56
- };
57
- }
58
-
59
- function makeRunnerDeps(
60
- overrides: Partial<GateRunnerDeps> = {},
61
- ): GateRunnerDeps {
62
- return {
63
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
64
- getSessionRuleset: vi.fn().mockReturnValue([]),
65
- recordSessionApproval: vi.fn(),
66
- writeReviewLog: vi.fn(),
67
- emitDecision: vi.fn(),
68
- canConfirm: vi.fn().mockReturnValue(true),
69
- promptPermission: vi
70
- .fn()
71
- .mockResolvedValue({ approved: true, state: "approved" }),
72
- ...overrides,
73
- };
74
- }
8
+ import { makeDescriptor, makeRunnerDeps } from "#test/helpers/gate-fixtures";
9
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
75
10
 
76
11
  // ── tests ──────────────────────────────────────────────────────────────────
77
12
 
@@ -92,7 +27,11 @@ describe("runGateCheck", () => {
92
27
 
93
28
  it("returns block and emits policy_deny when policy is deny", async () => {
94
29
  const deps = makeRunnerDeps({
95
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
30
+ checkPermission: vi
31
+ .fn()
32
+ .mockReturnValue(
33
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
34
+ ),
96
35
  });
97
36
  const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
98
37
  expect(result).toMatchObject({ action: "block" });
@@ -110,12 +49,11 @@ describe("runGateCheck", () => {
110
49
 
111
50
  it("returns allow and emits session_approved on session hit", async () => {
112
51
  const deps = makeRunnerDeps({
113
- checkPermission: vi.fn().mockReturnValue(
114
- makeCheckResult("allow", {
115
- source: "session",
116
- matchedPattern: "git *",
117
- }),
118
- ),
52
+ checkPermission: vi
53
+ .fn()
54
+ .mockReturnValue(
55
+ makeCheckResult({ source: "session", matchedPattern: "git *" }),
56
+ ),
119
57
  });
120
58
  const result = await runGateCheck(
121
59
  makeDescriptor({
@@ -145,7 +83,11 @@ describe("runGateCheck", () => {
145
83
 
146
84
  it("returns allow and emits user_approved when ask + user approves", async () => {
147
85
  const deps = makeRunnerDeps({
148
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
86
+ checkPermission: vi
87
+ .fn()
88
+ .mockReturnValue(
89
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
90
+ ),
149
91
  promptPermission: vi
150
92
  .fn()
151
93
  .mockResolvedValue({ approved: true, state: "approved" }),
@@ -162,7 +104,11 @@ describe("runGateCheck", () => {
162
104
 
163
105
  it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
164
106
  const deps = makeRunnerDeps({
165
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
107
+ checkPermission: vi
108
+ .fn()
109
+ .mockReturnValue(
110
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
111
+ ),
166
112
  promptPermission: vi
167
113
  .fn()
168
114
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
@@ -184,7 +130,11 @@ describe("runGateCheck", () => {
184
130
 
185
131
  it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
186
132
  const deps = makeRunnerDeps({
187
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
133
+ checkPermission: vi
134
+ .fn()
135
+ .mockReturnValue(
136
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
137
+ ),
188
138
  promptPermission: vi
189
139
  .fn()
190
140
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
@@ -202,7 +152,11 @@ describe("runGateCheck", () => {
202
152
 
203
153
  it("returns block and emits user_denied when ask + user denies", async () => {
204
154
  const deps = makeRunnerDeps({
205
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
155
+ checkPermission: vi
156
+ .fn()
157
+ .mockReturnValue(
158
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
159
+ ),
206
160
  promptPermission: vi
207
161
  .fn()
208
162
  .mockResolvedValue({ approved: false, state: "denied" }),
@@ -219,7 +173,11 @@ describe("runGateCheck", () => {
219
173
 
220
174
  it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
221
175
  const deps = makeRunnerDeps({
222
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
176
+ checkPermission: vi
177
+ .fn()
178
+ .mockReturnValue(
179
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
180
+ ),
223
181
  canConfirm: vi.fn().mockReturnValue(false),
224
182
  });
225
183
  const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
@@ -234,7 +192,11 @@ describe("runGateCheck", () => {
234
192
 
235
193
  it("emits auto_approved resolution when decision has autoApproved flag", async () => {
236
194
  const deps = makeRunnerDeps({
237
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
195
+ checkPermission: vi
196
+ .fn()
197
+ .mockReturnValue(
198
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
199
+ ),
238
200
  promptPermission: vi.fn().mockResolvedValue({
239
201
  approved: true,
240
202
  state: "approved",
@@ -304,7 +266,11 @@ describe("runGateCheck", () => {
304
266
 
305
267
  it("passes requestId from toolCallId to promptPermission", async () => {
306
268
  const deps = makeRunnerDeps({
307
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
269
+ checkPermission: vi
270
+ .fn()
271
+ .mockReturnValue(
272
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
273
+ ),
308
274
  });
309
275
  await runGateCheck(makeDescriptor(), null, "tc-42", deps);
310
276
  expect(deps.promptPermission).toHaveBeenCalledWith(
@@ -314,7 +280,11 @@ describe("runGateCheck", () => {
314
280
 
315
281
  it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
316
282
  const deps = makeRunnerDeps({
317
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
283
+ checkPermission: vi
284
+ .fn()
285
+ .mockReturnValue(
286
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
287
+ ),
318
288
  promptPermission: vi
319
289
  .fn()
320
290
  .mockResolvedValue({ approved: true, state: "approved" }),
@@ -326,7 +296,8 @@ describe("runGateCheck", () => {
326
296
  it("uses preCheck result directly instead of calling checkPermission", async () => {
327
297
  const deps = makeRunnerDeps();
328
298
  const descriptor = makeDescriptor({
329
- preCheck: makeCheckResult("deny", {
299
+ preCheck: makeCheckResult({
300
+ state: "deny",
330
301
  origin: "global",
331
302
  matchedPattern: "rm *",
332
303
  }),
@@ -345,7 +316,11 @@ describe("runGateCheck", () => {
345
316
 
346
317
  it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
347
318
  const deps = makeRunnerDeps({
348
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
319
+ checkPermission: vi
320
+ .fn()
321
+ .mockReturnValue(
322
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
323
+ ),
349
324
  promptPermission: vi
350
325
  .fn()
351
326
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
@@ -386,11 +361,15 @@ describe("runGateCheck", () => {
386
361
 
387
362
  it("uses denialContext to format denyReason with extension tag", async () => {
388
363
  const deps = makeRunnerDeps({
389
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
364
+ checkPermission: vi
365
+ .fn()
366
+ .mockReturnValue(
367
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
368
+ ),
390
369
  });
391
370
  const ctx: DenialContext = {
392
371
  kind: "tool",
393
- check: makeCheckResult("deny"),
372
+ check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
394
373
  agentName: "test-agent",
395
374
  };
396
375
  const result = await runGateCheck(
@@ -408,12 +387,16 @@ describe("runGateCheck", () => {
408
387
 
409
388
  it("uses denialContext to format unavailableReason with extension tag", async () => {
410
389
  const deps = makeRunnerDeps({
411
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
390
+ checkPermission: vi
391
+ .fn()
392
+ .mockReturnValue(
393
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
394
+ ),
412
395
  canConfirm: vi.fn().mockReturnValue(false),
413
396
  });
414
397
  const ctx: DenialContext = {
415
398
  kind: "tool",
416
- check: makeCheckResult("ask"),
399
+ check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
417
400
  };
418
401
  const result = await runGateCheck(
419
402
  makeDenialContextDescriptor(ctx),
@@ -430,7 +413,11 @@ describe("runGateCheck", () => {
430
413
 
431
414
  it("uses denialContext to format userDeniedReason with extension tag", async () => {
432
415
  const deps = makeRunnerDeps({
433
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
416
+ checkPermission: vi
417
+ .fn()
418
+ .mockReturnValue(
419
+ makeCheckResult({ state: "ask", matchedPattern: "*" }),
420
+ ),
434
421
  promptPermission: vi.fn().mockResolvedValue({
435
422
  approved: false,
436
423
  state: "denied",
@@ -439,7 +426,7 @@ describe("runGateCheck", () => {
439
426
  });
440
427
  const ctx: DenialContext = {
441
428
  kind: "tool",
442
- check: makeCheckResult("ask"),
429
+ check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
443
430
  };
444
431
  const result = await runGateCheck(
445
432
  makeDenialContextDescriptor(ctx),