@gotgenes/pi-permission-system 17.0.0 → 17.1.1

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 (59) hide show
  1. package/CHANGELOG.md +14 -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 +535 -0
  5. package/src/access-intent/bash/program.ts +21 -14
  6. package/src/access-intent/bash/token-classification.ts +24 -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 -1
  11. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  12. package/src/handlers/gates/external-directory.ts +7 -18
  13. package/src/handlers/gates/path.ts +3 -2
  14. package/src/handlers/gates/skill-read.ts +4 -2
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +20 -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 +100 -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 +8 -10
  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/access-intent/bash/token-classification.test.ts +75 -0
  30. package/test/bash-external-directory.test.ts +53 -1
  31. package/test/canonicalize-path.test.ts +34 -8
  32. package/test/forwarding-manager.test.ts +7 -1
  33. package/test/handlers/external-directory-symlink-acceptance.test.ts +23 -4
  34. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  35. package/test/handlers/gates/bash-external-directory.test.ts +5 -1
  36. package/test/handlers/gates/bash-path.test.ts +6 -1
  37. package/test/handlers/gates/external-directory-policy.test.ts +26 -8
  38. package/test/handlers/gates/external-directory.test.ts +8 -1
  39. package/test/handlers/gates/path.test.ts +53 -14
  40. package/test/handlers/gates/skill-read.test.ts +26 -13
  41. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  42. package/test/handlers/gates/tool.test.ts +13 -1
  43. package/test/helpers/gate-fixtures.ts +10 -0
  44. package/test/helpers/session-fixtures.ts +3 -0
  45. package/test/input-normalizer.test.ts +104 -39
  46. package/test/path-normalizer.test.ts +166 -0
  47. package/test/path-utils.test.ts +130 -51
  48. package/test/permission-forwarder.test.ts +1 -0
  49. package/test/permission-manager-unified.test.ts +40 -0
  50. package/test/permission-resolver.test.ts +12 -3
  51. package/test/permission-session.test.ts +41 -0
  52. package/test/pi-infrastructure-read.test.ts +27 -4
  53. package/test/prompting-gateway.test.ts +1 -0
  54. package/test/rule.test.ts +88 -42
  55. package/test/session-rules.test.ts +21 -4
  56. package/test/skill-prompt-sanitizer.test.ts +37 -16
  57. package/test/subagent-context.test.ts +77 -31
  58. package/test/synthesize.test.ts +13 -11
  59. package/src/access-intent/bash/cwd-projection.ts +0 -500
@@ -131,6 +131,7 @@ describe("SessionRules", () => {
131
131
  "external_directory",
132
132
  "/other/project/src/foo.ts",
133
133
  session.getRuleset(),
134
+ "linux",
134
135
  );
135
136
  expect(result.action).toBe("allow");
136
137
  });
@@ -142,6 +143,7 @@ describe("SessionRules", () => {
142
143
  "external_directory",
143
144
  "/other/unrelated/file.ts",
144
145
  session.getRuleset(),
146
+ "linux",
145
147
  );
146
148
  // No rule matches — evaluate returns synthetic rule with default action "ask"
147
149
  expect(result.action).toBe("ask");
@@ -154,6 +156,7 @@ describe("SessionRules", () => {
154
156
  "external_directory",
155
157
  "/other/project-b/foo.ts",
156
158
  session.getRuleset(),
159
+ "linux",
157
160
  );
158
161
  expect(result.action).toBe("ask");
159
162
  });
@@ -166,6 +169,7 @@ describe("SessionRules", () => {
166
169
  "external_directory",
167
170
  "/other/project/src/",
168
171
  session.getRuleset(),
172
+ "linux",
169
173
  );
170
174
  expect(result.action).toBe("allow");
171
175
  });
@@ -179,6 +183,7 @@ describe("SessionRules", () => {
179
183
  "external_directory",
180
184
  "/project-a/foo.ts",
181
185
  session.getRuleset(),
186
+ "linux",
182
187
  ).action,
183
188
  ).toBe("allow");
184
189
  expect(
@@ -186,6 +191,7 @@ describe("SessionRules", () => {
186
191
  "external_directory",
187
192
  "/project-b/bar.ts",
188
193
  session.getRuleset(),
194
+ "linux",
189
195
  ).action,
190
196
  ).toBe("allow");
191
197
  expect(
@@ -193,6 +199,7 @@ describe("SessionRules", () => {
193
199
  "external_directory",
194
200
  "/project-c/baz.ts",
195
201
  session.getRuleset(),
202
+ "linux",
196
203
  ).action,
197
204
  ).toBe("ask");
198
205
  });
@@ -204,6 +211,7 @@ describe("SessionRules", () => {
204
211
  "bash",
205
212
  "/other/project/foo.ts",
206
213
  session.getRuleset(),
214
+ "linux",
207
215
  );
208
216
  expect(result.action).toBe("ask");
209
217
  });
@@ -218,6 +226,7 @@ describe("SessionRules", () => {
218
226
  "external_directory",
219
227
  "/old/project/file.ts",
220
228
  session.getRuleset(),
229
+ "linux",
221
230
  ).action,
222
231
  ).toBe("ask");
223
232
  expect(
@@ -225,6 +234,7 @@ describe("SessionRules", () => {
225
234
  "external_directory",
226
235
  "/new/project/file.ts",
227
236
  session.getRuleset(),
237
+ "linux",
228
238
  ).action,
229
239
  ).toBe("allow");
230
240
  });
@@ -270,6 +280,7 @@ describe("deriveApprovalPattern", () => {
270
280
  "external_directory",
271
281
  "/other/project/src/bar.ts",
272
282
  session.getRuleset(),
283
+ "linux",
273
284
  ).action,
274
285
  ).toBe("allow");
275
286
  });
@@ -283,6 +294,7 @@ describe("deriveApprovalPattern", () => {
283
294
  "external_directory",
284
295
  "/other/project/lib/bar.ts",
285
296
  session.getRuleset(),
297
+ "linux",
286
298
  ).action,
287
299
  ).toBe("ask");
288
300
  });
@@ -295,10 +307,15 @@ describe("deriveApprovalPattern", () => {
295
307
  const session = new SessionRules();
296
308
  session.approve("edit", pattern);
297
309
  expect(
298
- evaluate("edit", "/test/project/index.html", session.getRuleset()).action,
310
+ evaluate(
311
+ "edit",
312
+ "/test/project/index.html",
313
+ session.getRuleset(),
314
+ "linux",
315
+ ).action,
299
316
  ).toBe("allow");
300
- expect(evaluate("edit", "/etc/passwd", session.getRuleset()).action).toBe(
301
- "ask",
302
- );
317
+ expect(
318
+ evaluate("edit", "/etc/passwd", session.getRuleset(), "linux").action,
319
+ ).toBe("ask");
303
320
  });
304
321
  });
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionManager } from "#src/permission-manager";
4
5
  import {
5
6
  findSkillPathMatch,
@@ -30,6 +31,11 @@ afterEach(() => {
30
31
 
31
32
  const CWD = "/projects/my-app";
32
33
 
34
+ // `findSkillPathMatch` only uses the normalizer's platform (it compares two
35
+ // already-absolute paths via `isWithinDirectory`), so this CWD-baked instance
36
+ // serves every call regardless of the entries' cwd.
37
+ const normalizer = new PathNormalizer("linux", CWD);
38
+
33
39
  function makeManager(
34
40
  defaultState: "allow" | "deny" | "ask" = "allow",
35
41
  overrides: Record<string, "allow" | "deny" | "ask"> = {},
@@ -72,7 +78,7 @@ describe("resolveSkillPromptEntries", () => {
72
78
  test("returns unchanged prompt and empty entries when no skills section present", () => {
73
79
  const input = "You are a helpful assistant.";
74
80
  const manager = makeManager("allow");
75
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
81
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
76
82
  expect(result.prompt).toBe(input);
77
83
  expect(result.entries).toEqual([]);
78
84
  expect(manager.checkPermission).not.toHaveBeenCalled();
@@ -81,7 +87,7 @@ describe("resolveSkillPromptEntries", () => {
81
87
  test("keeps all skills when all are allowed", () => {
82
88
  const input = availableSkillsSection("librarian", "ask-user");
83
89
  const manager = makeManager("allow");
84
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
90
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
85
91
  expect(result.prompt).toContain("librarian");
86
92
  expect(result.prompt).toContain("ask-user");
87
93
  expect(result.entries).toHaveLength(2);
@@ -90,7 +96,7 @@ describe("resolveSkillPromptEntries", () => {
90
96
  test("removes denied skill from section", () => {
91
97
  const input = availableSkillsSection("librarian", "dangerous");
92
98
  const manager = makeManager("allow", { dangerous: "deny" });
93
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
99
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
94
100
  expect(result.prompt).toContain("librarian");
95
101
  expect(result.prompt).not.toContain("dangerous");
96
102
  // denied skill is excluded from returned entries
@@ -100,7 +106,7 @@ describe("resolveSkillPromptEntries", () => {
100
106
  test("removes entire section when all skills are denied", () => {
101
107
  const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
102
108
  const manager = makeManager("deny");
103
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
109
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
104
110
  expect(result.prompt).not.toContain("<available_skills>");
105
111
  expect(result.prompt).toContain("Intro");
106
112
  expect(result.prompt).toContain("Outro");
@@ -110,7 +116,7 @@ describe("resolveSkillPromptEntries", () => {
110
116
  test("keeps ask-state skills in section and entries", () => {
111
117
  const input = availableSkillsSection("librarian");
112
118
  const manager = makeManager("ask");
113
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
119
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
114
120
  expect(result.prompt).toContain("librarian");
115
121
  expect(result.entries).toHaveLength(1);
116
122
  expect(result.entries[0].state).toBe("ask");
@@ -119,7 +125,7 @@ describe("resolveSkillPromptEntries", () => {
119
125
  test("delegates permission check to permissionManager for each skill", () => {
120
126
  const input = availableSkillsSection("alpha", "beta");
121
127
  const manager = makeManager("allow");
122
- resolveSkillPromptEntries(input, manager, null, CWD);
128
+ resolveSkillPromptEntries(input, manager, null, normalizer);
123
129
  expect(manager.checkPermission).toHaveBeenCalledWith(
124
130
  "skill",
125
131
  { name: "alpha" },
@@ -135,7 +141,7 @@ describe("resolveSkillPromptEntries", () => {
135
141
  test("passes agentName to permissionManager", () => {
136
142
  const input = availableSkillsSection("librarian");
137
143
  const manager = makeManager("allow");
138
- resolveSkillPromptEntries(input, manager, "my-agent", CWD);
144
+ resolveSkillPromptEntries(input, manager, "my-agent", normalizer);
139
145
  expect(manager.checkPermission).toHaveBeenCalledWith(
140
146
  "skill",
141
147
  { name: "librarian" },
@@ -150,7 +156,7 @@ describe("resolveSkillPromptEntries", () => {
150
156
  availableSkillsSection("librarian"),
151
157
  ].join("\n");
152
158
  const manager = makeManager("allow");
153
- resolveSkillPromptEntries(input, manager, null, CWD);
159
+ resolveSkillPromptEntries(input, manager, null, normalizer);
154
160
  // Should only be called once despite appearing twice.
155
161
  expect(manager.checkPermission).toHaveBeenCalledTimes(1);
156
162
  });
@@ -159,7 +165,7 @@ describe("resolveSkillPromptEntries", () => {
159
165
  const location = "/skills/librarian/SKILL.md";
160
166
  const input = availableSkillsSection("librarian");
161
167
  const manager = makeManager("allow");
162
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
168
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
163
169
  expect(result.entries[0].normalizedLocation).toBe(location);
164
170
  expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
165
171
  });
@@ -169,7 +175,7 @@ describe("resolveSkillPromptEntries", () => {
169
175
  const section2 = availableSkillsSection("beta");
170
176
  const input = `${section1}\n${section2}`;
171
177
  const manager = makeManager("allow", { beta: "deny" });
172
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
178
+ const result = resolveSkillPromptEntries(input, manager, null, normalizer);
173
179
  expect(result.entries.map((e) => e.name)).toContain("alpha");
174
180
  expect(result.entries.map((e) => e.name)).not.toContain("beta");
175
181
  });
@@ -198,15 +204,21 @@ describe("findSkillPathMatch", () => {
198
204
  ];
199
205
 
200
206
  test("returns null for empty normalized path", () => {
201
- expect(findSkillPathMatch("", entries)).toBeNull();
207
+ expect(findSkillPathMatch("", entries, normalizer)).toBeNull();
202
208
  });
203
209
 
204
210
  test("returns null for empty entries array", () => {
205
- expect(findSkillPathMatch("/skills/librarian/SKILL.md", [])).toBeNull();
211
+ expect(
212
+ findSkillPathMatch("/skills/librarian/SKILL.md", [], normalizer),
213
+ ).toBeNull();
206
214
  });
207
215
 
208
216
  test("matches exact location path", () => {
209
- const match = findSkillPathMatch("/skills/librarian/SKILL.md", entries);
217
+ const match = findSkillPathMatch(
218
+ "/skills/librarian/SKILL.md",
219
+ entries,
220
+ normalizer,
221
+ );
210
222
  expect(match?.name).toBe("librarian");
211
223
  });
212
224
 
@@ -214,12 +226,17 @@ describe("findSkillPathMatch", () => {
214
226
  const match = findSkillPathMatch(
215
227
  "/skills/librarian/extra/helper.md",
216
228
  entries,
229
+ normalizer,
217
230
  );
218
231
  expect(match?.name).toBe("librarian");
219
232
  });
220
233
 
221
234
  test("returns null for path not within any skill directory", () => {
222
- const match = findSkillPathMatch("/other/path/file.md", entries);
235
+ const match = findSkillPathMatch(
236
+ "/other/path/file.md",
237
+ entries,
238
+ normalizer,
239
+ );
223
240
  expect(match).toBeNull();
224
241
  });
225
242
 
@@ -228,6 +245,7 @@ describe("findSkillPathMatch", () => {
228
245
  const match = findSkillPathMatch(
229
246
  "/skills/librarian-extra/SKILL.md",
230
247
  entries,
248
+ normalizer,
231
249
  );
232
250
  expect(match).toBeNull();
233
251
  });
@@ -254,6 +272,7 @@ describe("findSkillPathMatch", () => {
254
272
  const match = findSkillPathMatch(
255
273
  "/skills/parent/child/helper.md",
256
274
  nestedEntries,
275
+ normalizer,
257
276
  );
258
277
  expect(match?.name).toBe("child");
259
278
  });
@@ -329,7 +348,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
329
348
  prompt,
330
349
  asChecker(manager),
331
350
  null,
332
- "/cwd",
351
+ new PathNormalizer("linux", "/cwd"),
333
352
  );
334
353
 
335
354
  expect(result.prompt).not.toContain("denied-skill");
@@ -376,17 +395,19 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
376
395
  prompt,
377
396
  asChecker(manager),
378
397
  null,
379
- "/cwd",
398
+ new PathNormalizer("linux", "/cwd"),
380
399
  );
381
400
  const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
382
401
  const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
383
402
  const matchedVisibleSkill = findSkillPathMatch(
384
403
  process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
385
404
  result.entries,
405
+ normalizer,
386
406
  );
387
407
  const matchedBlockedSkill = findSkillPathMatch(
388
408
  process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
389
409
  result.entries,
410
+ normalizer,
390
411
  );
391
412
 
392
413
  expect(matchedVisibleSkill?.name).toBe("visible-skill");
@@ -66,43 +66,73 @@ describe("isRegisteredSubagentChild", () => {
66
66
 
67
67
  describe("normalizeFilesystemPath", () => {
68
68
  test("normalizes a simple absolute path", () => {
69
- expect(normalizeFilesystemPath("/projects/my-app")).toBe(
69
+ expect(normalizeFilesystemPath("/projects/my-app", "linux")).toBe(
70
70
  "/projects/my-app",
71
71
  );
72
72
  });
73
73
 
74
74
  test("collapses redundant separators", () => {
75
- expect(normalizeFilesystemPath("/projects//my-app")).toBe(
75
+ expect(normalizeFilesystemPath("/projects//my-app", "linux")).toBe(
76
76
  "/projects/my-app",
77
77
  );
78
78
  });
79
79
 
80
80
  test("resolves . and .. segments", () => {
81
- expect(normalizeFilesystemPath("/projects/my-app/../other")).toBe(
81
+ expect(normalizeFilesystemPath("/projects/my-app/../other", "linux")).toBe(
82
82
  "/projects/other",
83
83
  );
84
84
  });
85
+
86
+ test("win32: lowercases and normalizes with win32 separators", () => {
87
+ expect(normalizeFilesystemPath("C:\\Projects\\My-App", "win32")).toBe(
88
+ "c:\\projects\\my-app",
89
+ );
90
+ });
91
+
92
+ test("posix: leaves case untouched", () => {
93
+ expect(normalizeFilesystemPath("/Projects/My-App", "linux")).toBe(
94
+ "/Projects/My-App",
95
+ );
96
+ });
97
+ });
98
+
99
+ describe("isSubagentExecutionContext — injected platform (#510)", () => {
100
+ test("win32: detects a subagent session dir case-insensitively", () => {
101
+ const subagentRoot = "C:\\Sessions\\Subagents";
102
+ const sessionDir = "c:\\sessions\\subagents\\child";
103
+ expect(
104
+ isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "win32"),
105
+ ).toBe(true);
106
+ });
107
+
108
+ test("posix: the same mixed-case dir is not a subagent context", () => {
109
+ const subagentRoot = "/Sessions/Subagents";
110
+ const sessionDir = "/sessions/subagents/child";
111
+ expect(
112
+ isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
113
+ ).toBe(false);
114
+ });
85
115
  });
86
116
 
87
117
  describe("isSubagentExecutionContext — env hint detection", () => {
88
118
  test("returns true when PI_IS_SUBAGENT is set", () => {
89
119
  vi.stubEnv("PI_IS_SUBAGENT", "true");
90
120
  expect(
91
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
121
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
92
122
  ).toBe(true);
93
123
  });
94
124
 
95
125
  test("returns true when PI_SUBAGENT_SESSION_ID is set", () => {
96
126
  vi.stubEnv("PI_SUBAGENT_SESSION_ID", "abc123");
97
127
  expect(
98
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
128
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
99
129
  ).toBe(true);
100
130
  });
101
131
 
102
132
  test("returns true when PI_AGENT_ROUTER_SUBAGENT is set", () => {
103
133
  vi.stubEnv("PI_AGENT_ROUTER_SUBAGENT", "1");
104
134
  expect(
105
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
135
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
106
136
  ).toBe(true);
107
137
  });
108
138
 
@@ -110,35 +140,35 @@ describe("isSubagentExecutionContext — env hint detection", () => {
110
140
  test("returns true when PI_SUBAGENT_CHILD is set", () => {
111
141
  vi.stubEnv("PI_SUBAGENT_CHILD", "1");
112
142
  expect(
113
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
143
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
114
144
  ).toBe(true);
115
145
  });
116
146
 
117
147
  test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
118
148
  vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
119
149
  expect(
120
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
150
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
121
151
  ).toBe(true);
122
152
  });
123
153
 
124
154
  test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
125
155
  vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
126
156
  expect(
127
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
157
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
128
158
  ).toBe(true);
129
159
  });
130
160
 
131
161
  test("returns true when PI_SUBAGENT_DEPTH is set", () => {
132
162
  vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
133
163
  expect(
134
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
164
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
135
165
  ).toBe(true);
136
166
  });
137
167
 
138
168
  test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
139
169
  vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
140
170
  expect(
141
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
171
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
142
172
  ).toBe(true);
143
173
  });
144
174
 
@@ -146,28 +176,28 @@ describe("isSubagentExecutionContext — env hint detection", () => {
146
176
  test("returns true when PI_SUBAGENT_NAME is set", () => {
147
177
  vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
148
178
  expect(
149
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
179
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
150
180
  ).toBe(true);
151
181
  });
152
182
 
153
183
  test("returns true when PI_SUBAGENT_ID is set", () => {
154
184
  vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
155
185
  expect(
156
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
186
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
157
187
  ).toBe(true);
158
188
  });
159
189
 
160
190
  test("returns true when PI_SUBAGENT_SESSION is set", () => {
161
191
  vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
162
192
  expect(
163
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
193
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
164
194
  ).toBe(true);
165
195
  });
166
196
 
167
197
  test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
168
198
  vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
169
199
  expect(
170
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
200
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
171
201
  ).toBe(true);
172
202
  });
173
203
 
@@ -191,14 +221,14 @@ describe("isSubagentExecutionContext — env hint detection", () => {
191
221
  test("returns false when env hint value is empty string", () => {
192
222
  vi.stubEnv("PI_IS_SUBAGENT", "");
193
223
  expect(
194
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
224
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
195
225
  ).toBe(false);
196
226
  });
197
227
 
198
228
  test("returns false when env hint value is whitespace only", () => {
199
229
  vi.stubEnv("PI_IS_SUBAGENT", " ");
200
230
  expect(
201
- isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
231
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents", "linux"),
202
232
  ).toBe(false);
203
233
  });
204
234
  });
@@ -208,38 +238,42 @@ describe("isSubagentExecutionContext — session dir detection", () => {
208
238
 
209
239
  test("returns true when session dir is within subagent root", () => {
210
240
  const sessionDir = `${subagentRoot}/session-abc`;
211
- expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
212
- true,
213
- );
241
+ expect(
242
+ isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
243
+ ).toBe(true);
214
244
  });
215
245
 
216
246
  test("returns true when session dir equals subagent root", () => {
217
247
  expect(
218
- isSubagentExecutionContext(makeCtx(subagentRoot), subagentRoot),
248
+ isSubagentExecutionContext(makeCtx(subagentRoot), subagentRoot, "linux"),
219
249
  ).toBe(true);
220
250
  });
221
251
 
222
252
  test("returns false when session dir is outside subagent root", () => {
223
253
  const sessionDir = "/home/user/.pi/agent/sessions/main-session";
224
- expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
225
- false,
226
- );
254
+ expect(
255
+ isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
256
+ ).toBe(false);
227
257
  });
228
258
 
229
259
  test("returns false when session dir is a sibling with shared prefix", () => {
230
260
  // "/sessions/subagents-extra" should not match root "/sessions/subagents"
231
261
  const sessionDir = `${subagentRoot}-extra/session-abc`;
232
- expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
233
- false,
234
- );
262
+ expect(
263
+ isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot, "linux"),
264
+ ).toBe(false);
235
265
  });
236
266
 
237
267
  test("returns false when getSessionDir returns null", () => {
238
- expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
268
+ expect(
269
+ isSubagentExecutionContext(makeCtx(null), subagentRoot, "linux"),
270
+ ).toBe(false);
239
271
  });
240
272
 
241
273
  test("returns false when getSessionDir returns empty string", () => {
242
- expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
274
+ expect(isSubagentExecutionContext(makeCtx(""), subagentRoot, "linux")).toBe(
275
+ false,
276
+ );
243
277
  });
244
278
  });
245
279
 
@@ -256,6 +290,7 @@ describe("isSubagentExecutionContext — registry detection", () => {
256
290
  isSubagentExecutionContext(
257
291
  makeCtx(outsideDir, childSessionId),
258
292
  subagentRoot,
293
+ "linux",
259
294
  registry,
260
295
  ),
261
296
  ).toBe(true);
@@ -268,6 +303,7 @@ describe("isSubagentExecutionContext — registry detection", () => {
268
303
  isSubagentExecutionContext(
269
304
  makeCtx(outsideDir, childSessionId),
270
305
  subagentRoot,
306
+ "linux",
271
307
  registry,
272
308
  ),
273
309
  ).toBe(true);
@@ -279,6 +315,7 @@ describe("isSubagentExecutionContext — registry detection", () => {
279
315
  isSubagentExecutionContext(
280
316
  makeCtx(outsideDir, childSessionId),
281
317
  subagentRoot,
318
+ "linux",
282
319
  registry,
283
320
  ),
284
321
  ).toBe(false);
@@ -287,7 +324,12 @@ describe("isSubagentExecutionContext — registry detection", () => {
287
324
  test("returns false when session id is empty and registry has no matching entry", () => {
288
325
  const registry = new SubagentSessionRegistry();
289
326
  expect(
290
- isSubagentExecutionContext(makeCtx(null, ""), subagentRoot, registry),
327
+ isSubagentExecutionContext(
328
+ makeCtx(null, ""),
329
+ subagentRoot,
330
+ "linux",
331
+ registry,
332
+ ),
291
333
  ).toBe(false);
292
334
  });
293
335
 
@@ -301,6 +343,7 @@ describe("isSubagentExecutionContext — registry detection", () => {
301
343
  isSubagentExecutionContext(
302
344
  makeCtx(outsideDir, childSessionId),
303
345
  subagentRoot,
346
+ "linux",
304
347
  registry,
305
348
  ),
306
349
  ).toBe(true);
@@ -314,6 +357,7 @@ describe("isSubagentExecutionContext — registry detection", () => {
314
357
  isSubagentExecutionContext(
315
358
  makeCtx(outsideDir, childSessionId),
316
359
  subagentRoot,
360
+ "linux",
317
361
  registry,
318
362
  ),
319
363
  ).toBe(true);
@@ -321,6 +365,8 @@ describe("isSubagentExecutionContext — registry detection", () => {
321
365
 
322
366
  test("no registry passed — existing behaviour unchanged", () => {
323
367
  // Ensure the parameter is truly optional (no registry arg)
324
- expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
368
+ expect(
369
+ isSubagentExecutionContext(makeCtx(null), subagentRoot, "linux"),
370
+ ).toBe(false);
325
371
  });
326
372
  });
@@ -30,15 +30,17 @@ describe("synthesizeDefaults", () => {
30
30
 
31
31
  test("universal rule catches any surface via wildcardMatch", () => {
32
32
  const rules = synthesizeDefaults("ask");
33
- expect(evaluate("read", "*", rules).action).toBe("ask");
34
- expect(evaluate("bash", "git status", rules).action).toBe("ask");
35
- expect(evaluate("external_directory", "*", rules).action).toBe("ask");
36
- expect(evaluate("future_surface", "*", rules).action).toBe("ask");
33
+ expect(evaluate("read", "*", rules, "linux").action).toBe("ask");
34
+ expect(evaluate("bash", "git status", rules, "linux").action).toBe("ask");
35
+ expect(evaluate("external_directory", "*", rules, "linux").action).toBe(
36
+ "ask",
37
+ );
38
+ expect(evaluate("future_surface", "*", rules, "linux").action).toBe("ask");
37
39
  });
38
40
 
39
41
  test("universal rule has layer 'default'", () => {
40
42
  const rules = synthesizeDefaults("allow");
41
- expect(evaluate("read", "*", rules).layer).toBe("default");
43
+ expect(evaluate("read", "*", rules, "linux").layer).toBe("default");
42
44
  });
43
45
 
44
46
  test("defaults to origin 'builtin' when no origin supplied", () => {
@@ -54,7 +56,7 @@ describe("synthesizeDefaults", () => {
54
56
 
55
57
  test("origin is preserved through evaluate()", () => {
56
58
  const rules = synthesizeDefaults("allow", "project");
57
- const result = evaluate("read", "*", rules);
59
+ const result = evaluate("read", "*", rules, "linux");
58
60
  expect(result.origin).toBe("project");
59
61
  });
60
62
 
@@ -171,7 +173,7 @@ describe("synthesizeBaseline", () => {
171
173
  },
172
174
  ];
173
175
  const rules = synthesizeBaseline(configRules);
174
- const result = evaluate("mcp", "mcp_status", rules);
176
+ const result = evaluate("mcp", "mcp_status", rules, "linux");
175
177
  expect(result.action).toBe("allow");
176
178
  expect(result.layer).toBe("baseline");
177
179
  expect(result.origin).toBe("baseline");
@@ -218,7 +220,7 @@ describe("composeRuleset", () => {
218
220
  },
219
221
  ];
220
222
  const composed = composeRuleset(defaults, [], config);
221
- const result = evaluate("bash", "echo hello", composed);
223
+ const result = evaluate("bash", "echo hello", composed, "linux");
222
224
  expect(result.action).toBe("deny");
223
225
  expect(result.layer).toBe("config");
224
226
  });
@@ -235,7 +237,7 @@ describe("composeRuleset", () => {
235
237
  },
236
238
  ];
237
239
  const composed = composeRuleset(defaults, [], config);
238
- const result = evaluate("read", "*", composed);
240
+ const result = evaluate("read", "*", composed, "linux");
239
241
  expect(result.action).toBe("allow");
240
242
  expect(result.layer).toBe("config");
241
243
  });
@@ -261,7 +263,7 @@ describe("composeRuleset", () => {
261
263
  },
262
264
  ];
263
265
  const composed = composeRuleset(defaults, baseline, config);
264
- const result = evaluate("mcp", "mcp_status", composed);
266
+ const result = evaluate("mcp", "mcp_status", composed, "linux");
265
267
  expect(result.action).toBe("deny");
266
268
  expect(result.layer).toBe("config");
267
269
  });
@@ -287,7 +289,7 @@ describe("composeRuleset", () => {
287
289
  },
288
290
  ];
289
291
  const composed = composeRuleset(defaults, baseline, config);
290
- const result = evaluate("mcp", "exa_web_search", composed);
292
+ const result = evaluate("mcp", "exa_web_search", composed, "linux");
291
293
  expect(result.action).toBe("allow");
292
294
  expect(result.layer).toBe("config");
293
295
  });