@gotgenes/pi-permission-system 18.0.2 → 18.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.
@@ -10,7 +10,7 @@ import {
10
10
  ToolPreviewFormatter,
11
11
  type ToolPreviewFormatterOptions,
12
12
  } from "#src/tool-preview-formatter";
13
- import type { PermissionCheckResult } from "#src/types";
13
+ import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
14
14
  import { getNonEmptyString, toRecord } from "#src/value-guards";
15
15
  import { resolveBashCommandCheck } from "./bash-command";
16
16
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
@@ -42,6 +42,11 @@ export interface ToolCallGateInputs {
42
42
  getToolPreviewLimits(): ToolPreviewFormatterOptions;
43
43
  /** The session's path normalizer (platform + cwd baked in). */
44
44
  getPathNormalizer(): PathNormalizer;
45
+ /**
46
+ * Predicate deciding whether a bare bash token should be promoted into the
47
+ * `path` rule-candidate surface (#509), scoped to the given agent.
48
+ */
49
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
45
50
  }
46
51
 
47
52
  /**
@@ -73,7 +78,13 @@ export class ToolCallGatePipeline {
73
78
  const normalizer = this.inputs.getPathNormalizer();
74
79
  const bashProgram =
75
80
  tcc.toolName === "bash" && command
76
- ? await BashProgram.parse(command, normalizer)
81
+ ? await BashProgram.parse(
82
+ command,
83
+ normalizer,
84
+ this.inputs.getPromotablePathTokenMatcher(
85
+ tcc.agentName ?? undefined,
86
+ ),
87
+ )
77
88
  : null;
78
89
 
79
90
  const formatter = new ToolPreviewFormatter(
@@ -15,7 +15,12 @@ import {
15
15
  type ResolvedPolicyPaths,
16
16
  } from "./policy-loader";
17
17
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
18
- import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
18
+ import {
19
+ evaluate,
20
+ evaluateAnyValue,
21
+ evaluateFirst,
22
+ pathMatchOptions,
23
+ } from "./rule";
19
24
  import { mergeScopesWithOrigins } from "./scope-merge";
20
25
  import {
21
26
  composeRuleset,
@@ -24,10 +29,12 @@ import {
24
29
  } from "./synthesize";
25
30
  import type {
26
31
  FlatPermissionConfig,
32
+ PathRuleTokenMatcher,
27
33
  PermissionCheckResult,
28
34
  PermissionState,
29
35
  } from "./types";
30
36
  import { isPermissionState } from "./value-guards";
37
+ import { wildcardMatch } from "./wildcard-matcher";
31
38
 
32
39
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
33
40
  "bash",
@@ -43,6 +50,9 @@ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
43
50
  /** Universal fallback when permission["*"] is absent from all scopes. */
44
51
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
45
52
 
53
+ /** Promotion predicate matching no token — the no-`path`-rules default (#509). */
54
+ const NO_PROMOTION: PathRuleTokenMatcher = () => false;
55
+
46
56
  type FileCacheEntry<TValue> = {
47
57
  stamp: string;
48
58
  value: TValue;
@@ -76,6 +86,15 @@ export interface ScopedPermissionManager {
76
86
  ): PermissionCheckResult;
77
87
  getToolPermission(toolName: string, agentName?: string): PermissionState;
78
88
  getConfigIssues(agentName?: string): string[];
89
+ /**
90
+ * Build a predicate deciding whether a bare bash token should be promoted
91
+ * into the `path` rule-candidate surface (#509).
92
+ *
93
+ * Matches against specific (non-`*`) `path`-surface config rules whose
94
+ * action is `deny` or `ask` — an allow rule never gates, and `"*"` would
95
+ * promote every bare bash argument.
96
+ */
97
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
79
98
  }
80
99
 
81
100
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -211,6 +230,37 @@ export class PermissionManager implements ScopedPermissionManager {
211
230
  return composedRules.filter((r) => r.layer === "config");
212
231
  }
213
232
 
233
+ /**
234
+ * Build a predicate deciding whether a bare bash token should be promoted
235
+ * into the `path` rule-candidate surface (#509).
236
+ *
237
+ * Filters the composed config ruleset to specific (non-`*`) `path`-surface
238
+ * deny/ask patterns, then returns a closure matching a token against them
239
+ * with the platform-correct fold (Windows case-and-separator matching, same
240
+ * as {@link pathMatchOptions} applies for evaluation) so promotion agrees
241
+ * with the later `path`-surface decision.
242
+ *
243
+ * Returns a matcher rejecting every token when no such rule exists — the
244
+ * default-config case is unaffected by promotion.
245
+ */
246
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
247
+ const { composedRules } = this.resolvePermissions(agentName);
248
+ const patterns = composedRules
249
+ .filter(
250
+ (r) =>
251
+ r.layer === "config" &&
252
+ r.surface === "path" &&
253
+ r.pattern !== "*" &&
254
+ r.action !== "allow",
255
+ )
256
+ .map((r) => r.pattern);
257
+ if (patterns.length === 0) return NO_PROMOTION;
258
+
259
+ const matchOptions = pathMatchOptions("path", this.platform);
260
+ return (token) =>
261
+ patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
262
+ }
263
+
214
264
  /**
215
265
  * Get the tool-level permission state for a tool, without considering
216
266
  * command-level rules. Used for tool injection decisions.
@@ -19,6 +19,7 @@ import {
19
19
  resolveToolPreviewLimits,
20
20
  type ToolPreviewFormatterOptions,
21
21
  } from "./tool-preview-formatter";
22
+ import type { PathRuleTokenMatcher } from "./types";
22
23
 
23
24
  /**
24
25
  * Encapsulates all mutable session state and exposes operations instead of
@@ -214,4 +215,15 @@ export class PermissionSession implements ToolCallGateInputs {
214
215
  getPathNormalizer(): PathNormalizer {
215
216
  return this.pathNormalizer;
216
217
  }
218
+
219
+ /**
220
+ * Predicate deciding whether a bare bash token should be promoted into the
221
+ * `path` rule-candidate surface (#509), scoped to the given agent.
222
+ *
223
+ * Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
224
+ * the manager owns the composed ruleset and the platform-correct match.
225
+ */
226
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
227
+ return this.permissionManager.getPromotablePathTokenMatcher(agentName);
228
+ }
217
229
  }
package/src/rule.ts CHANGED
@@ -74,7 +74,7 @@ export function evaluate(
74
74
  * pattern→value match (case and separators) so mixed-case / forward-slash
75
75
  * overrides still match. The surface→surface match stays exact.
76
76
  */
77
- function pathMatchOptions(
77
+ export function pathMatchOptions(
78
78
  surface: string,
79
79
  platform: NodeJS.Platform,
80
80
  ): { caseInsensitive: true; windowsSeparators: true } | undefined {
package/src/types.ts CHANGED
@@ -16,6 +16,17 @@ export interface DenyWithReason {
16
16
  /** A pattern value: a PermissionState string OR a DenyWithReason object. */
17
17
  export type PatternValue = PermissionState | DenyWithReason;
18
18
 
19
+ /**
20
+ * Predicate deciding whether a bare bash token should be promoted into the
21
+ * `path` rule-candidate surface.
22
+ *
23
+ * Built by `PermissionManager.getPromotablePathTokenMatcher` from the
24
+ * composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
25
+ * threaded through to `BashPathResolver` so promotion policy stays in the
26
+ * manager while the bash layer only asks the predicate.
27
+ */
28
+ export type PathRuleTokenMatcher = (token: string) => boolean;
29
+
19
30
  /**
20
31
  * The on-disk permission shape inside the `"permission"` key.
21
32
  * A surface value is a PermissionState string (shorthand for `{ "*": action }`)
@@ -91,6 +91,63 @@ describe("BashProgram", () => {
91
91
  expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
92
92
  expect(fileCandidate?.path.boundaryValue()).toBe("");
93
93
  });
94
+
95
+ describe("rule-driven bare-token promotion (#509)", () => {
96
+ it("promotes a bare token when the matcher says it is promotable", async () => {
97
+ const isPromotable = (token: string): boolean => token === "id_rsa";
98
+ const program = await BashProgram.parse(
99
+ "cat id_rsa",
100
+ normalizer,
101
+ isPromotable,
102
+ );
103
+ const candidates = program.pathRuleCandidates();
104
+ expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
105
+ expect(candidates[0].path.matchValues()).toEqual([
106
+ "/projects/my-app/id_rsa",
107
+ "id_rsa",
108
+ ]);
109
+ });
110
+
111
+ it("does not promote a bare token the matcher rejects", async () => {
112
+ const isPromotable = (token: string): boolean => token === "id_rsa";
113
+ const program = await BashProgram.parse(
114
+ "git status",
115
+ normalizer,
116
+ isPromotable,
117
+ );
118
+ expect(program.pathRuleCandidates()).toHaveLength(0);
119
+ });
120
+
121
+ it("does not promote any bare token with the default no-op matcher", async () => {
122
+ const program = await BashProgram.parse("cat id_rsa", normalizer);
123
+ expect(program.pathRuleCandidates()).toHaveLength(0);
124
+ });
125
+
126
+ it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
127
+ const isPromotable = (token: string): boolean => token === "id_rsa";
128
+ const program = await BashProgram.parse(
129
+ 'cd "$DIR" && cat id_rsa',
130
+ normalizer,
131
+ isPromotable,
132
+ );
133
+ const candidate = program
134
+ .pathRuleCandidates()
135
+ .find((c) => c.token === "id_rsa");
136
+ expect(candidate?.path.matchValues()).toEqual(["id_rsa"]);
137
+ });
138
+
139
+ it("does not double-promote a token the shape gate already accepts", async () => {
140
+ // ./id_rsa already passes classifyTokenAsRuleCandidate; the promoted
141
+ // fallback must not run (and must not duplicate the candidate).
142
+ const isPromotable = (): boolean => true;
143
+ const program = await BashProgram.parse(
144
+ "cat ./id_rsa",
145
+ normalizer,
146
+ isPromotable,
147
+ );
148
+ expect(program.pathRuleCandidates()).toHaveLength(1);
149
+ });
150
+ });
94
151
  });
95
152
 
96
153
  describe("externalPaths", () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
 
3
3
  import {
4
+ classifyPromotedRuleCandidate,
4
5
  classifyTokenAsPathCandidate,
5
6
  classifyTokenAsRuleCandidate,
6
7
  } from "#src/access-intent/bash/token-classification";
@@ -314,3 +315,49 @@ describe("classifyTokenAsRuleCandidate", () => {
314
315
  }
315
316
  });
316
317
  });
318
+
319
+ describe("classifyPromotedRuleCandidate", () => {
320
+ test("shape-eligible bare token promoted when predicate returns true", () => {
321
+ expect(classifyPromotedRuleCandidate("id_rsa", () => true)).toBe("id_rsa");
322
+ });
323
+
324
+ test("shape-eligible bare token rejected when predicate returns false", () => {
325
+ expect(classifyPromotedRuleCandidate("id_rsa", () => false)).toBeNull();
326
+ });
327
+
328
+ test("predicate receives the raw token", () => {
329
+ const isPromotable = (token: string): boolean => token === "key.pem";
330
+ expect(classifyPromotedRuleCandidate("key.pem", isPromotable)).toBe(
331
+ "key.pem",
332
+ );
333
+ expect(classifyPromotedRuleCandidate("other.pem", isPromotable)).toBeNull();
334
+ });
335
+
336
+ describe("shared rejection still applies regardless of the predicate", () => {
337
+ test("flag (leading dash) → null", () => {
338
+ expect(classifyPromotedRuleCandidate("-r", () => true)).toBeNull();
339
+ });
340
+
341
+ test("env assignment → null", () => {
342
+ expect(classifyPromotedRuleCandidate("FOO=/bar", () => true)).toBeNull();
343
+ });
344
+
345
+ test("URL → null", () => {
346
+ expect(
347
+ classifyPromotedRuleCandidate("https://example.com", () => true),
348
+ ).toBeNull();
349
+ });
350
+
351
+ test("@scope/package → null", () => {
352
+ expect(classifyPromotedRuleCandidate("@foo/bar", () => true)).toBeNull();
353
+ });
354
+
355
+ test("regex metacharacters → null", () => {
356
+ expect(classifyPromotedRuleCandidate("foo.*", () => true)).toBeNull();
357
+ });
358
+
359
+ test("empty string → null", () => {
360
+ expect(classifyPromotedRuleCandidate("", () => true)).toBeNull();
361
+ });
362
+ });
363
+ });
@@ -501,6 +501,79 @@ describe("service path queries evaluate the supplied path (#503)", () => {
501
501
  });
502
502
  });
503
503
 
504
+ describe("bash bare-filename path gating (#509)", () => {
505
+ // Before #509 a bash bare-filename argument (`cat id_rsa`) bypassed the
506
+ // `path` surface entirely: the broad classifier only accepted tokens
507
+ // starting with `.`, containing `/`, containing `..`, or a Windows
508
+ // drive-letter absolute path. The same file accessed via a prefixed path
509
+ // (`cat ./id_rsa`) or the `read` tool was already gated. Rule-driven
510
+ // promotion closes the gap for a bare token matching a specific, non-`*`
511
+ // `path` deny/ask rule — the literal repro from the issue.
512
+
513
+ async function fireBashToolCall(
514
+ pi: ReturnType<typeof makeFakePi>,
515
+ ctx: unknown,
516
+ command: string,
517
+ ): Promise<{ block?: true; reason?: string }> {
518
+ return (await pi.fire(
519
+ "tool_call",
520
+ { name: "bash", input: { command }, toolCallId: "tc-1" },
521
+ ctx,
522
+ )) as { block?: true; reason?: string };
523
+ }
524
+
525
+ it("denies a bare filename matching a specific path deny rule", async () => {
526
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
527
+ writeGlobalConfig({
528
+ permission: { "*": "allow", path: { id_rsa: "deny" } },
529
+ });
530
+
531
+ const pi = makeFakePi({ events: createEventBus() });
532
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
533
+ const ctx = makeChildCtx(cwd, "bare-token-session-deny");
534
+ await fireSessionStart(pi, ctx);
535
+
536
+ const result = await fireBashToolCall(pi, ctx, "cat id_rsa");
537
+ expect(result.block).toBe(true);
538
+
539
+ rmSync(cwd, { recursive: true, force: true });
540
+ });
541
+
542
+ it("denies a bare filename matching a wildcard path deny rule", async () => {
543
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
544
+ writeGlobalConfig({
545
+ permission: { "*": "allow", path: { "*.pem": "deny" } },
546
+ });
547
+
548
+ const pi = makeFakePi({ events: createEventBus() });
549
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
550
+ const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
551
+ await fireSessionStart(pi, ctx);
552
+
553
+ const result = await fireBashToolCall(pi, ctx, "cat key.pem");
554
+ expect(result.block).toBe(true);
555
+
556
+ rmSync(cwd, { recursive: true, force: true });
557
+ });
558
+
559
+ it("leaves a bare token that does not match any path rule unaffected", async () => {
560
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
561
+ writeGlobalConfig({
562
+ permission: { "*": "allow", path: { id_rsa: "deny" } },
563
+ });
564
+
565
+ const pi = makeFakePi({ events: createEventBus() });
566
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
567
+ const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
568
+ await fireSessionStart(pi, ctx);
569
+
570
+ const result = await fireBashToolCall(pi, ctx, "git status");
571
+ expect(result.block).toBeUndefined();
572
+
573
+ rmSync(cwd, { recursive: true, force: true });
574
+ });
575
+ });
576
+
504
577
  describe("multi-instance global service interplay", () => {
505
578
  // The fix (#302) scopes the process-global service slot to the publishing
506
579
  // instance. The parent publishes at its session_start; an in-process child
@@ -186,6 +186,7 @@ describe("ToolCallGatePipeline", () => {
186
186
  expect(mockBashProgramParse).toHaveBeenCalledWith(
187
187
  "echo hello",
188
188
  expect.any(PathNormalizer),
189
+ expect.any(Function),
189
190
  );
190
191
  });
191
192
 
@@ -202,6 +203,31 @@ describe("ToolCallGatePipeline", () => {
202
203
 
203
204
  expect(mockBashProgramParse).not.toHaveBeenCalled();
204
205
  });
206
+
207
+ it("passes the session's promotable path-token matcher into BashProgram.parse (#509)", async () => {
208
+ const resolver = makeResolver(makeCheckResult());
209
+ const isPromotable = vi.fn((token: string) => token === "id_rsa");
210
+ const getPromotablePathTokenMatcher = vi.fn(() => isPromotable);
211
+ const inputs = makeGateInputs({ getPromotablePathTokenMatcher });
212
+ const { runner } = makeGateRunner();
213
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
214
+
215
+ await pipeline.evaluate(
216
+ makeTcc({
217
+ toolName: "bash",
218
+ input: { command: "cat id_rsa" },
219
+ agentName: "my-agent",
220
+ }),
221
+ runner,
222
+ );
223
+
224
+ expect(getPromotablePathTokenMatcher).toHaveBeenCalledWith("my-agent");
225
+ expect(mockBashProgramParse).toHaveBeenCalledWith(
226
+ "cat id_rsa",
227
+ expect.any(PathNormalizer),
228
+ isPromotable,
229
+ );
230
+ });
205
231
  });
206
232
 
207
233
  // ── customExtractors threading (#352) ────────────────────────────────────
@@ -15,7 +15,7 @@ import type { ScopedPermissionResolver } from "#src/permission-resolver";
15
15
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
16
16
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
17
17
  import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
18
- import type { PermissionCheckResult } from "#src/types";
18
+ import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
19
19
 
20
20
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
21
21
 
@@ -255,6 +255,9 @@ export function makeGateInputs(
255
255
  getInfrastructureReadDirs?: () => string[];
256
256
  getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
257
257
  getPathNormalizer?: () => PathNormalizer;
258
+ getPromotablePathTokenMatcher?: (
259
+ agentName?: string,
260
+ ) => PathRuleTokenMatcher;
258
261
  } = {},
259
262
  ): ToolCallGateInputs {
260
263
  return {
@@ -275,6 +278,9 @@ export function makeGateInputs(
275
278
  vi.fn<() => PathNormalizer>(
276
279
  () => new PathNormalizer(process.platform, "/test/cwd"),
277
280
  ),
281
+ getPromotablePathTokenMatcher:
282
+ overrides.getPromotablePathTokenMatcher ??
283
+ vi.fn<(agentName?: string) => PathRuleTokenMatcher>(() => () => false),
278
284
  };
279
285
  }
280
286
 
@@ -25,7 +25,11 @@ import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
25
25
  import type { Ruleset } from "#src/rule";
26
26
  import type { SessionLogger } from "#src/session-logger";
27
27
  import { SessionRules } from "#src/session-rules";
28
- import type { PermissionCheckResult, PermissionState } from "#src/types";
28
+ import type {
29
+ PathRuleTokenMatcher,
30
+ PermissionCheckResult,
31
+ PermissionState,
32
+ } from "#src/types";
29
33
 
30
34
  // ── Per-collaborator fake factories ────────────────────────────────────────
31
35
 
@@ -105,6 +109,9 @@ export function makeFakePermissionManager() {
105
109
  .fn<(toolName: string, agentName?: string) => PermissionState>()
106
110
  .mockReturnValue("allow"),
107
111
  getConfigIssues: vi.fn((): string[] => []),
112
+ getPromotablePathTokenMatcher: vi
113
+ .fn<(agentName?: string) => PathRuleTokenMatcher>()
114
+ .mockReturnValue(() => false),
108
115
  };
109
116
  }
110
117
 
@@ -1096,6 +1096,87 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
1096
1096
  ).toBe(true);
1097
1097
  });
1098
1098
  });
1099
+
1100
+ describe("getPromotablePathTokenMatcher (#509)", () => {
1101
+ it("matches a bare token against a specific deny pattern", () => {
1102
+ const manager = makeInMemoryManager({
1103
+ global: { permission: { path: { id_rsa: "deny" } } },
1104
+ });
1105
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1106
+ expect(isPromotable("id_rsa")).toBe(true);
1107
+ expect(isPromotable("other_file")).toBe(false);
1108
+ });
1109
+
1110
+ it("matches a bare token against a specific ask wildcard pattern", () => {
1111
+ const manager = makeInMemoryManager({
1112
+ global: { permission: { path: { "*.pem": "ask" } } },
1113
+ });
1114
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1115
+ expect(isPromotable("key.pem")).toBe(true);
1116
+ expect(isPromotable("key.txt")).toBe(false);
1117
+ });
1118
+
1119
+ it("does not match against the universal '*' path pattern", () => {
1120
+ const manager = makeInMemoryManager({
1121
+ global: { permission: { path: { "*": "ask" } } },
1122
+ });
1123
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1124
+ expect(isPromotable("anything")).toBe(false);
1125
+ expect(isPromotable("status")).toBe(false);
1126
+ });
1127
+
1128
+ it("does not match against an allow-only path pattern", () => {
1129
+ const manager = makeInMemoryManager({
1130
+ global: { permission: { path: { id_rsa: "allow" } } },
1131
+ });
1132
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1133
+ expect(isPromotable("id_rsa")).toBe(false);
1134
+ });
1135
+
1136
+ it("returns a no-op matcher when no path rules exist", () => {
1137
+ const manager = makeInMemoryManager({
1138
+ global: { permission: { read: "allow" } },
1139
+ });
1140
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1141
+ expect(isPromotable("id_rsa")).toBe(false);
1142
+ expect(isPromotable("anything")).toBe(false);
1143
+ });
1144
+
1145
+ it("folds case on an injected win32 platform", () => {
1146
+ const manager = new PermissionManager({
1147
+ policyLoader: createInMemoryPolicyLoader({
1148
+ global: { permission: { path: { id_rsa: "deny" } } },
1149
+ }),
1150
+ platform: "win32",
1151
+ });
1152
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1153
+ expect(isPromotable("ID_RSA")).toBe(true);
1154
+ });
1155
+
1156
+ it("stays case-sensitive on a POSIX platform", () => {
1157
+ const manager = new PermissionManager({
1158
+ policyLoader: createInMemoryPolicyLoader({
1159
+ global: { permission: { path: { id_rsa: "deny" } } },
1160
+ }),
1161
+ platform: "linux",
1162
+ });
1163
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1164
+ expect(isPromotable("ID_RSA")).toBe(false);
1165
+ });
1166
+
1167
+ it("scopes to the requested agent's composed rules", () => {
1168
+ const manager = makeInMemoryManager({
1169
+ global: { permission: { path: { id_rsa: "deny" } } },
1170
+ agent: {
1171
+ coder: { permission: { path: { "secret.key": "deny" } } },
1172
+ },
1173
+ });
1174
+ const globalMatcher = manager.getPromotablePathTokenMatcher();
1175
+ const agentMatcher = manager.getPromotablePathTokenMatcher("coder");
1176
+ expect(globalMatcher("secret.key")).toBe(false);
1177
+ expect(agentMatcher("secret.key")).toBe(true);
1178
+ });
1179
+ });
1099
1180
  });
1100
1181
 
1101
1182
  // ---------------------------------------------------------------------------
@@ -6,7 +6,11 @@ import { PermissionResolver } from "#src/permission-resolver";
6
6
  import type { Ruleset } from "#src/rule";
7
7
  import { SessionApproval } from "#src/session-approval";
8
8
  import { SessionRules } from "#src/session-rules";
9
- import type { PermissionCheckResult, PermissionState } from "#src/types";
9
+ import type {
10
+ PathRuleTokenMatcher,
11
+ PermissionCheckResult,
12
+ PermissionState,
13
+ } from "#src/types";
10
14
 
11
15
  function makePermissionManager() {
12
16
  return {
@@ -28,6 +32,9 @@ function makePermissionManager() {
28
32
  .fn<(toolName: string, agentName?: string) => PermissionState>()
29
33
  .mockReturnValue("allow"),
30
34
  getConfigIssues: vi.fn((): string[] => []),
35
+ getPromotablePathTokenMatcher: vi
36
+ .fn<(agentName?: string) => PathRuleTokenMatcher>()
37
+ .mockReturnValue(() => false),
31
38
  };
32
39
  }
33
40