@gotgenes/pi-permission-system 17.1.0 → 18.0.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 (38) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -0
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +6 -3
  5. package/src/access-intent/bash/bash-path-resolver.ts +16 -12
  6. package/src/access-intent/bash/program.ts +0 -4
  7. package/src/access-intent/bash/token-classification.ts +23 -0
  8. package/src/handlers/before-agent-start.ts +1 -2
  9. package/src/handlers/gates/external-directory.ts +2 -12
  10. package/src/handlers/gates/skill-read.ts +4 -8
  11. package/src/handlers/gates/tool-call-gate-pipeline.ts +62 -24
  12. package/src/handlers/gates/tool.ts +10 -14
  13. package/src/index.ts +8 -6
  14. package/src/input-normalizer.ts +54 -56
  15. package/src/path-normalizer.ts +30 -0
  16. package/src/permission-event-rpc.ts +21 -15
  17. package/src/permission-manager.ts +3 -6
  18. package/src/permission-session.ts +0 -5
  19. package/src/permissions-service.ts +33 -12
  20. package/src/skill-prompt-sanitizer.ts +8 -21
  21. package/test/access-intent/bash/program.test.ts +1 -1
  22. package/test/access-intent/bash/token-classification.test.ts +75 -0
  23. package/test/bash-external-directory.test.ts +38 -0
  24. package/test/composition-root.test.ts +22 -0
  25. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -3
  26. package/test/handlers/gates/external-directory.test.ts +0 -1
  27. package/test/handlers/gates/skill-read.test.ts +16 -12
  28. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
  29. package/test/handlers/gates/tool.test.ts +25 -16
  30. package/test/helpers/gate-fixtures.ts +0 -3
  31. package/test/input-normalizer.test.ts +163 -270
  32. package/test/path-normalizer.test.ts +43 -0
  33. package/test/path-utils.test.ts +1 -1
  34. package/test/permission-event-rpc.test.ts +80 -65
  35. package/test/permission-manager-unified.test.ts +134 -145
  36. package/test/permissions-service.test.ts +84 -72
  37. package/test/service.test.ts +56 -103
  38. package/test/skill-prompt-sanitizer.test.ts +31 -65
@@ -1,25 +1,60 @@
1
+ import type { AccessIntent } from "./access-intent/access-intent";
1
2
  import { stripBashCommentLines } from "./bash-arity";
2
3
  import { createMcpPermissionTargets } from "./mcp-targets";
3
- import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
4
+ import type { PathNormalizer } from "./path-normalizer";
5
+ import { PATH_SURFACES } from "./path-utils";
4
6
  import { getNonEmptyString, toRecord } from "./value-guards";
5
7
 
6
8
  /**
7
- * Construct a surface-appropriate input object from a raw value string.
9
+ * Build the {@link AccessIntent} an external policy query (the `Symbol.for()`
10
+ * service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
11
+ * pair.
8
12
  *
9
- * This is the inverse of `normalizeInput()` it builds the minimal input
10
- * object that `PermissionManager.checkPermission()` expects for a given
11
- * surface, from a single string value.
13
+ * For a path-shaped surface (`path`, `external_directory`, or a path-bearing
14
+ * tool) carrying a non-empty value, it builds an `AccessPath` and emits an
15
+ * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
16
+ * (symlink-resolved) set — at parity with the gates (#486, #502). Every other
17
+ * surface, and any value-less surface-level query, keeps the `tool` intent so
18
+ * the manager's `normalizeInput` `["*"]` fallback is preserved.
19
+ */
20
+ export function buildAccessIntentForSurface(
21
+ surface: string,
22
+ value: string | undefined,
23
+ normalizer: PathNormalizer,
24
+ agentName: string | undefined,
25
+ ): AccessIntent {
26
+ const pathValue = getNonEmptyString(value);
27
+ if (pathValue !== null && PATH_SURFACES.has(surface)) {
28
+ return {
29
+ kind: "access-path",
30
+ surface,
31
+ path: normalizer.forPath(pathValue),
32
+ agentName,
33
+ };
34
+ }
35
+ return {
36
+ kind: "tool",
37
+ surface,
38
+ input: buildInputForSurface(surface, value),
39
+ agentName,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Construct a surface-appropriate input object from a raw value string for the
45
+ * `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path
46
+ * surfaces and value-less path queries).
12
47
  *
13
- * Used by the event-bus RPC handler and the `Symbol.for()` service accessor
14
- * so external callers can query policy with `(surface, value)` instead of
15
- * constructing a full tool-call input payload.
48
+ * This is the inverse of `normalizeInput()` it builds the minimal input
49
+ * object that the manager expects for a given surface, from a single string
50
+ * value.
16
51
  *
17
52
  * Note: MCP inputs are complex (server name + tool name derivation). Callers
18
53
  * providing an MCP surface receive a best-effort policy evaluation using the
19
54
  * value as a pre-qualified target string. Pass the fully-qualified target
20
55
  * (e.g. "exa:search" or "exa") directly.
21
56
  */
22
- export function buildInputForSurface(
57
+ function buildInputForSurface(
23
58
  surface: string,
24
59
  value: string | undefined,
25
60
  ): unknown {
@@ -51,12 +86,16 @@ export interface NormalizedInput {
51
86
  resultExtras: Record<string, unknown>;
52
87
  }
53
88
 
54
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
55
-
56
89
  /**
57
90
  * Map a raw tool invocation to the surface/values/extras triple needed by
58
91
  * `checkPermission()`.
59
92
  *
93
+ * Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
94
+ * (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`,
95
+ * `ls`) now route through the access-path gate (#502) and service/RPC builder
96
+ * (#503) before reaching the manager, so they never arrive here with a real
97
+ * path value — all fall through to the extension catch-all `["*"]`.
98
+ *
60
99
  * @param toolName - Normalized (trimmed) tool name from the tool-call event.
61
100
  * @param input - Raw input payload from the tool-call event.
62
101
  * @param configuredMcpServerNames - Ordered list of MCP server names from the
@@ -66,18 +105,7 @@ export function normalizeInput(
66
105
  toolName: string,
67
106
  input: unknown,
68
107
  configuredMcpServerNames: readonly string[],
69
- platform: NodeJS.Platform,
70
- cwd?: string,
71
108
  ): NormalizedInput {
72
- // --- Special surfaces (path, external_directory) ---
73
- if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
74
- return {
75
- surface: toolName,
76
- values: normalizePathSurfaceValues(input, platform, cwd),
77
- resultExtras: {},
78
- };
79
- }
80
-
81
109
  // --- Skill ---
82
110
  if (toolName === "skill") {
83
111
  const record = toRecord(input);
@@ -120,43 +148,13 @@ export function normalizeInput(
120
148
  };
121
149
  }
122
150
 
123
- // --- Path-bearing tools (read, write, edit, grep, find, ls) ---
124
- if (PATH_BEARING_TOOLS.has(toolName)) {
125
- return {
126
- surface: toolName,
127
- values: normalizePathSurfaceValues(input, platform, cwd),
128
- resultExtras: {},
129
- };
130
- }
131
-
132
- // --- Extension tools (non-path-bearing) ---
151
+ // --- All other surfaces (path-bearing tools and extension tools) ---
152
+ // Path-bearing tools with a present path never reach here — the gate emits
153
+ // an access-path intent (#502). Missing-path and extension-tool cases both
154
+ // collapse to the surface catch-all.
133
155
  return {
134
156
  surface: toolName,
135
157
  values: ["*"],
136
158
  resultExtras: {},
137
159
  };
138
160
  }
139
-
140
- /**
141
- * Extract and normalize the path lookup values shared by every path surface
142
- * (`path`, `external_directory`, and the path-bearing tools).
143
- *
144
- * Missing, empty, or whitespace-only paths collapse to the surface catch-all
145
- * `"*"`. When CWD is known, a relative path also produces a normalized
146
- * absolute policy value and a project-relative alias while keeping its legacy
147
- * relative value, so values match home- and cwd-anchored patterns
148
- * symmetrically with how the patterns themselves are expanded (#350).
149
- *
150
- * Only `input.path` is read — policy values are never sourced from any other
151
- * (potentially attacker-controlled) field on the raw tool input.
152
- */
153
- function normalizePathSurfaceValues(
154
- input: unknown,
155
- platform: NodeJS.Platform,
156
- cwd?: string,
157
- ): string[] {
158
- const path = getNonEmptyString(toRecord(input).path);
159
- if (path === null) return ["*"];
160
- const values = getPathPolicyValues(path, cwd ? { cwd } : {}, platform);
161
- return values.length > 0 ? values : ["*"];
162
- }
@@ -4,6 +4,8 @@ import { AccessPath } from "./access-intent/access-path";
4
4
  import {
5
5
  isPathOutsideWorkingDirectory,
6
6
  isPathWithinDirectory,
7
+ isPiInfrastructureRead,
8
+ normalizePathForComparison,
7
9
  } from "./path-utils";
8
10
 
9
11
  /**
@@ -67,4 +69,32 @@ export class PathNormalizer {
67
69
  isOutsideWorkingDirectory(pathValue: string): boolean {
68
70
  return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
69
71
  }
72
+
73
+ /**
74
+ * Lexical (not symlink-resolved) comparison value, resolved against the baked
75
+ * cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
76
+ * touches no filesystem, unlike {@link forPath}'s canonical alias.
77
+ */
78
+ comparableValue(pathValue: string): string {
79
+ return normalizePathForComparison(pathValue, this.cwd, this.platform);
80
+ }
81
+
82
+ /**
83
+ * Pi infrastructure-read containment for a read-only tool, decided against
84
+ * the canonical (symlink-resolved) path and the baked cwd/platform. Takes the
85
+ * already-built {@link AccessPath} so the caller does not re-resolve it.
86
+ */
87
+ isInfrastructureRead(
88
+ toolName: string,
89
+ accessPath: AccessPath,
90
+ infraDirs: readonly string[],
91
+ ): boolean {
92
+ return isPiInfrastructureRead(
93
+ toolName,
94
+ accessPath.boundaryValue(),
95
+ infraDirs,
96
+ this.cwd,
97
+ this.platform,
98
+ );
99
+ }
70
100
  }
@@ -7,7 +7,8 @@
7
7
  * permission prompts without importing this package.
8
8
  */
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { buildInputForSurface } from "./input-normalizer";
10
+ import { buildAccessIntentForSurface } from "./input-normalizer";
11
+ import type { PathNormalizer } from "./path-normalizer";
11
12
  import type {
12
13
  PermissionPromptDecision,
13
14
  RequestPermissionOptions,
@@ -26,22 +27,26 @@ import {
26
27
  PERMISSIONS_RPC_CHECK_CHANNEL,
27
28
  PERMISSIONS_RPC_PROMPT_CHANNEL,
28
29
  } from "./permission-events";
29
- import type { ScopedPermissionManager } from "./permission-manager";
30
+ import type { ScopedPermissionResolver } from "./permission-resolver";
30
31
  import { buildRpcUiPrompt } from "./permission-ui-prompt";
31
32
  import type { ReviewLogger } from "./session-logger";
32
- import type { SessionRules } from "./session-rules";
33
33
 
34
34
  /** Dependencies injected into the RPC handler registry. */
35
35
  export interface PermissionRpcDeps {
36
- /** The shared PermissionManager instance. */
37
- permissionManager: Pick<ScopedPermissionManager, "check">;
38
- /** The shared SessionRules instance. */
39
- sessionRules: Pick<SessionRules, "getRuleset">;
40
36
  /**
41
- * Narrow session view: provides runtime context.
42
- * Used by the prompt handler to check hasUI and access the UI dialog.
37
+ * The shared resolver: answers an access intent, composing the session
38
+ * ruleset and unwrapping `access-path` `path-values` internally so the
39
+ * RPC check matches the same lexical ∪ canonical set the gates do (#503).
43
40
  */
44
- session: { getRuntimeContext(): ExtensionContext | null };
41
+ resolver: Pick<ScopedPermissionResolver, "resolve">;
42
+ /**
43
+ * Narrow session view: runtime context for the prompt handler (hasUI / UI
44
+ * dialog) and the cwd-bound path normalizer for path-surface check queries.
45
+ */
46
+ session: {
47
+ getRuntimeContext(): ExtensionContext | null;
48
+ getPathNormalizer(): PathNormalizer;
49
+ };
45
50
  /** Show the interactive permission dialog in the parent session UI. */
46
51
  requestPermissionDecisionFromUi(
47
52
  ui: ExtensionContext["ui"],
@@ -107,12 +112,13 @@ function handleCheckRpc(
107
112
  return;
108
113
  }
109
114
 
110
- const input = buildInputForSurface(surface, value);
111
- const sessionRules = deps.sessionRules.getRuleset();
112
- const result = deps.permissionManager.check(
113
- { kind: "tool", surface, input, agentName: agentName ?? undefined },
114
- sessionRules,
115
+ const intent = buildAccessIntentForSurface(
116
+ surface,
117
+ value,
118
+ deps.session.getPathNormalizer(),
119
+ agentName ?? undefined,
115
120
  );
121
+ const result = deps.resolver.resolve(intent);
116
122
 
117
123
  const data: PermissionsCheckReplyData = {
118
124
  result: result.state,
@@ -96,7 +96,6 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
96
96
  export class PermissionManager implements ScopedPermissionManager {
97
97
  private readonly agentDir: string | undefined;
98
98
  private readonly platform: NodeJS.Platform;
99
- private currentCwd: string | undefined;
100
99
  private loader: PolicyLoader;
101
100
  private readonly resolvedPermissionsCache = new Map<
102
101
  string,
@@ -123,8 +122,6 @@ export class PermissionManager implements ScopedPermissionManager {
123
122
  * built with explicit paths), only the cache is cleared.
124
123
  */
125
124
  configureForCwd(cwd: string | undefined | null): void {
126
- this.currentCwd =
127
- typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
128
125
  if (this.agentDir !== undefined) {
129
126
  this.loader = new FilePolicyLoader(
130
127
  derivePolicyLoaderOptions(this.agentDir, cwd),
@@ -248,7 +245,9 @@ export class PermissionManager implements ScopedPermissionManager {
248
245
  /**
249
246
  * Unified resolution entry point — dispatches on intent kind.
250
247
  *
251
- * `"tool"` → normalizes raw input through `normalizeInput`.
248
+ * `"tool"` → normalizes raw input through `normalizeInput` (bash, skill, mcp,
249
+ * extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
250
+ * the access-path gate (#502) or service/RPC builder (#503).
252
251
  * `"path-values"` → evaluates the precomputed values directly.
253
252
  */
254
253
  check(
@@ -280,8 +279,6 @@ export class PermissionManager implements ScopedPermissionManager {
280
279
  toolName,
281
280
  intent.input,
282
281
  this.loader.getConfiguredMcpServerNames(),
283
- this.platform,
284
- this.currentCwd,
285
282
  );
286
283
  return buildCheckResult(
287
284
  surface,
@@ -85,11 +85,6 @@ export class PermissionSession implements ToolCallGateInputs {
85
85
  return this.context;
86
86
  }
87
87
 
88
- /** The host platform injected at the composition root. */
89
- getPlatform(): NodeJS.Platform {
90
- return this.platform;
91
- }
92
-
93
88
  // ── UI notifications ────────────────────────────────────────────────────
94
89
 
95
90
  /** Surface a warning message to the user via the active UI context, if any. */
@@ -1,7 +1,7 @@
1
- import { buildInputForSurface } from "./input-normalizer";
2
- import type { ScopedPermissionManager } from "./permission-manager";
1
+ import type { AccessIntent } from "./access-intent/access-intent";
2
+ import { buildAccessIntentForSurface } from "./input-normalizer";
3
+ import type { PathNormalizer } from "./path-normalizer";
3
4
  import type { PermissionsService } from "./service";
4
- import type { SessionRules } from "./session-rules";
5
5
  import type {
6
6
  ToolAccessExtractor,
7
7
  ToolAccessExtractorRegistrar,
@@ -10,18 +10,37 @@ import type {
10
10
  ToolInputFormatter,
11
11
  ToolInputFormatterRegistrar,
12
12
  } from "./tool-input-formatter-registry";
13
+ import type { PermissionCheckResult, PermissionState } from "./types";
14
+
15
+ /**
16
+ * Resolution surface the service needs: answer a gate-style {@link AccessIntent}
17
+ * (composing the session ruleset internally) and report a tool-level state.
18
+ * `PermissionResolver` satisfies it.
19
+ */
20
+ interface ResolverForService {
21
+ resolve(intent: AccessIntent): PermissionCheckResult;
22
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
23
+ }
24
+
25
+ /** Narrow session view: hands out the cwd-bound path normalizer. */
26
+ interface PathNormalizerProvider {
27
+ getPathNormalizer(): PathNormalizer;
28
+ }
13
29
 
14
30
  /**
15
31
  * In-process implementation of the cross-extension {@link PermissionsService}.
16
32
  *
17
33
  * Constructed once in the composition root and backed by the single shared
18
- * `PermissionManager` and `SessionRules` instances that `PermissionSession`
19
- * also uses — so service queries and gate-path approvals see the same state.
34
+ * `PermissionResolver` and `PermissionSession` that the gates also use — so
35
+ * service queries and gate-path decisions see the same state. Path-shaped
36
+ * surface queries route through the resolver as an `access-path` intent, so
37
+ * they match the lexical aliases ∪ canonical (symlink-resolved) set the gates
38
+ * do (#503); non-path surfaces stay on the `tool` intent.
20
39
  */
21
40
  export class LocalPermissionsService implements PermissionsService {
22
41
  constructor(
23
- private readonly permissionManager: ScopedPermissionManager,
24
- private readonly sessionRules: Pick<SessionRules, "getRuleset">,
42
+ private readonly resolver: ResolverForService,
43
+ private readonly session: PathNormalizerProvider,
25
44
  private readonly formatterRegistry: ToolInputFormatterRegistrar,
26
45
  private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
27
46
  ) {}
@@ -31,18 +50,20 @@ export class LocalPermissionsService implements PermissionsService {
31
50
  value?: string,
32
51
  agentName?: string,
33
52
  ): ReturnType<PermissionsService["checkPermission"]> {
34
- const input = buildInputForSurface(surface, value);
35
- return this.permissionManager.check(
36
- { kind: "tool", surface, input, agentName },
37
- this.sessionRules.getRuleset(),
53
+ const intent = buildAccessIntentForSurface(
54
+ surface,
55
+ value,
56
+ this.session.getPathNormalizer(),
57
+ agentName,
38
58
  );
59
+ return this.resolver.resolve(intent);
39
60
  }
40
61
 
41
62
  getToolPermission(
42
63
  toolName: string,
43
64
  agentName?: string,
44
65
  ): ReturnType<PermissionsService["getToolPermission"]> {
45
- return this.permissionManager.getToolPermission(toolName, agentName);
66
+ return this.resolver.getToolPermission(toolName, agentName);
46
67
  }
47
68
 
48
69
  registerToolInputFormatter(
@@ -1,9 +1,6 @@
1
1
  import { dirname } from "node:path";
2
2
 
3
- import {
4
- isPathWithinDirectory,
5
- normalizePathForComparison,
6
- } from "./path-utils";
3
+ import type { PathNormalizer } from "./path-normalizer";
7
4
  import type { PermissionCheckResult, PermissionState } from "./types";
8
5
 
9
6
  /**
@@ -151,24 +148,15 @@ function resolvePermissionState(
151
148
  function createResolvedSkillEntry(
152
149
  entry: ParsedSkillPromptEntry,
153
150
  state: PermissionState,
154
- cwd: string,
155
- platform: NodeJS.Platform,
151
+ normalizer: PathNormalizer,
156
152
  ): SkillPromptEntry {
157
153
  return {
158
154
  name: entry.name,
159
155
  description: entry.description,
160
156
  location: entry.location,
161
157
  state,
162
- normalizedLocation: normalizePathForComparison(
163
- entry.location,
164
- cwd,
165
- platform,
166
- ),
167
- normalizedBaseDir: normalizePathForComparison(
168
- dirname(entry.location),
169
- cwd,
170
- platform,
171
- ),
158
+ normalizedLocation: normalizer.comparableValue(entry.location),
159
+ normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
172
160
  };
173
161
  }
174
162
 
@@ -198,8 +186,7 @@ export function resolveSkillPromptEntries(
198
186
  prompt: string,
199
187
  permissionManager: SkillPermissionChecker,
200
188
  agentName: string | null,
201
- cwd: string,
202
- platform: NodeJS.Platform,
189
+ normalizer: PathNormalizer,
203
190
  ): { prompt: string; entries: SkillPromptEntry[] } {
204
191
  const sections = parseAllSkillPromptSections(prompt);
205
192
  if (sections.length === 0) {
@@ -219,7 +206,7 @@ export function resolveSkillPromptEntries(
219
206
  agentName,
220
207
  permissionCache,
221
208
  );
222
- return createResolvedSkillEntry(entry, state, cwd, platform);
209
+ return createResolvedSkillEntry(entry, state, normalizer);
223
210
  });
224
211
 
225
212
  const visibleSectionEntries = resolvedEntries.filter(
@@ -267,7 +254,7 @@ export function resolveSkillPromptEntries(
267
254
  export function findSkillPathMatch(
268
255
  normalizedPath: string,
269
256
  entries: readonly SkillPromptEntry[],
270
- platform: NodeJS.Platform,
257
+ normalizer: PathNormalizer,
271
258
  ): SkillPromptEntry | null {
272
259
  if (!normalizedPath || entries.length === 0) {
273
260
  return null;
@@ -286,7 +273,7 @@ export function findSkillPathMatch(
286
273
  for (const entry of entries) {
287
274
  if (
288
275
  !entry.normalizedBaseDir ||
289
- !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir, platform)
276
+ !normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)
290
277
  ) {
291
278
  continue;
292
279
  }
@@ -375,7 +375,7 @@ describe("BashProgram", () => {
375
375
  const symlinkCwd = "/private/tmp";
376
376
  realpathSync.mockImplementation((p: string) => {
377
377
  if (p === "/tmp") return "/private/tmp";
378
- if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
378
+ if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
379
379
  return p;
380
380
  });
381
381
  const program = await BashProgram.parse(
@@ -114,6 +114,40 @@ describe("classifyTokenAsPathCandidate", () => {
114
114
  expect(classifyTokenAsPathCandidate("./build")).toBeNull();
115
115
  });
116
116
  });
117
+
118
+ describe("Windows drive-letter acceptance gate", () => {
119
+ test("forward-slash drive path → returned as-is", () => {
120
+ expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
121
+ "C:/Windows/win.ini",
122
+ );
123
+ expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
124
+ "D:/secrets/password.txt",
125
+ );
126
+ });
127
+
128
+ test("backslash drive path → returned as-is", () => {
129
+ expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
130
+ "C:\\Windows\\win.ini",
131
+ );
132
+ expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
133
+ "D:\\secrets\\password.txt",
134
+ );
135
+ });
136
+
137
+ test("lowercase drive letter → returned as-is", () => {
138
+ expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
139
+ });
140
+
141
+ test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
142
+ // c:// matches URL_PATTERN before the drive-letter check runs.
143
+ expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
144
+ });
145
+
146
+ test("drive-relative path without separator (C:foo) → null", () => {
147
+ // No / or \ after the colon — not an absolute drive path per node:path.
148
+ expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
149
+ });
150
+ });
117
151
  });
118
152
 
119
153
  describe("classifyTokenAsRuleCandidate", () => {
@@ -204,6 +238,35 @@ describe("classifyTokenAsRuleCandidate", () => {
204
238
  });
205
239
  });
206
240
 
241
+ describe("Windows drive-letter acceptance gate", () => {
242
+ test("forward-slash drive path → returned as-is", () => {
243
+ // Forward-slash form was already accepted via token.includes("/").
244
+ // The explicit branch makes it first-class and order-independent.
245
+ expect(classifyTokenAsRuleCandidate("C:/Windows/win.ini")).toBe(
246
+ "C:/Windows/win.ini",
247
+ );
248
+ });
249
+
250
+ test("backslash drive path → returned as-is (new: no forward slash)", () => {
251
+ // Previously dropped by both classifiers; the backslash form has no /
252
+ // so the includes("/") branch could not catch it.
253
+ expect(classifyTokenAsRuleCandidate("D:\\secrets\\password.txt")).toBe(
254
+ "D:\\secrets\\password.txt",
255
+ );
256
+ expect(classifyTokenAsRuleCandidate("C:\\Windows\\win.ini")).toBe(
257
+ "C:\\Windows\\win.ini",
258
+ );
259
+ });
260
+
261
+ test("lowercase drive letter (backslash) → returned as-is", () => {
262
+ expect(classifyTokenAsRuleCandidate("c:\\foo")).toBe("c:\\foo");
263
+ });
264
+
265
+ test("drive-relative path without separator (C:foo) → null", () => {
266
+ expect(classifyTokenAsRuleCandidate("C:foo")).toBeNull();
267
+ });
268
+ });
269
+
207
270
  describe("rule-vs-path divergence", () => {
208
271
  const dotFiles = [".env", ".gitignore", ".eslintrc"];
209
272
  const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
@@ -230,6 +293,18 @@ describe("classifyTokenAsRuleCandidate", () => {
230
293
  });
231
294
  }
232
295
 
296
+ const winDrivePaths = [
297
+ "C:/Windows/win.ini",
298
+ "D:\\secrets\\password.txt",
299
+ "c:/foo",
300
+ ];
301
+ for (const tok of winDrivePaths) {
302
+ test(`Windows drive path "${tok}": both classifiers accept`, () => {
303
+ expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
304
+ expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
305
+ });
306
+ }
307
+
233
308
  const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
234
309
  for (const tok of sharedRejected) {
235
310
  test(`"${tok}": both classifiers reject`, () => {
@@ -953,6 +953,44 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
953
953
  });
954
954
  });
955
955
 
956
+ describe("Windows drive-letter paths (win32 semantics)", () => {
957
+ const windowsCwd = "C:/projects/app";
958
+
959
+ async function extractWin32(command: string): Promise<string[]> {
960
+ return extractWithNormalizer(
961
+ command,
962
+ new PathNormalizer("win32", windowsCwd),
963
+ );
964
+ }
965
+
966
+ test("forward-slash drive path outside CWD is flagged", async () => {
967
+ const result = await extractWin32("cat C:/Windows/win.ini");
968
+ expect(result).not.toHaveLength(0);
969
+ });
970
+
971
+ test("different drive letter outside CWD is flagged", async () => {
972
+ const result = await extractWin32("cat D:/secrets/password.txt");
973
+ expect(result).not.toHaveLength(0);
974
+ });
975
+
976
+ test("drive path inside CWD is not flagged (known base)", async () => {
977
+ const result = await extractWin32("cat C:/projects/app/inside.txt");
978
+ expect(result).toHaveLength(0);
979
+ });
980
+
981
+ test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
982
+ // Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
983
+ // check treats C:/ as relative on win32, so the unknown-base conservative
984
+ // branch fires and over-flags an inside-CWD drive path.
985
+ // After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
986
+ // and routes to the resolved branch with its inside-CWD check.
987
+ const result = await extractWin32(
988
+ 'cd "$D" && cat C:/projects/app/inside.txt',
989
+ );
990
+ expect(result).toHaveLength(0);
991
+ });
992
+ });
993
+
956
994
  describe("bash external-directory denial messages (centralized)", () => {
957
995
  test("denial message includes command, paths, and extension tag", () => {
958
996
  const result = formatDenyReason({
@@ -479,6 +479,28 @@ describe("single source of truth for session state", () => {
479
479
  });
480
480
  });
481
481
 
482
+ describe("service path queries evaluate the supplied path (#503)", () => {
483
+ // Before #503 the service path query dropped the value (buildInputForSurface
484
+ // returned {} for the `path` surface), so the query collapsed to ["*"] and a
485
+ // path-specific rule never fired. The query now builds an AccessPath, so the
486
+ // supplied path flows through the resolver → manager and matches `path` rules
487
+ // end-to-end.
488
+ it("resolves a path-surface query against a deny rule on the supplied path", async () => {
489
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-svc-path-cwd-"));
490
+ const target = join(cwd, "secrets.env");
491
+ writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
492
+
493
+ const pi = makeFakePi({ events: createEventBus() });
494
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
495
+ await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
496
+
497
+ const result = getPermissionsService()!.checkPermission("path", target);
498
+ expect(result.state).toBe("deny");
499
+
500
+ rmSync(cwd, { recursive: true, force: true });
501
+ });
502
+ });
503
+
482
504
  describe("multi-instance global service interplay", () => {
483
505
  // The fix (#302) scopes the process-global service slot to the publishing
484
506
  // instance. The parent publishes at its session_start; an in-process child
@@ -89,7 +89,6 @@ describe("external_directory symlink acceptance (#418)", () => {
89
89
  [],
90
90
  resolver,
91
91
  new PathNormalizer(process.platform, cwd),
92
- "linux",
93
92
  );
94
93
  expect(isGateDescriptor(result)).toBe(true);
95
94
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
@@ -113,7 +112,6 @@ describe("external_directory symlink acceptance (#418)", () => {
113
112
  [],
114
113
  resolver,
115
114
  new PathNormalizer(process.platform, cwd),
116
- "linux",
117
115
  );
118
116
  expect(isGateDescriptor(result)).toBe(true);
119
117
  expect((result as GateDescriptor).preCheck?.state).toBe("allow");
@@ -132,7 +130,6 @@ describe("external_directory symlink acceptance (#418)", () => {
132
130
  [],
133
131
  resolver,
134
132
  new PathNormalizer(process.platform, cwd),
135
- "linux",
136
133
  );
137
134
  expect(isGateDescriptor(result)).toBe(true);
138
135
  expect((result as GateDescriptor).preCheck?.state).toBe("ask");