@gotgenes/pi-permission-system 16.0.2 → 16.2.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-intent.ts +57 -0
  4. package/src/access-intent/access-path.ts +88 -0
  5. package/src/access-intent/bash/cwd-projection.ts +9 -4
  6. package/src/access-intent/bash/program.ts +8 -8
  7. package/src/handlers/gates/bash-command.ts +18 -3
  8. package/src/handlers/gates/bash-external-directory.ts +16 -30
  9. package/src/handlers/gates/bash-path-extractor.ts +9 -3
  10. package/src/handlers/gates/bash-path.ts +6 -4
  11. package/src/handlers/gates/external-directory-policy.ts +66 -0
  12. package/src/handlers/gates/external-directory.ts +8 -9
  13. package/src/handlers/gates/path.ts +6 -5
  14. package/src/handlers/gates/runner.ts +6 -5
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +6 -5
  16. package/src/path-utils.ts +0 -20
  17. package/src/permission-event-rpc.ts +4 -6
  18. package/src/permission-manager.ts +39 -55
  19. package/src/permission-resolver.ts +45 -57
  20. package/src/permissions-service.ts +2 -4
  21. package/src/skill-prompt-sanitizer.ts +2 -2
  22. package/test/access-intent/access-path.test.ts +107 -0
  23. package/test/access-intent/bash/program.test.ts +30 -12
  24. package/test/handlers/before-agent-start.test.ts +6 -9
  25. package/test/handlers/external-directory-session-dedup.test.ts +16 -40
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
  27. package/test/handlers/gates/bash-command.test.ts +26 -23
  28. package/test/handlers/gates/bash-external-directory.test.ts +36 -32
  29. package/test/handlers/gates/bash-path.test.ts +12 -8
  30. package/test/handlers/gates/external-directory-policy.test.ts +124 -0
  31. package/test/handlers/gates/external-directory.test.ts +11 -5
  32. package/test/handlers/gates/path.test.ts +30 -25
  33. package/test/handlers/gates/runner.test.ts +6 -1
  34. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +4 -3
  35. package/test/handlers/input.test.ts +1 -1
  36. package/test/helpers/gate-fixtures.ts +12 -24
  37. package/test/helpers/handler-fixtures.ts +21 -15
  38. package/test/helpers/session-fixtures.ts +3 -18
  39. package/test/path-utils.test.ts +0 -34
  40. package/test/permission-event-rpc.test.ts +24 -20
  41. package/test/permission-manager-unified.test.ts +445 -209
  42. package/test/permission-resolver.test.ts +112 -89
  43. package/test/permissions-service.test.ts +10 -7
  44. package/test/skill-prompt-sanitizer.test.ts +30 -7
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import type { ResolvedAccessIntent } from "./access-intent/access-intent";
2
3
  import { isPermissionState } from "./common";
3
4
  import {
4
5
  getGlobalConfigPath,
@@ -50,7 +51,7 @@ type FileCacheEntry<TValue> = {
50
51
  type ResolvedPermissions = {
51
52
  /**
52
53
  * Fully composed ruleset: synthesized defaults → baseline → config.
53
- * Session rules are appended at call-time inside checkPermission().
54
+ * Session rules are appended at call-time inside check().
54
55
  */
55
56
  composedRules: Ruleset;
56
57
  };
@@ -62,25 +63,16 @@ type ResolvedPermissions = {
62
63
  */
63
64
  export interface ScopedPermissionManager {
64
65
  configureForCwd(cwd: string | undefined | null): void;
65
- checkPermission(
66
- toolName: string,
67
- input: unknown,
68
- agentName?: string,
69
- sessionRules?: Ruleset,
70
- ): PermissionCheckResult;
71
66
  /**
72
- * Evaluate a path-shaped surface (`path` or `external_directory`) against a
73
- * caller-supplied set of equivalent policy values (e.g. bash tokens already
74
- * resolved against a preceding literal `cd`, or a path's typed and
75
- * symlink-resolved aliases). The values are trusted because they are computed
76
- * internally, never read from a field on raw tool input. `surface` defaults
77
- * to `path`.
67
+ * Unified resolution entry point (Phase 6 Step 6, #478).
68
+ *
69
+ * Replaces the former `checkPermission` + `checkPathPolicy` method pair with
70
+ * a single dispatched call, making it structurally impossible to stub one
71
+ * method and forget the other (the #393 false-green class).
78
72
  */
79
- checkPathPolicy(
80
- values: readonly string[],
81
- agentName?: string,
73
+ check(
74
+ intent: ResolvedAccessIntent,
82
75
  sessionRules?: Ruleset,
83
- surface?: string,
84
76
  ): PermissionCheckResult;
85
77
  getToolPermission(toolName: string, agentName?: string): PermissionState;
86
78
  getConfigIssues(agentName?: string): string[];
@@ -243,56 +235,48 @@ export class PermissionManager implements ScopedPermissionManager {
243
235
  return evaluate(normalizedToolName, "*", composedRules).action;
244
236
  }
245
237
 
246
- checkPermission(
247
- toolName: string,
248
- input: unknown,
249
- agentName?: string,
238
+ /**
239
+ * Unified resolution entry point — dispatches on intent kind.
240
+ *
241
+ * `"tool"` → normalizes raw input through `normalizeInput`.
242
+ * `"path-values"` → evaluates the precomputed values directly.
243
+ */
244
+ check(
245
+ intent: ResolvedAccessIntent,
250
246
  sessionRules?: Ruleset,
251
247
  ): PermissionCheckResult {
252
- const { composedRules } = this.resolvePermissions(agentName);
253
- const normalizedToolName = toolName.trim();
254
-
255
- // Append session rules at the end (highest priority) so evaluate() handles
256
- // them via last-match-wins — no separate per-branch pre-check needed.
248
+ const { composedRules } = this.resolvePermissions(intent.agentName);
257
249
  const fullRules: Ruleset = sessionRules?.length
258
250
  ? [...composedRules, ...sessionRules]
259
251
  : composedRules;
260
252
 
253
+ if (intent.kind === "path-values") {
254
+ const lookupValues =
255
+ intent.values.length > 0 ? [...intent.values] : ["*"];
256
+ return buildCheckResult(
257
+ intent.surface,
258
+ lookupValues,
259
+ {},
260
+ intent.surface,
261
+ intent.surface,
262
+ fullRules,
263
+ );
264
+ }
265
+
266
+ // kind === "tool"
267
+ const toolName = intent.surface.trim();
261
268
  const { surface, values, resultExtras } = normalizeInput(
262
- normalizedToolName,
263
- input,
269
+ toolName,
270
+ intent.input,
264
271
  this.loader.getConfiguredMcpServerNames(),
265
272
  this.currentCwd,
266
273
  );
267
-
268
274
  return buildCheckResult(
269
275
  surface,
270
276
  values,
271
277
  resultExtras,
272
- normalizedToolName,
273
278
  toolName,
274
- fullRules,
275
- );
276
- }
277
-
278
- checkPathPolicy(
279
- values: readonly string[],
280
- agentName?: string,
281
- sessionRules?: Ruleset,
282
- surface = "path",
283
- ): PermissionCheckResult {
284
- const { composedRules } = this.resolvePermissions(agentName);
285
- const fullRules: Ruleset = sessionRules?.length
286
- ? [...composedRules, ...sessionRules]
287
- : composedRules;
288
-
289
- const lookupValues = values.length > 0 ? [...values] : ["*"];
290
- return buildCheckResult(
291
- surface,
292
- lookupValues,
293
- {},
294
- surface,
295
- surface,
279
+ intent.surface,
296
280
  fullRules,
297
281
  );
298
282
  }
@@ -302,8 +286,8 @@ export class PermissionManager implements ScopedPermissionManager {
302
286
  * Evaluate a normalized surface/values triple and shape the result.
303
287
  *
304
288
  * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
305
- * aliases); every other surface keeps {@link evaluateFirst}. Shared by
306
- * `checkPermission` and `checkPathPolicy`.
289
+ * aliases); every other surface keeps {@link evaluateFirst}. Shared by the
290
+ * `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
307
291
  */
308
292
  function buildCheckResult(
309
293
  surface: string,
@@ -357,7 +341,7 @@ function derivePolicyLoaderOptions(
357
341
  * Map a matched rule + tool name to the correct PermissionCheckResult.source.
358
342
  *
359
343
  * Mirrors the source-derivation logic from the former per-branch
360
- * checkPermission() implementation:
344
+ * permission-check implementation:
361
345
  *
362
346
  * - session → "session" (always, all surfaces)
363
347
  * - mcp + default → "default"
@@ -1,34 +1,42 @@
1
+ import type {
2
+ AccessIntent,
3
+ ResolvedAccessIntent,
4
+ } from "./access-intent/access-intent";
1
5
  import type { ScopedPermissionManager } from "./permission-manager";
2
6
  import type { Rule } from "./rule";
3
7
  import type { SessionRules } from "./session-rules";
8
+ import type { SkillPermissionChecker } from "./skill-prompt-sanitizer";
4
9
  import type { PermissionCheckResult, PermissionState } from "./types";
5
10
 
6
11
  /**
7
- * Resolves the effective permission for a surface/input, applying the current
8
- * session rules internally.
12
+ * Answers an {@link AccessIntent} a gate emits, applying the current session
13
+ * rules internally.
9
14
  *
10
- * Collapses the `checkPermission` + `getSessionRuleset` relay that every gate
11
- * previously threaded by hand: the ruleset was only ever fetched to be passed
12
- * straight back into `checkPermission`, so the two are one operation.
15
+ * A single `resolve(intent)` entry point means adding a gate cannot widen the
16
+ * resolver surface, and a test fixture cannot stub one resolution method and
17
+ * forget another (the #393 false-green class) #478.
13
18
  */
14
19
  export interface ScopedPermissionResolver {
15
- resolve(
16
- surface: string,
17
- input: unknown,
18
- agentName?: string,
19
- ): PermissionCheckResult;
20
- /**
21
- * Resolve a path-shaped surface against a caller-supplied set of equivalent
22
- * policy values, applying the current session rules. Used by the bash path
23
- * gate (`path`) and the external-directory gates (`external_directory`),
24
- * which compute equivalent path aliases per token. `surface` defaults to
25
- * `path`.
26
- */
27
- resolvePathPolicy(
28
- values: readonly string[],
29
- agentName?: string,
30
- surface?: string,
31
- ): PermissionCheckResult;
20
+ resolve(intent: AccessIntent): PermissionCheckResult;
21
+ }
22
+
23
+ /**
24
+ * Reduce a gate-emitted {@link AccessIntent} to the string-based
25
+ * {@link ResolvedAccessIntent} the manager consumes.
26
+ *
27
+ * Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`,
28
+ * so the low-level manager never imports the value object.
29
+ */
30
+ function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
31
+ if (intent.kind === "access-path") {
32
+ return {
33
+ kind: "path-values",
34
+ surface: intent.surface,
35
+ values: intent.path.matchValues(),
36
+ agentName: intent.agentName,
37
+ };
38
+ }
39
+ return intent;
32
40
  }
33
41
 
34
42
  /**
@@ -41,60 +49,40 @@ export interface ScopedPermissionResolver {
41
49
  * - `permissionManager` — the narrow session-scoped permission-checking interface
42
50
  * - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
43
51
  */
44
- export class PermissionResolver implements ScopedPermissionResolver {
52
+ export class PermissionResolver
53
+ implements ScopedPermissionResolver, SkillPermissionChecker
54
+ {
45
55
  constructor(
46
56
  private readonly permissionManager: ScopedPermissionManager,
47
57
  private readonly sessionRules: Pick<SessionRules, "getRuleset">,
48
58
  ) {}
49
59
 
50
60
  /**
51
- * Resolve the effective permission for a surface/input, applying the current
52
- * session rules. Composes `checkPermission` with `getRuleset()` so callers
53
- * never thread the ruleset by hand.
61
+ * Answer a gate-emitted access intent, composing the current session ruleset
62
+ * so callers never thread it by hand. Unwraps the `access-path` variant via
63
+ * `matchValues()` before handing a string-based intent to the manager.
54
64
  */
55
- resolve(
56
- surface: string,
57
- input: unknown,
58
- agentName?: string,
59
- ): PermissionCheckResult {
60
- return this.checkPermission(
61
- surface,
62
- input,
63
- agentName,
65
+ resolve(intent: AccessIntent): PermissionCheckResult {
66
+ return this.permissionManager.check(
67
+ toResolvedIntent(intent),
64
68
  this.sessionRules.getRuleset(),
65
69
  );
66
70
  }
67
71
 
68
72
  /**
69
- * Resolve a path-shaped surface (`path` or `external_directory`) for
70
- * precomputed policy values, composing the current session ruleset so callers
71
- * never thread it by hand. `surface` defaults to `path`; the external-directory
72
- * gates pass `external_directory` so a path's typed and symlink-resolved
73
- * aliases match against the `external_directory` rules.
73
+ * Raw permission check without session rules the no-session-rules path
74
+ * consumed by `SkillInputGateInputs` / `SkillPermissionChecker`.
75
+ *
76
+ * Not on `ScopedPermissionResolver` (ISP: gates do not use this).
74
77
  */
75
- resolvePathPolicy(
76
- values: readonly string[],
77
- agentName?: string,
78
- surface = "path",
79
- ): PermissionCheckResult {
80
- return this.permissionManager.checkPathPolicy(
81
- values,
82
- agentName,
83
- this.sessionRules.getRuleset(),
84
- surface,
85
- );
86
- }
87
-
88
78
  checkPermission(
89
79
  surface: string,
90
80
  input: unknown,
91
81
  agentName?: string,
92
82
  sessionRules?: Rule[],
93
83
  ): PermissionCheckResult {
94
- return this.permissionManager.checkPermission(
95
- surface,
96
- input,
97
- agentName,
84
+ return this.permissionManager.check(
85
+ { kind: "tool", surface, input, agentName },
98
86
  sessionRules,
99
87
  );
100
88
  }
@@ -32,10 +32,8 @@ export class LocalPermissionsService implements PermissionsService {
32
32
  agentName?: string,
33
33
  ): ReturnType<PermissionsService["checkPermission"]> {
34
34
  const input = buildInputForSurface(surface, value);
35
- return this.permissionManager.checkPermission(
36
- surface,
37
- input,
38
- agentName,
35
+ return this.permissionManager.check(
36
+ { kind: "tool", surface, input, agentName },
39
37
  this.sessionRules.getRuleset(),
40
38
  );
41
39
  }
@@ -7,8 +7,8 @@ import {
7
7
  import type { PermissionCheckResult, PermissionState } from "./types";
8
8
 
9
9
  /**
10
- * Narrow interface for the permission checker used by skill prompt resolution.
11
- * Both `PermissionManager` and `PermissionResolver` satisfy this structurally.
10
+ * Narrow interface for the raw (no-session-rules) permission checker used by
11
+ * skill prompt resolution. `PermissionResolver` implements it (#478).
12
12
  */
13
13
  export interface SkillPermissionChecker {
14
14
  checkPermission(
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
13
+ // Default implementation is identity — lexical tests are unaffected.
14
+ const realpathSync = vi.hoisted(() =>
15
+ vi.fn<(path: string) => string>((p) => p),
16
+ );
17
+ vi.mock("node:fs", () => ({
18
+ realpathSync,
19
+ default: { realpathSync },
20
+ }));
21
+
22
+ import { AccessPath } from "#src/access-intent/access-path";
23
+
24
+ describe("AccessPath.forExternalDirectory", () => {
25
+ const cwd = "/projects/my-app";
26
+
27
+ beforeEach(() => {
28
+ realpathSync.mockReset();
29
+ realpathSync.mockImplementation((p: string) => p);
30
+ });
31
+
32
+ describe("matchValues()", () => {
33
+ test("adds the symlink-resolved alias alongside the typed path", () => {
34
+ // /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
35
+ realpathSync.mockImplementation((p: string) =>
36
+ p.startsWith("/tmp") ? `/private${p}` : p,
37
+ );
38
+ expect(
39
+ AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
40
+ ).toEqual(["/tmp/x", "/private/tmp/x"]);
41
+ });
42
+
43
+ test("deduplicates when the canonical form equals the lexical form", () => {
44
+ expect(
45
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
46
+ ).toEqual(["/etc/hosts"]);
47
+ });
48
+
49
+ test("keeps the relative aliases for an in-cwd token without duplicating", () => {
50
+ expect(
51
+ AccessPath.forExternalDirectory("src/foo.ts", cwd).matchValues(),
52
+ ).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
53
+ });
54
+
55
+ test("includes only the lexical aliases when canonical is empty", () => {
56
+ // Force canonicalizePath to return the original (no-op symlink resolution
57
+ // effectively means canonical === lexical, handled by dedup).
58
+ expect(
59
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
60
+ ).not.toHaveLength(0);
61
+ });
62
+ });
63
+
64
+ describe("boundaryValue()", () => {
65
+ test("returns the canonical (symlink-resolved) form", () => {
66
+ realpathSync.mockImplementation((p: string) =>
67
+ p.startsWith("/tmp") ? `/private${p}` : p,
68
+ );
69
+ expect(
70
+ AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
71
+ ).toBe("/private/tmp/x");
72
+ });
73
+
74
+ test("returns the lexical form when path has no symlinks", () => {
75
+ expect(
76
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
77
+ ).toBe("/etc/hosts");
78
+ });
79
+
80
+ test("returns empty string for empty input", () => {
81
+ expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
82
+ });
83
+ });
84
+
85
+ describe("value()", () => {
86
+ test("returns the lexical (as-typed, normalized) form", () => {
87
+ realpathSync.mockImplementation((p: string) =>
88
+ p.startsWith("/tmp") ? `/private${p}` : p,
89
+ );
90
+ // Even when the path resolves to a different canonical, value() stays lexical.
91
+ expect(AccessPath.forExternalDirectory("/tmp/x", cwd).value()).toBe(
92
+ "/tmp/x",
93
+ );
94
+ });
95
+
96
+ test("normalizes the path against cwd", () => {
97
+ // A relative path becomes an absolute lexical value.
98
+ expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
99
+ "/projects/my-app/src/foo.ts",
100
+ );
101
+ });
102
+
103
+ test("returns empty string for empty input", () => {
104
+ expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
105
+ });
106
+ });
107
+ });
@@ -70,7 +70,9 @@ describe("BashProgram", () => {
70
70
  it("returns absolute paths resolving outside cwd", async () => {
71
71
  const program = await BashProgram.parse("cat /etc/hosts", cwd);
72
72
  // Subset matcher: the path is normalized before comparison.
73
- expect(program.externalPaths()).toContain("/etc/hosts");
73
+ expect(program.externalPaths().map((p) => p.value())).toContain(
74
+ "/etc/hosts",
75
+ );
74
76
  });
75
77
 
76
78
  it("excludes paths within cwd", async () => {
@@ -95,7 +97,9 @@ describe("BashProgram", () => {
95
97
  "cd nested/deep && cd .. && cat ../../etc/passwd",
96
98
  cwd,
97
99
  );
98
- expect(program.externalPaths()).toContain("/projects/etc/passwd");
100
+ expect(program.externalPaths().map((p) => p.value())).toContain(
101
+ "/projects/etc/passwd",
102
+ );
99
103
  });
100
104
 
101
105
  it("folds a cd that is not the first command", async () => {
@@ -112,13 +116,17 @@ describe("BashProgram", () => {
112
116
  // `cd a &` runs in a subshell, so it must not update the running
113
117
  // directory; ../b resolves against cwd and escapes.
114
118
  const program = await BashProgram.parse("cd a & cat ../b", cwd);
115
- expect(program.externalPaths()).toContain("/projects/b");
119
+ expect(program.externalPaths().map((p) => p.value())).toContain(
120
+ "/projects/b",
121
+ );
116
122
  });
117
123
 
118
124
  it("does not fold a cd inside a pipeline", async () => {
119
125
  // Pipeline members run in subshells; the cd must not leak.
120
126
  const program = await BashProgram.parse("cd nested | cat ../b", cwd);
121
- expect(program.externalPaths()).toContain("/projects/b");
127
+ expect(program.externalPaths().map((p) => p.value())).toContain(
128
+ "/projects/b",
129
+ );
122
130
  });
123
131
 
124
132
  it("folds a cd inside a subshell for paths within that subshell", async () => {
@@ -130,7 +138,9 @@ describe("BashProgram", () => {
130
138
  it("does not leak a subshell cd to following commands", async () => {
131
139
  // The subshell cd resets on exit, so ../y resolves against cwd.
132
140
  const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
133
- expect(program.externalPaths()).toContain("/projects/y");
141
+ expect(program.externalPaths().map((p) => p.value())).toContain(
142
+ "/projects/y",
143
+ );
134
144
  });
135
145
 
136
146
  it("persists a cd inside a brace group to later commands in the group", async () => {
@@ -152,14 +162,18 @@ describe("BashProgram", () => {
152
162
  "echo $(cd q && cat ../r)",
153
163
  cwd,
154
164
  );
155
- expect(program.externalPaths()).toContain("/projects/r");
165
+ expect(program.externalPaths().map((p) => p.value())).toContain(
166
+ "/projects/r",
167
+ );
156
168
  });
157
169
 
158
170
  it("flags relative paths conservatively after a non-literal cd", async () => {
159
171
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
160
172
  // so it is flagged (least-privilege).
161
173
  const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
162
- expect(program.externalPaths()).toContain("/projects/x");
174
+ expect(program.externalPaths().map((p) => p.value())).toContain(
175
+ "/projects/x",
176
+ );
163
177
  });
164
178
 
165
179
  it("flags even a within-cwd relative path after a non-literal cd", async () => {
@@ -169,7 +183,7 @@ describe("BashProgram", () => {
169
183
  'cd "$DIR" && cat src/../within.txt',
170
184
  cwd,
171
185
  );
172
- expect(program.externalPaths()).toContain(
186
+ expect(program.externalPaths().map((p) => p.value())).toContain(
173
187
  "/projects/my-app/within.txt",
174
188
  );
175
189
  });
@@ -186,7 +200,9 @@ describe("BashProgram", () => {
186
200
 
187
201
  it("treats `cd -` as an unknown effective directory", async () => {
188
202
  const program = await BashProgram.parse("cd - && cat ../x", cwd);
189
- expect(program.externalPaths()).toContain("/projects/x");
203
+ expect(program.externalPaths().map((p) => p.value())).toContain(
204
+ "/projects/x",
205
+ );
190
206
  });
191
207
 
192
208
  it("recovers a known base when a later cd is absolute", async () => {
@@ -233,7 +249,9 @@ describe("BashProgram", () => {
233
249
  "cd a && cd b 2>&1 | tail ; cat ../../x",
234
250
  cwd,
235
251
  );
236
- expect(program.externalPaths()).toContain("/projects/x");
252
+ expect(program.externalPaths().map((p) => p.value())).toContain(
253
+ "/projects/x",
254
+ );
237
255
  });
238
256
 
239
257
  it("resolves a downstream pipe stage against the folded base", async () => {
@@ -262,7 +280,7 @@ describe("BashProgram", () => {
262
280
  "cat /projects/my-app/link/hosts",
263
281
  cwd,
264
282
  );
265
- const external = program.externalPaths();
283
+ const external = program.externalPaths().map((p) => p.value());
266
284
  expect(external).toContain("/projects/my-app/link/hosts");
267
285
  expect(external).not.toContain("/etc/hosts");
268
286
  });
@@ -426,7 +444,7 @@ describe("BashProgram", () => {
426
444
  ".env",
427
445
  "/etc/hosts",
428
446
  ]);
429
- const external = program.externalPaths();
447
+ const external = program.externalPaths().map((p) => p.value());
430
448
  expect(external).toContain("/etc/hosts");
431
449
  expect(external).not.toContain(".env");
432
450
  });
@@ -49,10 +49,8 @@ function makeSetup(opts?: {
49
49
  opts.toolPermission,
50
50
  );
51
51
  }
52
- // Default checkPermission returns allow (for skill-prompt sanitizer)
53
- vi.mocked(permissionManager.checkPermission).mockReturnValue(
54
- makeCheckResult(),
55
- );
52
+ // Default check returns allow (for skill-prompt sanitizer via resolver.checkPermission)
53
+ vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult());
56
54
  const toolRegistry = makeToolRegistry(opts?.toolRegistry);
57
55
  const handler = new AgentPrepHandler(session, resolver, toolRegistry);
58
56
  return {
@@ -195,11 +193,10 @@ describe("AgentPrepHandler.handle", () => {
195
193
  "</available_skills>",
196
194
  ].join("\n");
197
195
  const { handler, permissionManager } = makeSetup();
198
- vi.mocked(permissionManager.checkPermission).mockImplementation(
199
- (surface) =>
200
- surface === "skill"
201
- ? makeCheckResult({ state: "deny" })
202
- : makeCheckResult(),
196
+ vi.mocked(permissionManager.check).mockImplementation((intent) =>
197
+ intent.surface === "skill"
198
+ ? makeCheckResult({ state: "deny" })
199
+ : makeCheckResult(),
203
200
  );
204
201
 
205
202
  const first = await handler.handle(makeEvent(systemPrompt), makeCtx());