@gotgenes/pi-permission-system 16.2.1 → 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 (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  7. package/src/access-intent/bash/program.ts +21 -14
  8. package/src/access-intent/bash/token-classification.ts +1 -1
  9. package/src/canonicalize-path.ts +11 -5
  10. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  11. package/src/forwarding-manager.ts +7 -1
  12. package/src/handlers/before-agent-start.ts +1 -0
  13. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  14. package/src/handlers/gates/bash-path.ts +13 -13
  15. package/src/handlers/gates/external-directory.ts +14 -16
  16. package/src/handlers/gates/path.ts +12 -7
  17. package/src/handlers/gates/skill-read.ts +7 -1
  18. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  19. package/src/handlers/gates/tool.ts +4 -2
  20. package/src/index.ts +12 -1
  21. package/src/input-normalizer.ts +9 -4
  22. package/src/path-normalizer.ts +70 -0
  23. package/src/path-utils.ts +39 -28
  24. package/src/permission-manager.ts +21 -7
  25. package/src/permission-session.ts +35 -2
  26. package/src/prompting-gateway.ts +3 -0
  27. package/src/rule.ts +8 -6
  28. package/src/skill-prompt-sanitizer.ts +15 -4
  29. package/src/subagent-context.ts +17 -9
  30. package/test/access-intent/access-path.test.ts +124 -15
  31. package/test/access-intent/bash/program.test.ts +178 -80
  32. package/test/bash-external-directory.test.ts +15 -1
  33. package/test/canonicalize-path.test.ts +34 -8
  34. package/test/forwarding-manager.test.ts +7 -1
  35. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  36. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  37. package/test/handlers/gates/bash-external-directory.test.ts +5 -2
  38. package/test/handlers/gates/bash-path.test.ts +14 -9
  39. package/test/handlers/gates/external-directory-policy.test.ts +28 -18
  40. package/test/handlers/gates/external-directory.test.ts +9 -1
  41. package/test/handlers/gates/path.test.ts +90 -20
  42. package/test/handlers/gates/skill-read.test.ts +22 -13
  43. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  44. package/test/handlers/gates/tool.test.ts +13 -1
  45. package/test/helpers/gate-fixtures.ts +11 -2
  46. package/test/helpers/session-fixtures.ts +3 -0
  47. package/test/input-normalizer.test.ts +104 -39
  48. package/test/path-normalizer.test.ts +123 -0
  49. package/test/path-utils.test.ts +130 -51
  50. package/test/permission-forwarder.test.ts +1 -0
  51. package/test/permission-manager-unified.test.ts +40 -0
  52. package/test/permission-resolver.test.ts +14 -32
  53. package/test/permission-session.test.ts +41 -0
  54. package/test/pi-infrastructure-read.test.ts +27 -4
  55. package/test/prompting-gateway.test.ts +1 -0
  56. package/test/rule.test.ts +88 -42
  57. package/test/session-rules.test.ts +21 -4
  58. package/test/skill-prompt-sanitizer.test.ts +69 -14
  59. package/test/subagent-context.test.ts +77 -31
  60. package/test/synthesize.test.ts +13 -11
  61. package/src/access-intent/bash/cwd-projection.ts +0 -498
@@ -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
  });
@@ -72,7 +72,13 @@ describe("resolveSkillPromptEntries", () => {
72
72
  test("returns unchanged prompt and empty entries when no skills section present", () => {
73
73
  const input = "You are a helpful assistant.";
74
74
  const manager = makeManager("allow");
75
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
75
+ const result = resolveSkillPromptEntries(
76
+ input,
77
+ manager,
78
+ null,
79
+ CWD,
80
+ "linux",
81
+ );
76
82
  expect(result.prompt).toBe(input);
77
83
  expect(result.entries).toEqual([]);
78
84
  expect(manager.checkPermission).not.toHaveBeenCalled();
@@ -81,7 +87,13 @@ 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(
91
+ input,
92
+ manager,
93
+ null,
94
+ CWD,
95
+ "linux",
96
+ );
85
97
  expect(result.prompt).toContain("librarian");
86
98
  expect(result.prompt).toContain("ask-user");
87
99
  expect(result.entries).toHaveLength(2);
@@ -90,7 +102,13 @@ describe("resolveSkillPromptEntries", () => {
90
102
  test("removes denied skill from section", () => {
91
103
  const input = availableSkillsSection("librarian", "dangerous");
92
104
  const manager = makeManager("allow", { dangerous: "deny" });
93
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
105
+ const result = resolveSkillPromptEntries(
106
+ input,
107
+ manager,
108
+ null,
109
+ CWD,
110
+ "linux",
111
+ );
94
112
  expect(result.prompt).toContain("librarian");
95
113
  expect(result.prompt).not.toContain("dangerous");
96
114
  // denied skill is excluded from returned entries
@@ -100,7 +118,13 @@ describe("resolveSkillPromptEntries", () => {
100
118
  test("removes entire section when all skills are denied", () => {
101
119
  const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
102
120
  const manager = makeManager("deny");
103
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
121
+ const result = resolveSkillPromptEntries(
122
+ input,
123
+ manager,
124
+ null,
125
+ CWD,
126
+ "linux",
127
+ );
104
128
  expect(result.prompt).not.toContain("<available_skills>");
105
129
  expect(result.prompt).toContain("Intro");
106
130
  expect(result.prompt).toContain("Outro");
@@ -110,7 +134,13 @@ describe("resolveSkillPromptEntries", () => {
110
134
  test("keeps ask-state skills in section and entries", () => {
111
135
  const input = availableSkillsSection("librarian");
112
136
  const manager = makeManager("ask");
113
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
137
+ const result = resolveSkillPromptEntries(
138
+ input,
139
+ manager,
140
+ null,
141
+ CWD,
142
+ "linux",
143
+ );
114
144
  expect(result.prompt).toContain("librarian");
115
145
  expect(result.entries).toHaveLength(1);
116
146
  expect(result.entries[0].state).toBe("ask");
@@ -119,7 +149,7 @@ describe("resolveSkillPromptEntries", () => {
119
149
  test("delegates permission check to permissionManager for each skill", () => {
120
150
  const input = availableSkillsSection("alpha", "beta");
121
151
  const manager = makeManager("allow");
122
- resolveSkillPromptEntries(input, manager, null, CWD);
152
+ resolveSkillPromptEntries(input, manager, null, CWD, "linux");
123
153
  expect(manager.checkPermission).toHaveBeenCalledWith(
124
154
  "skill",
125
155
  { name: "alpha" },
@@ -135,7 +165,7 @@ describe("resolveSkillPromptEntries", () => {
135
165
  test("passes agentName to permissionManager", () => {
136
166
  const input = availableSkillsSection("librarian");
137
167
  const manager = makeManager("allow");
138
- resolveSkillPromptEntries(input, manager, "my-agent", CWD);
168
+ resolveSkillPromptEntries(input, manager, "my-agent", CWD, "linux");
139
169
  expect(manager.checkPermission).toHaveBeenCalledWith(
140
170
  "skill",
141
171
  { name: "librarian" },
@@ -150,7 +180,7 @@ describe("resolveSkillPromptEntries", () => {
150
180
  availableSkillsSection("librarian"),
151
181
  ].join("\n");
152
182
  const manager = makeManager("allow");
153
- resolveSkillPromptEntries(input, manager, null, CWD);
183
+ resolveSkillPromptEntries(input, manager, null, CWD, "linux");
154
184
  // Should only be called once despite appearing twice.
155
185
  expect(manager.checkPermission).toHaveBeenCalledTimes(1);
156
186
  });
@@ -159,7 +189,13 @@ describe("resolveSkillPromptEntries", () => {
159
189
  const location = "/skills/librarian/SKILL.md";
160
190
  const input = availableSkillsSection("librarian");
161
191
  const manager = makeManager("allow");
162
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
192
+ const result = resolveSkillPromptEntries(
193
+ input,
194
+ manager,
195
+ null,
196
+ CWD,
197
+ "linux",
198
+ );
163
199
  expect(result.entries[0].normalizedLocation).toBe(location);
164
200
  expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
165
201
  });
@@ -169,7 +205,13 @@ describe("resolveSkillPromptEntries", () => {
169
205
  const section2 = availableSkillsSection("beta");
170
206
  const input = `${section1}\n${section2}`;
171
207
  const manager = makeManager("allow", { beta: "deny" });
172
- const result = resolveSkillPromptEntries(input, manager, null, CWD);
208
+ const result = resolveSkillPromptEntries(
209
+ input,
210
+ manager,
211
+ null,
212
+ CWD,
213
+ "linux",
214
+ );
173
215
  expect(result.entries.map((e) => e.name)).toContain("alpha");
174
216
  expect(result.entries.map((e) => e.name)).not.toContain("beta");
175
217
  });
@@ -198,15 +240,21 @@ describe("findSkillPathMatch", () => {
198
240
  ];
199
241
 
200
242
  test("returns null for empty normalized path", () => {
201
- expect(findSkillPathMatch("", entries)).toBeNull();
243
+ expect(findSkillPathMatch("", entries, "linux")).toBeNull();
202
244
  });
203
245
 
204
246
  test("returns null for empty entries array", () => {
205
- expect(findSkillPathMatch("/skills/librarian/SKILL.md", [])).toBeNull();
247
+ expect(
248
+ findSkillPathMatch("/skills/librarian/SKILL.md", [], "linux"),
249
+ ).toBeNull();
206
250
  });
207
251
 
208
252
  test("matches exact location path", () => {
209
- const match = findSkillPathMatch("/skills/librarian/SKILL.md", entries);
253
+ const match = findSkillPathMatch(
254
+ "/skills/librarian/SKILL.md",
255
+ entries,
256
+ "linux",
257
+ );
210
258
  expect(match?.name).toBe("librarian");
211
259
  });
212
260
 
@@ -214,12 +262,13 @@ describe("findSkillPathMatch", () => {
214
262
  const match = findSkillPathMatch(
215
263
  "/skills/librarian/extra/helper.md",
216
264
  entries,
265
+ "linux",
217
266
  );
218
267
  expect(match?.name).toBe("librarian");
219
268
  });
220
269
 
221
270
  test("returns null for path not within any skill directory", () => {
222
- const match = findSkillPathMatch("/other/path/file.md", entries);
271
+ const match = findSkillPathMatch("/other/path/file.md", entries, "linux");
223
272
  expect(match).toBeNull();
224
273
  });
225
274
 
@@ -228,6 +277,7 @@ describe("findSkillPathMatch", () => {
228
277
  const match = findSkillPathMatch(
229
278
  "/skills/librarian-extra/SKILL.md",
230
279
  entries,
280
+ "linux",
231
281
  );
232
282
  expect(match).toBeNull();
233
283
  });
@@ -254,6 +304,7 @@ describe("findSkillPathMatch", () => {
254
304
  const match = findSkillPathMatch(
255
305
  "/skills/parent/child/helper.md",
256
306
  nestedEntries,
307
+ "linux",
257
308
  );
258
309
  expect(match?.name).toBe("child");
259
310
  });
@@ -330,6 +381,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
330
381
  asChecker(manager),
331
382
  null,
332
383
  "/cwd",
384
+ "linux",
333
385
  );
334
386
 
335
387
  expect(result.prompt).not.toContain("denied-skill");
@@ -377,16 +429,19 @@ test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available
377
429
  asChecker(manager),
378
430
  null,
379
431
  "/cwd",
432
+ "linux",
380
433
  );
381
434
  const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
382
435
  const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
383
436
  const matchedVisibleSkill = findSkillPathMatch(
384
437
  process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
385
438
  result.entries,
439
+ "linux",
386
440
  );
387
441
  const matchedBlockedSkill = findSkillPathMatch(
388
442
  process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
389
443
  result.entries,
444
+ "linux",
390
445
  );
391
446
 
392
447
  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
  });