@gotgenes/pi-permission-system 17.0.0 → 17.1.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 (58) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-path.ts +5 -5
  4. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  5. package/src/access-intent/bash/program.ts +21 -14
  6. package/src/access-intent/bash/token-classification.ts +1 -1
  7. package/src/canonicalize-path.ts +11 -5
  8. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  9. package/src/forwarding-manager.ts +7 -1
  10. package/src/handlers/before-agent-start.ts +1 -0
  11. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  12. package/src/handlers/gates/external-directory.ts +14 -15
  13. package/src/handlers/gates/path.ts +3 -2
  14. package/src/handlers/gates/skill-read.ts +7 -1
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  16. package/src/handlers/gates/tool.ts +4 -2
  17. package/src/index.ts +12 -1
  18. package/src/input-normalizer.ts +9 -4
  19. package/src/path-normalizer.ts +70 -0
  20. package/src/path-utils.ts +39 -28
  21. package/src/permission-manager.ts +21 -7
  22. package/src/permission-session.ts +35 -2
  23. package/src/prompting-gateway.ts +3 -0
  24. package/src/rule.ts +8 -6
  25. package/src/skill-prompt-sanitizer.ts +15 -4
  26. package/src/subagent-context.ts +17 -9
  27. package/test/access-intent/access-path.test.ts +73 -24
  28. package/test/access-intent/bash/program.test.ts +131 -65
  29. package/test/bash-external-directory.test.ts +15 -1
  30. package/test/canonicalize-path.test.ts +34 -8
  31. package/test/forwarding-manager.test.ts +7 -1
  32. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  33. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  34. package/test/handlers/gates/bash-external-directory.test.ts +5 -1
  35. package/test/handlers/gates/bash-path.test.ts +6 -1
  36. package/test/handlers/gates/external-directory-policy.test.ts +26 -8
  37. package/test/handlers/gates/external-directory.test.ts +9 -1
  38. package/test/handlers/gates/path.test.ts +53 -14
  39. package/test/handlers/gates/skill-read.test.ts +22 -13
  40. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  41. package/test/handlers/gates/tool.test.ts +13 -1
  42. package/test/helpers/gate-fixtures.ts +10 -0
  43. package/test/helpers/session-fixtures.ts +3 -0
  44. package/test/input-normalizer.test.ts +104 -39
  45. package/test/path-normalizer.test.ts +123 -0
  46. package/test/path-utils.test.ts +130 -51
  47. package/test/permission-forwarder.test.ts +1 -0
  48. package/test/permission-manager-unified.test.ts +40 -0
  49. package/test/permission-resolver.test.ts +12 -3
  50. package/test/permission-session.test.ts +41 -0
  51. package/test/pi-infrastructure-read.test.ts +27 -4
  52. package/test/prompting-gateway.test.ts +1 -0
  53. package/test/rule.test.ts +88 -42
  54. package/test/session-rules.test.ts +21 -4
  55. package/test/skill-prompt-sanitizer.test.ts +69 -14
  56. package/test/subagent-context.test.ts +77 -31
  57. package/test/synthesize.test.ts +13 -11
  58. package/src/access-intent/bash/cwd-projection.ts +0 -500
@@ -117,6 +117,7 @@ describe("isPiInfrastructureRead", () => {
117
117
  "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
118
118
  INFRA_DIRS,
119
119
  CWD,
120
+ "linux",
120
121
  ),
121
122
  ).toBe(true);
122
123
  });
@@ -128,6 +129,7 @@ describe("isPiInfrastructureRead", () => {
128
129
  "/opt/homebrew/lib/node_modules/pi-ask-user/skills",
129
130
  INFRA_DIRS,
130
131
  CWD,
132
+ "linux",
131
133
  ),
132
134
  ).toBe(true);
133
135
  });
@@ -139,6 +141,7 @@ describe("isPiInfrastructureRead", () => {
139
141
  "/home/user/.pi/agent/git/some-package/README.md",
140
142
  INFRA_DIRS,
141
143
  CWD,
144
+ "linux",
142
145
  ),
143
146
  ).toBe(true);
144
147
  });
@@ -150,6 +153,7 @@ describe("isPiInfrastructureRead", () => {
150
153
  "/opt/homebrew/lib/node_modules/pi-permission-system",
151
154
  INFRA_DIRS,
152
155
  CWD,
156
+ "linux",
153
157
  ),
154
158
  ).toBe(true);
155
159
  });
@@ -163,6 +167,7 @@ describe("isPiInfrastructureRead", () => {
163
167
  "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
164
168
  INFRA_DIRS,
165
169
  CWD,
170
+ "linux",
166
171
  ),
167
172
  ).toBe(false);
168
173
  });
@@ -174,6 +179,7 @@ describe("isPiInfrastructureRead", () => {
174
179
  "/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
175
180
  INFRA_DIRS,
176
181
  CWD,
182
+ "linux",
177
183
  ),
178
184
  ).toBe(false);
179
185
  });
@@ -185,6 +191,7 @@ describe("isPiInfrastructureRead", () => {
185
191
  "/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
186
192
  INFRA_DIRS,
187
193
  CWD,
194
+ "linux",
188
195
  ),
189
196
  ).toBe(false);
190
197
  });
@@ -192,9 +199,9 @@ describe("isPiInfrastructureRead", () => {
192
199
  // ── non-infra paths not allowed ──────────────────────────────────────────
193
200
 
194
201
  test("does not allow 'read' for a path outside all infra dirs", () => {
195
- expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe(
196
- false,
197
- );
202
+ expect(
203
+ isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD, "linux"),
204
+ ).toBe(false);
198
205
  });
199
206
 
200
207
  test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
@@ -205,6 +212,7 @@ describe("isPiInfrastructureRead", () => {
205
212
  "/home/user/.pi/agent-other/config.json",
206
213
  INFRA_DIRS,
207
214
  CWD,
215
+ "linux",
208
216
  ),
209
217
  ).toBe(false);
210
218
  });
@@ -218,6 +226,7 @@ describe("isPiInfrastructureRead", () => {
218
226
  `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
219
227
  INFRA_DIRS,
220
228
  CWD,
229
+ "linux",
221
230
  ),
222
231
  ).toBe(true);
223
232
  });
@@ -229,6 +238,7 @@ describe("isPiInfrastructureRead", () => {
229
238
  `${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
230
239
  INFRA_DIRS,
231
240
  CWD,
241
+ "linux",
232
242
  ),
233
243
  ).toBe(true);
234
244
  });
@@ -240,6 +250,7 @@ describe("isPiInfrastructureRead", () => {
240
250
  `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
241
251
  INFRA_DIRS,
242
252
  CWD,
253
+ "linux",
243
254
  ),
244
255
  ).toBe(false);
245
256
  });
@@ -247,7 +258,9 @@ describe("isPiInfrastructureRead", () => {
247
258
  // ── empty / edge cases ───────────────────────────────────────────────────
248
259
 
249
260
  test("returns false when infrastructureDirs is empty and path is not project-local", () => {
250
- expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false);
261
+ expect(
262
+ isPiInfrastructureRead("read", "/etc/passwd", [], CWD, "linux"),
263
+ ).toBe(false);
251
264
  });
252
265
 
253
266
  test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
@@ -258,6 +271,7 @@ describe("isPiInfrastructureRead", () => {
258
271
  `${CWD}/.pi/npm/node_modules/x/SKILL.md`,
259
272
  [],
260
273
  CWD,
274
+ "linux",
261
275
  ),
262
276
  ).toBe(true);
263
277
  });
@@ -273,6 +287,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
273
287
  "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
274
288
  ["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
275
289
  CWD,
290
+ "linux",
276
291
  ),
277
292
  ).toBe(true);
278
293
  });
@@ -284,6 +299,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
284
299
  "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
285
300
  ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
286
301
  CWD,
302
+ "linux",
287
303
  ),
288
304
  ).toBe(true);
289
305
  });
@@ -295,6 +311,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
295
311
  "/etc/passwd",
296
312
  ["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
297
313
  CWD,
314
+ "linux",
298
315
  ),
299
316
  ).toBe(false);
300
317
  });
@@ -306,6 +323,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
306
323
  "/opt/homebrew/X/file.md",
307
324
  ["/opt/homebrew/?/file.md"],
308
325
  CWD,
326
+ "linux",
309
327
  ),
310
328
  ).toBe(true);
311
329
  });
@@ -317,6 +335,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
317
335
  "/opt/homebrew/abc/file.md",
318
336
  ["/opt/homebrew/?/file.md"],
319
337
  CWD,
338
+ "linux",
320
339
  ),
321
340
  ).toBe(false);
322
341
  });
@@ -332,6 +351,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
332
351
  "/home/user/.pi/agent/config.json",
333
352
  dirs,
334
353
  CWD,
354
+ "linux",
335
355
  ),
336
356
  ).toBe(true);
337
357
  expect(
@@ -340,6 +360,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
340
360
  "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
341
361
  dirs,
342
362
  CWD,
363
+ "linux",
343
364
  ),
344
365
  ).toBe(true);
345
366
  });
@@ -352,6 +373,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
352
373
  `${home}/.pi/agent/config.json`,
353
374
  ["~/.pi/agent"],
354
375
  CWD,
376
+ "linux",
355
377
  ),
356
378
  ).toBe(true);
357
379
  });
@@ -363,6 +385,7 @@ describe("isPiInfrastructureRead with glob patterns", () => {
363
385
  "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
364
386
  ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
365
387
  CWD,
388
+ "linux",
366
389
  ),
367
390
  ).toBe(false);
368
391
  });
@@ -78,6 +78,7 @@ function makeDeps(
78
78
  config: overrides.config ?? makeConfigReader(),
79
79
  subagentSessionsDir:
80
80
  overrides.subagentSessionsDir ?? "/test/agent/subagent-sessions",
81
+ platform: overrides.platform ?? "linux",
81
82
  registry: overrides.registry,
82
83
  prompter: overrides.prompter ?? makePrompterApi(),
83
84
  };
package/test/rule.test.ts CHANGED
@@ -47,50 +47,65 @@ describe("evaluate", () => {
47
47
 
48
48
  test("returns matching rule when a rule matches", () => {
49
49
  const ruleset: Ruleset = [allowBashGit];
50
- const result = evaluate("bash", "git status", ruleset);
50
+ const result = evaluate("bash", "git status", ruleset, "linux");
51
51
  expect(result).toEqual(allowBashGit);
52
52
  });
53
53
 
54
54
  test("returns synthetic rule with 'ask' when no rules match and no defaultAction", () => {
55
- const result = evaluate("bash", "npm install", [allowBashGit]);
55
+ const result = evaluate("bash", "npm install", [allowBashGit], "linux");
56
56
  expect(result.surface).toBe("bash");
57
57
  expect(result.pattern).toBe("npm install");
58
58
  expect(result.action).toBe("ask");
59
59
  });
60
60
 
61
61
  test("returns synthetic rule with custom defaultAction when no rules match", () => {
62
- const result = evaluate("bash", "npm install", [allowBashGit], "deny");
62
+ const result = evaluate(
63
+ "bash",
64
+ "npm install",
65
+ [allowBashGit],
66
+ "linux",
67
+ "deny",
68
+ );
63
69
  expect(result.surface).toBe("bash");
64
70
  expect(result.pattern).toBe("npm install");
65
71
  expect(result.action).toBe("deny");
66
72
  });
67
73
 
68
74
  test("defaultAction does not affect matched rules", () => {
69
- const result = evaluate("bash", "git status", [allowBashGit], "deny");
75
+ const result = evaluate(
76
+ "bash",
77
+ "git status",
78
+ [allowBashGit],
79
+ "linux",
80
+ "deny",
81
+ );
70
82
  expect(result).toEqual(allowBashGit);
71
83
  });
72
84
 
73
85
  test("returns synthetic rule for empty ruleset", () => {
74
- const result = evaluate("mcp", "exa_search", []);
86
+ const result = evaluate("mcp", "exa_search", [], "linux");
75
87
  expect(result.surface).toBe("mcp");
76
88
  expect(result.pattern).toBe("exa_search");
77
89
  expect(result.action).toBe("ask");
78
90
  });
79
91
 
80
92
  test("matches rules for all permission surfaces", () => {
81
- expect(evaluate("read", "src/foo.ts", [allowRead]).action).toBe("allow");
82
- expect(evaluate("mcp", "exa_search", [askMcp]).action).toBe("ask");
83
- expect(evaluate("skill", "librarian", [allowSkillLibrarian]).action).toBe(
93
+ expect(evaluate("read", "src/foo.ts", [allowRead], "linux").action).toBe(
84
94
  "allow",
85
95
  );
96
+ expect(evaluate("mcp", "exa_search", [askMcp], "linux").action).toBe("ask");
86
97
  expect(
87
- evaluate("special", "external_directory", [askSpecialExtDir]).action,
98
+ evaluate("skill", "librarian", [allowSkillLibrarian], "linux").action,
99
+ ).toBe("allow");
100
+ expect(
101
+ evaluate("special", "external_directory", [askSpecialExtDir], "linux")
102
+ .action,
88
103
  ).toBe("ask");
89
104
  });
90
105
 
91
106
  test("last-match-wins: later conflicting rule overrides earlier", () => {
92
107
  const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
93
- const result = evaluate("bash", "git push origin main", ruleset);
108
+ const result = evaluate("bash", "git push origin main", ruleset, "linux");
94
109
  expect(result).toEqual(denyBashGitPush);
95
110
  });
96
111
 
@@ -107,7 +122,12 @@ describe("evaluate", () => {
107
122
  action: "allow",
108
123
  origin: "global",
109
124
  };
110
- const result = evaluate("bash", "git status", [denyAll, allowStatus]);
125
+ const result = evaluate(
126
+ "bash",
127
+ "git status",
128
+ [denyAll, allowStatus],
129
+ "linux",
130
+ );
111
131
  expect(result).toEqual(allowStatus);
112
132
  });
113
133
 
@@ -118,17 +138,21 @@ describe("evaluate", () => {
118
138
  action: "allow",
119
139
  origin: "global",
120
140
  };
121
- expect(evaluate("bash", "anything", [universalAllow]).action).toBe("allow");
122
- expect(evaluate("mcp", "something", [universalAllow]).action).toBe("allow");
123
- expect(evaluate("skill", "librarian", [universalAllow]).action).toBe(
141
+ expect(evaluate("bash", "anything", [universalAllow], "linux").action).toBe(
142
+ "allow",
143
+ );
144
+ expect(evaluate("mcp", "something", [universalAllow], "linux").action).toBe(
124
145
  "allow",
125
146
  );
147
+ expect(
148
+ evaluate("skill", "librarian", [universalAllow], "linux").action,
149
+ ).toBe("allow");
126
150
  });
127
151
 
128
152
  test("specific surface rule does not match a different surface", () => {
129
153
  const ruleset: Ruleset = [allowBashGit];
130
154
  // bash rule should not match mcp surface
131
- const result = evaluate("mcp", "git status", ruleset);
155
+ const result = evaluate("mcp", "git status", ruleset, "linux");
132
156
  expect(result.action).toBe("ask"); // falls back to default
133
157
  });
134
158
 
@@ -140,7 +164,7 @@ describe("evaluate", () => {
140
164
  { surface: "bash", pattern: "git *", action: "allow", origin: "agent" },
141
165
  ];
142
166
  const merged = [...globalRules, ...agentRules];
143
- const result = evaluate("bash", "git status", merged);
167
+ const result = evaluate("bash", "git status", merged, "linux");
144
168
  expect(result.action).toBe("allow"); // agent rule wins
145
169
  });
146
170
 
@@ -153,12 +177,12 @@ describe("evaluate", () => {
153
177
  ];
154
178
  // git status matches global but not agent rule
155
179
  const merged = [...globalRules, ...agentRules];
156
- const result = evaluate("bash", "git status", merged);
180
+ const result = evaluate("bash", "git status", merged, "linux");
157
181
  expect(result.action).toBe("allow"); // global rule is the last match for this pattern
158
182
  });
159
183
 
160
184
  test("empty ruleset returns synthetic default", () => {
161
- const result = evaluate("bash", "git status", []);
185
+ const result = evaluate("bash", "git status", [], "linux");
162
186
  expect(result.surface).toBe("bash");
163
187
  expect(result.pattern).toBe("git status");
164
188
  expect(result.action).toBe("ask");
@@ -186,14 +210,18 @@ describe("evaluate", () => {
186
210
  origin: "builtin",
187
211
  };
188
212
  // Both rules with and without layer field produce the same match.
189
- expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
190
- expect(evaluate("bash", "git status", [withoutLayer]).action).toBe("allow");
213
+ expect(evaluate("bash", "git status", [withLayer], "linux").action).toBe(
214
+ "allow",
215
+ );
216
+ expect(evaluate("bash", "git status", [withoutLayer], "linux").action).toBe(
217
+ "allow",
218
+ );
191
219
  // Layer metadata does not affect last-match-wins ordering.
192
220
  const ruleset: Rule[] = [withDefault, withLayer];
193
- expect(evaluate("bash", "git status", ruleset)).toEqual(withLayer);
221
+ expect(evaluate("bash", "git status", ruleset, "linux")).toEqual(withLayer);
194
222
  // A rule with layer: "default" still wins if it is last in the array.
195
223
  const reversedRuleset: Rule[] = [withLayer, withDefault];
196
- expect(evaluate("bash", "git status", reversedRuleset)).toEqual(
224
+ expect(evaluate("bash", "git status", reversedRuleset, "linux")).toEqual(
197
225
  withDefault,
198
226
  );
199
227
  });
@@ -207,12 +235,12 @@ describe("evaluate", () => {
207
235
  layer: "config",
208
236
  origin,
209
237
  };
210
- const result = evaluate("bash", "git status", [rule]);
238
+ const result = evaluate("bash", "git status", [rule], "linux");
211
239
  expect(result.origin).toBe("project");
212
240
  });
213
241
 
214
242
  test("evaluate() synthetic fallback rule has origin 'builtin'", () => {
215
- const result = evaluate("bash", "npm install", []);
243
+ const result = evaluate("bash", "npm install", [], "linux");
216
244
  expect(result.origin).toBe("builtin");
217
245
  });
218
246
 
@@ -225,7 +253,7 @@ describe("evaluate", () => {
225
253
  layer: "config",
226
254
  origin: "global",
227
255
  };
228
- const result = evaluate("bash", "npm install", [rule]);
256
+ const result = evaluate("bash", "npm install", [rule], "linux");
229
257
  expect(result.action).toBe("deny");
230
258
  expect(result.reason).toBe("Use pnpm instead");
231
259
  });
@@ -246,7 +274,12 @@ describe("evaluate", () => {
246
274
  layer: "config",
247
275
  origin: "global",
248
276
  };
249
- const result = evaluate("bash", "npm install", [allowAll, denyNpm]);
277
+ const result = evaluate(
278
+ "bash",
279
+ "npm install",
280
+ [allowAll, denyNpm],
281
+ "linux",
282
+ );
250
283
  expect(result.action).toBe("deny");
251
284
  expect(result.reason).toBe("Use pnpm");
252
285
  });
@@ -267,13 +300,18 @@ describe("evaluate", () => {
267
300
  layer: "config",
268
301
  origin: "global",
269
302
  };
270
- const result = evaluate("bash", "npm install", [denyNpm, allowInstall]);
303
+ const result = evaluate(
304
+ "bash",
305
+ "npm install",
306
+ [denyNpm, allowInstall],
307
+ "linux",
308
+ );
271
309
  expect(result.action).toBe("allow");
272
310
  expect(result.reason).toBeUndefined();
273
311
  });
274
312
 
275
313
  test("evaluate() synthetic fallback rule has no reason", () => {
276
- const result = evaluate("bash", "npm install", []);
314
+ const result = evaluate("bash", "npm install", [], "linux");
277
315
  expect(result.reason).toBeUndefined();
278
316
  });
279
317
 
@@ -295,7 +333,7 @@ describe("evaluate", () => {
295
333
  layer: "config",
296
334
  origin,
297
335
  };
298
- expect(evaluate("read", "*", [rule]).origin).toBe(origin);
336
+ expect(evaluate("read", "*", [rule], "linux").origin).toBe(origin);
299
337
  }
300
338
  });
301
339
 
@@ -321,7 +359,6 @@ describe("evaluate", () => {
321
359
  "external_directory",
322
360
  "c:\\users\\foo\\pi\\docs\\readme.md",
323
361
  [denyExternalAll, allowExternalPi],
324
- undefined,
325
362
  "win32",
326
363
  );
327
364
  expect(result.action).toBe("allow");
@@ -332,7 +369,6 @@ describe("evaluate", () => {
332
369
  "external_directory",
333
370
  "c:\\users\\foo\\pi\\docs\\readme.md",
334
371
  [denyExternalAll, allowExternalPi],
335
- undefined,
336
372
  "linux",
337
373
  );
338
374
  expect(result.action).toBe("deny");
@@ -350,7 +386,6 @@ describe("evaluate", () => {
350
386
  "external_directory",
351
387
  "c:\\users\\foo\\pi\\docs\\readme.md",
352
388
  [denyExternalAll, allowForwardSlash],
353
- undefined,
354
389
  "win32",
355
390
  );
356
391
  expect(result.action).toBe("allow");
@@ -368,7 +403,6 @@ describe("evaluate", () => {
368
403
  origin: "global",
369
404
  },
370
405
  ],
371
- undefined,
372
406
  "win32",
373
407
  );
374
408
  expect(result.action).toBe("ask");
@@ -400,7 +434,7 @@ describe("evaluateFirst", () => {
400
434
 
401
435
  test("returns the first candidate that matches a non-default rule", () => {
402
436
  const rules: Ruleset = [defaultRule, allowBash];
403
- const result = evaluateFirst("bash", ["git status", "*"], rules);
437
+ const result = evaluateFirst("bash", ["git status", "*"], rules, "linux");
404
438
  expect(result.rule).toEqual(allowBash);
405
439
  expect(result.value).toBe("git status");
406
440
  });
@@ -409,14 +443,14 @@ describe("evaluateFirst", () => {
409
443
  // "npm install" matches only the default; "*" also matches only the
410
444
  // default — falls back to first candidate.
411
445
  const rules: Ruleset = [defaultRule];
412
- const result = evaluateFirst("bash", ["npm install", "*"], rules);
446
+ const result = evaluateFirst("bash", ["npm install", "*"], rules, "linux");
413
447
  expect(result.rule.layer).toBe("default");
414
448
  expect(result.value).toBe("npm install");
415
449
  });
416
450
 
417
451
  test("falls back to first candidate when all candidates match only the default", () => {
418
452
  const rules: Ruleset = [defaultRule];
419
- const result = evaluateFirst("bash", ["a", "b", "c"], rules);
453
+ const result = evaluateFirst("bash", ["a", "b", "c"], rules, "linux");
420
454
  expect(result.value).toBe("a");
421
455
  });
422
456
 
@@ -431,7 +465,7 @@ describe("evaluateFirst", () => {
431
465
  origin: "global",
432
466
  };
433
467
  const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll];
434
- const result = evaluateFirst("mcp", ["exa_search", "mcp"], rules);
468
+ const result = evaluateFirst("mcp", ["exa_search", "mcp"], rules, "linux");
435
469
  expect(result.rule).toEqual(denyMcp);
436
470
  expect(result.value).toBe("exa_search");
437
471
  });
@@ -440,21 +474,26 @@ describe("evaluateFirst", () => {
440
474
  // "unknown_tool" matches only the universal default;
441
475
  // "exa_search" matches denyMcp (non-default) — that is the result.
442
476
  const rules: Ruleset = [defaultRule, denyMcp];
443
- const result = evaluateFirst("mcp", ["unknown_tool", "exa_search"], rules);
477
+ const result = evaluateFirst(
478
+ "mcp",
479
+ ["unknown_tool", "exa_search"],
480
+ rules,
481
+ "linux",
482
+ );
444
483
  expect(result.rule).toEqual(denyMcp);
445
484
  expect(result.value).toBe("exa_search");
446
485
  });
447
486
 
448
487
  test("single-candidate array behaves like evaluate()", () => {
449
488
  const rules: Ruleset = [defaultRule, allowBash];
450
- const result = evaluateFirst("bash", ["git status"], rules);
489
+ const result = evaluateFirst("bash", ["git status"], rules, "linux");
451
490
  expect(result.rule).toEqual(allowBash);
452
491
  expect(result.value).toBe("git status");
453
492
  });
454
493
 
455
494
  test("uses '*' as fallback value when values array is empty", () => {
456
495
  const rules: Ruleset = [defaultRule];
457
- const result = evaluateFirst("bash", [], rules);
496
+ const result = evaluateFirst("bash", [], rules, "linux");
458
497
  expect(result.value).toBe("*");
459
498
  });
460
499
  });
@@ -495,6 +534,7 @@ describe("evaluateAnyValue", () => {
495
534
  "path",
496
535
  ["/proj/src/foo.ts", "src/foo.ts"],
497
536
  rules,
537
+ "linux",
498
538
  );
499
539
  expect(result.rule).toEqual(relativeDeny);
500
540
  expect(result.value).toBe("src/foo.ts");
@@ -506,6 +546,7 @@ describe("evaluateAnyValue", () => {
506
546
  "path",
507
547
  ["/proj/src/foo.ts", "src/foo.ts"],
508
548
  rules,
549
+ "linux",
509
550
  );
510
551
  expect(result.rule).toEqual(absoluteAllow);
511
552
  expect(result.value).toBe("/proj/src/foo.ts");
@@ -516,13 +557,14 @@ describe("evaluateAnyValue", () => {
516
557
  "path",
517
558
  ["/proj/src/foo.ts", "src/foo.ts"],
518
559
  [],
560
+ "linux",
519
561
  );
520
562
  expect(result.rule.action).toBe("ask");
521
563
  expect(result.value).toBe("/proj/src/foo.ts");
522
564
  });
523
565
 
524
566
  test("uses '*' as fallback value when values array is empty", () => {
525
- const result = evaluateAnyValue("path", [], []);
567
+ const result = evaluateAnyValue("path", [], [], "linux");
526
568
  expect(result.value).toBe("*");
527
569
  });
528
570
  });
@@ -556,6 +598,7 @@ describe("evaluateMostRestrictive", () => {
556
598
  "path",
557
599
  [".env", "README.md"],
558
600
  rules,
601
+ "linux",
559
602
  );
560
603
  expect(result).not.toBeNull();
561
604
  expect(result!.rule.action).toBe("deny");
@@ -568,6 +611,7 @@ describe("evaluateMostRestrictive", () => {
568
611
  "path",
569
612
  ["/home/user/.ssh/id_rsa", "README.md"],
570
613
  rules,
614
+ "linux",
571
615
  );
572
616
  expect(result).not.toBeNull();
573
617
  expect(result!.rule.action).toBe("ask");
@@ -580,13 +624,14 @@ describe("evaluateMostRestrictive", () => {
580
624
  "path",
581
625
  ["README.md", "src/index.ts"],
582
626
  rules,
627
+ "linux",
583
628
  );
584
629
  expect(result).toBeNull();
585
630
  });
586
631
 
587
632
  test("empty values: returns null", () => {
588
633
  const rules: Ruleset = [allowAll, denyEnv];
589
- const result = evaluateMostRestrictive("path", [], rules);
634
+ const result = evaluateMostRestrictive("path", [], rules, "linux");
590
635
  expect(result).toBeNull();
591
636
  });
592
637
 
@@ -596,6 +641,7 @@ describe("evaluateMostRestrictive", () => {
596
641
  "path",
597
642
  ["/home/user/.ssh/id_rsa", ".env"],
598
643
  rules,
644
+ "linux",
599
645
  );
600
646
  expect(result).not.toBeNull();
601
647
  expect(result!.rule.action).toBe("deny");