@gotgenes/pi-permission-system 17.0.0 → 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 (58) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-path.ts +5 -5
  4. package/src/access-intent/bash/bash-path-resolver.ts +531 -0
  5. package/src/access-intent/bash/program.ts +21 -14
  6. package/src/access-intent/bash/token-classification.ts +1 -1
  7. package/src/canonicalize-path.ts +11 -5
  8. package/src/forwarded-permissions/permission-forwarder.ts +11 -1
  9. package/src/forwarding-manager.ts +7 -1
  10. package/src/handlers/before-agent-start.ts +1 -0
  11. package/src/handlers/gates/bash-path-extractor.ts +7 -5
  12. package/src/handlers/gates/external-directory.ts +14 -15
  13. package/src/handlers/gates/path.ts +3 -2
  14. package/src/handlers/gates/skill-read.ts +7 -1
  15. package/src/handlers/gates/tool-call-gate-pipeline.ts +21 -4
  16. package/src/handlers/gates/tool.ts +4 -2
  17. package/src/index.ts +12 -1
  18. package/src/input-normalizer.ts +9 -4
  19. package/src/path-normalizer.ts +70 -0
  20. package/src/path-utils.ts +39 -28
  21. package/src/permission-manager.ts +21 -7
  22. package/src/permission-session.ts +35 -2
  23. package/src/prompting-gateway.ts +3 -0
  24. package/src/rule.ts +8 -6
  25. package/src/skill-prompt-sanitizer.ts +15 -4
  26. package/src/subagent-context.ts +17 -9
  27. package/test/access-intent/access-path.test.ts +73 -24
  28. package/test/access-intent/bash/program.test.ts +131 -65
  29. package/test/bash-external-directory.test.ts +15 -1
  30. package/test/canonicalize-path.test.ts +34 -8
  31. package/test/forwarding-manager.test.ts +7 -1
  32. package/test/handlers/external-directory-symlink-acceptance.test.ts +26 -4
  33. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -1
  34. package/test/handlers/gates/bash-external-directory.test.ts +5 -1
  35. package/test/handlers/gates/bash-path.test.ts +6 -1
  36. package/test/handlers/gates/external-directory-policy.test.ts +26 -8
  37. package/test/handlers/gates/external-directory.test.ts +9 -1
  38. package/test/handlers/gates/path.test.ts +53 -14
  39. package/test/handlers/gates/skill-read.test.ts +22 -13
  40. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -1
  41. package/test/handlers/gates/tool.test.ts +13 -1
  42. package/test/helpers/gate-fixtures.ts +10 -0
  43. package/test/helpers/session-fixtures.ts +3 -0
  44. package/test/input-normalizer.test.ts +104 -39
  45. package/test/path-normalizer.test.ts +123 -0
  46. package/test/path-utils.test.ts +130 -51
  47. package/test/permission-forwarder.test.ts +1 -0
  48. package/test/permission-manager-unified.test.ts +40 -0
  49. package/test/permission-resolver.test.ts +12 -3
  50. package/test/permission-session.test.ts +41 -0
  51. package/test/pi-infrastructure-read.test.ts +27 -4
  52. package/test/prompting-gateway.test.ts +1 -0
  53. package/test/rule.test.ts +88 -42
  54. package/test/session-rules.test.ts +21 -4
  55. package/test/skill-prompt-sanitizer.test.ts +69 -14
  56. package/test/subagent-context.test.ts +77 -31
  57. package/test/synthesize.test.ts +13 -11
  58. package/src/access-intent/bash/cwd-projection.ts +0 -500
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { posix as posixPath, win32 as winPath } from "node:path";
3
3
 
4
4
  /**
5
5
  * Resolve symlinks in an absolute path, best-effort.
@@ -11,16 +11,22 @@ import { join } from "node:path";
11
11
  * encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
12
12
  * containment for paths that cannot be resolved.
13
13
  */
14
- export function canonicalizePath(absolutePath: string): string {
14
+ export function canonicalizePath(
15
+ absolutePath: string,
16
+ platform: NodeJS.Platform,
17
+ ): string {
15
18
  if (!absolutePath) return absolutePath;
16
19
 
17
- const parts = absolutePath.split("/").filter(Boolean);
20
+ const impl = platform === "win32" ? winPath : posixPath;
21
+ const root = impl.parse(absolutePath).root;
22
+ const rest = absolutePath.slice(root.length);
23
+ const parts = rest.split(impl.sep).filter(Boolean);
18
24
  for (let i = parts.length; i >= 0; i--) {
19
- const candidate = "/" + parts.slice(0, i).join("/");
25
+ const candidate = root + parts.slice(0, i).join(impl.sep);
20
26
  try {
21
27
  const real = realpathSync(candidate);
22
28
  const tail = parts.slice(i);
23
- return tail.length === 0 ? real : join(real, ...tail);
29
+ return tail.length === 0 ? real : impl.join(real, ...tail);
24
30
  } catch (error) {
25
31
  const code = (error as NodeJS.ErrnoException).code;
26
32
  if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
@@ -77,6 +77,8 @@ export interface ForwarderContext {
77
77
  export interface PermissionForwarderDeps {
78
78
  forwardingDir: string;
79
79
  subagentSessionsDir: string;
80
+ /** Host platform, injected from the composition root, for subagent-context path detection. */
81
+ platform: NodeJS.Platform;
80
82
  /** In-process subagent session registry for detection and forwarding target resolution. */
81
83
  registry?: SubagentSessionRegistry;
82
84
  /** Event bus used for UI prompt broadcasts. */
@@ -185,6 +187,7 @@ export interface InboxProcessor {
185
187
  export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
186
188
  private readonly forwardingDir: string;
187
189
  private readonly subagentSessionsDir: string;
190
+ private readonly platform: NodeJS.Platform;
188
191
  private readonly registry: SubagentSessionRegistry | undefined;
189
192
  private readonly events: PermissionEventBus | undefined;
190
193
  private readonly logger: DebugReviewLogger;
@@ -199,6 +202,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
199
202
  constructor(deps: PermissionForwarderDeps) {
200
203
  this.forwardingDir = deps.forwardingDir;
201
204
  this.subagentSessionsDir = deps.subagentSessionsDir;
205
+ this.platform = deps.platform;
202
206
  this.registry = deps.registry;
203
207
  this.events = deps.events;
204
208
  this.logger = deps.logger;
@@ -228,7 +232,12 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
228
232
  }
229
233
 
230
234
  if (
231
- !isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
235
+ !isSubagentExecutionContext(
236
+ ctx,
237
+ this.subagentSessionsDir,
238
+ this.platform,
239
+ this.registry,
240
+ )
232
241
  ) {
233
242
  return Promise.resolve({ approved: false, state: "denied" });
234
243
  }
@@ -307,6 +316,7 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
307
316
  isSubagent: isSubagentExecutionContext(
308
317
  ctx,
309
318
  this.subagentSessionsDir,
319
+ this.platform,
310
320
  this.registry,
311
321
  ),
312
322
  currentSessionId: requesterSessionId,
@@ -30,6 +30,7 @@ export class ForwardingManager {
30
30
  constructor(
31
31
  private readonly subagentSessionsDir: string,
32
32
  private readonly forwarder: InboxProcessor,
33
+ private readonly platform: NodeJS.Platform,
33
34
  private readonly registry?: SubagentSessionRegistry,
34
35
  ) {}
35
36
 
@@ -42,7 +43,12 @@ export class ForwardingManager {
42
43
  start(ctx: ExtensionContext): void {
43
44
  if (
44
45
  !ctx.hasUI ||
45
- isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
46
+ isSubagentExecutionContext(
47
+ ctx,
48
+ this.subagentSessionsDir,
49
+ this.platform,
50
+ this.registry,
51
+ )
46
52
  ) {
47
53
  this.stop();
48
54
  return;
@@ -85,6 +85,7 @@ export class AgentPrepHandler {
85
85
  this.resolver,
86
86
  agentName,
87
87
  ctx.cwd,
88
+ this.session.getPlatform(),
88
89
  );
89
90
  this.session.setActiveSkillEntries(skillPromptResult.entries);
90
91
  return skillPromptResult.prompt !== event.systemPrompt
@@ -1,21 +1,23 @@
1
1
  import { BashProgram } from "#src/access-intent/bash/program";
2
+ import type { PathNormalizer } from "#src/path-normalizer";
2
3
 
3
4
  /**
4
- * Extract paths from a bash command string that resolve outside CWD.
5
+ * Extract paths from a bash command that resolve outside CWD.
5
6
  *
6
- * Thin facade over {@link BashProgram.externalPaths}; parses the command and
7
+ * Thin facade over {@link BashProgram.externalPaths}; parses the command
8
+ * through the injected {@link PathNormalizer} (platform + cwd baked in) and
7
9
  * returns the cd-aware external paths in their lexical (as-typed) string form.
8
10
  * See `BashProgram` for the parsing and resolution semantics.
9
11
  *
10
12
  * Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
11
13
  * test suite in `bash-external-directory.test.ts` can assert path values
12
- * without migrating every call site.
14
+ * without migrating to the `AccessPath` accessors.
13
15
  */
14
16
  export async function extractExternalPathsFromBashCommand(
15
17
  command: string,
16
- cwd: string,
18
+ normalizer: PathNormalizer,
17
19
  ): Promise<string[]> {
18
- return (await BashProgram.parse(command, cwd))
20
+ return (await BashProgram.parse(command, normalizer))
19
21
  .externalPaths()
20
22
  .map((p) => p.value());
21
23
  }
@@ -1,10 +1,5 @@
1
- import { AccessPath } from "#src/access-intent/access-path";
2
- import {
3
- getToolInputPath,
4
- isPathOutsideWorkingDirectory,
5
- isPiInfrastructureRead,
6
- normalizePathForComparison,
7
- } from "#src/path-utils";
1
+ import type { PathNormalizer } from "#src/path-normalizer";
2
+ import { getToolInputPath, isPiInfrastructureRead } from "#src/path-utils";
8
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
9
4
  import { SessionApproval } from "#src/session-approval";
10
5
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -26,6 +21,8 @@ export function describeExternalDirectoryGate(
26
21
  tcc: ToolCallContext,
27
22
  infraDirs: string[],
28
23
  resolver: ScopedPermissionResolver,
24
+ normalizer: PathNormalizer,
25
+ platform: NodeJS.Platform,
29
26
  extractors?: ToolAccessExtractorLookup,
30
27
  ): GateResult {
31
28
  const externalDirectoryPath = getToolInputPath(
@@ -35,21 +32,25 @@ export function describeExternalDirectoryGate(
35
32
  );
36
33
  if (!externalDirectoryPath) return null;
37
34
 
38
- if (!isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd)) {
35
+ if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
39
36
  return null;
40
37
  }
41
38
 
42
39
  // The boundary decision (above) and the infrastructure-read containment
43
40
  // check (below) use the canonical, symlink-resolved path; pattern matching
44
41
  // uses the typed and resolved aliases (#418).
45
- const accessPath = AccessPath.forPath(externalDirectoryPath, {
46
- cwd: tcc.cwd,
47
- });
42
+ const accessPath = normalizer.forPath(externalDirectoryPath);
48
43
  const canonicalExtPath = accessPath.boundaryValue();
49
44
 
50
45
  // ── Pi infrastructure read bypass ──────────────────────────────────────
51
46
  if (
52
- isPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd)
47
+ isPiInfrastructureRead(
48
+ tcc.toolName,
49
+ canonicalExtPath,
50
+ infraDirs,
51
+ tcc.cwd,
52
+ platform,
53
+ )
53
54
  ) {
54
55
  return {
55
56
  action: "allow",
@@ -89,9 +90,7 @@ export function describeExternalDirectoryGate(
89
90
  resolver,
90
91
  tcc.agentName ?? undefined,
91
92
  );
92
- const pattern = deriveApprovalPattern(
93
- normalizePathForComparison(externalDirectoryPath, tcc.cwd),
94
- );
93
+ const pattern = deriveApprovalPattern(accessPath.value());
95
94
 
96
95
  return {
97
96
  surface: "external_directory",
@@ -1,4 +1,4 @@
1
- import { AccessPath } from "#src/access-intent/access-path";
1
+ import type { PathNormalizer } from "#src/path-normalizer";
2
2
  import { getToolInputPath } from "#src/path-utils";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { SessionApproval } from "#src/session-approval";
@@ -18,6 +18,7 @@ import type { ToolCallContext } from "./types";
18
18
  export function describePathGate(
19
19
  tcc: ToolCallContext,
20
20
  resolver: ScopedPermissionResolver,
21
+ normalizer: PathNormalizer,
21
22
  extractors?: ToolAccessExtractorLookup,
22
23
  ): GateResult {
23
24
  const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
@@ -26,7 +27,7 @@ export function describePathGate(
26
27
  // Emit an access-path intent so the resolver matches the lexical aliases
27
28
  // *and* the canonical (symlink-resolved) form, the same set
28
29
  // `external_directory` matches (#418, #486).
29
- const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
30
+ const accessPath = normalizer.forPath(filePath);
30
31
  const check = resolver.resolve({
31
32
  kind: "access-path",
32
33
  surface: "path",
@@ -15,6 +15,7 @@ import type { ToolCallContext } from "./types";
15
15
  */
16
16
  export function describeSkillReadGate(
17
17
  tcc: ToolCallContext,
18
+ platform: NodeJS.Platform,
18
19
  getActiveSkillEntries: () => SkillPromptEntry[],
19
20
  ): GateDescriptor | null {
20
21
  const activeSkillEntries = getActiveSkillEntries();
@@ -29,10 +30,15 @@ export function describeSkillReadGate(
29
30
  return null;
30
31
  }
31
32
 
32
- const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
33
+ const normalizedReadPath = normalizePathForComparison(
34
+ path,
35
+ tcc.cwd,
36
+ platform,
37
+ );
33
38
  const matchedSkill = findSkillPathMatch(
34
39
  normalizedReadPath,
35
40
  activeSkillEntries,
41
+ platform,
36
42
  );
37
43
 
38
44
  if (!matchedSkill) {
@@ -1,4 +1,5 @@
1
1
  import { BashProgram } from "#src/access-intent/bash/program";
2
+ import type { PathNormalizer } from "#src/path-normalizer";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
5
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
@@ -36,6 +37,10 @@ export interface ToolCallGateInputs {
36
37
  getInfrastructureReadDirs(): string[];
37
38
  /** Resolved tool-preview formatter options from the current config. */
38
39
  getToolPreviewLimits(): ToolPreviewFormatterOptions;
40
+ /** The session's path normalizer (platform + cwd baked in). */
41
+ getPathNormalizer(): PathNormalizer;
42
+ /** The host platform injected at the composition root. */
43
+ getPlatform(): NodeJS.Platform;
39
44
  }
40
45
 
41
46
  /**
@@ -64,9 +69,10 @@ export class ToolCallGatePipeline {
64
69
  // Parse the bash command exactly once per evaluate; the three bash gates
65
70
  // share this single BashProgram instead of each re-parsing (#308).
66
71
  const command = getNonEmptyString(toRecord(tcc.input).command);
72
+ const normalizer = this.inputs.getPathNormalizer();
67
73
  const bashProgram =
68
74
  tcc.toolName === "bash" && command
69
- ? await BashProgram.parse(command, tcc.cwd)
75
+ ? await BashProgram.parse(command, normalizer)
70
76
  : null;
71
77
 
72
78
  const formatter = new ToolPreviewFormatter(
@@ -75,16 +81,22 @@ export class ToolCallGatePipeline {
75
81
  );
76
82
 
77
83
  const infraDirs = this.inputs.getInfrastructureReadDirs();
84
+ const platform = this.inputs.getPlatform();
78
85
 
79
86
  const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
80
87
  () =>
81
- describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
82
- () => describePathGate(tcc, this.resolver, this.customExtractors),
88
+ describeSkillReadGate(tcc, platform, () =>
89
+ this.inputs.getActiveSkillEntries(),
90
+ ),
91
+ () =>
92
+ describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
83
93
  () =>
84
94
  describeExternalDirectoryGate(
85
95
  tcc,
86
96
  infraDirs,
87
97
  this.resolver,
98
+ normalizer,
99
+ platform,
88
100
  this.customExtractors,
89
101
  ),
90
102
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
@@ -108,7 +120,12 @@ export class ToolCallGatePipeline {
108
120
  input: tcc.input,
109
121
  agentName: tcc.agentName ?? undefined,
110
122
  });
111
- const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
123
+ const toolDescriptor = describeToolGate(
124
+ tcc,
125
+ toolCheck,
126
+ formatter,
127
+ platform,
128
+ );
112
129
  toolDescriptor.preCheck = toolCheck;
113
130
  return toolDescriptor;
114
131
  },
@@ -23,12 +23,13 @@ import type { ToolCallContext } from "./types";
23
23
  function deriveSuggestionValue(
24
24
  tcc: ToolCallContext,
25
25
  check: PermissionCheckResult,
26
+ platform: NodeJS.Platform,
26
27
  ): string {
27
28
  if (tcc.toolName === "bash") return check.command ?? "";
28
29
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
29
30
  const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
31
  if (path === null) return "*";
31
- return normalizePathForComparison(path, tcc.cwd);
32
+ return normalizePathForComparison(path, tcc.cwd, platform);
32
33
  }
33
34
 
34
35
  /**
@@ -41,6 +42,7 @@ export function describeToolGate(
41
42
  tcc: ToolCallContext,
42
43
  check: PermissionCheckResult,
43
44
  formatter: ToolPreviewFormatter,
45
+ platform: NodeJS.Platform,
44
46
  ): GateDescriptor {
45
47
  const permissionLogContext = formatter.getPermissionLogContext(
46
48
  check,
@@ -51,7 +53,7 @@ export function describeToolGate(
51
53
  // Compute session approval suggestion for the "for this session" option.
52
54
  const suggestion = suggestSessionPattern(
53
55
  tcc.toolName,
54
- deriveSuggestionValue(tcc, check),
56
+ deriveSuggestionValue(tcc, check, platform),
55
57
  );
56
58
 
57
59
  const askMessage = formatAskPrompt(
package/src/index.ts CHANGED
@@ -42,7 +42,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
42
42
  // getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
43
43
  // so the agent can read Pi's bundled docs/examples regardless of layout.
44
44
  const paths = computeExtensionPaths(agentDir, getPackageDir());
45
- const permissionManager = new PermissionManager({ agentDir });
45
+ // The single process.platform read for the whole extension; injected into the
46
+ // session (PathNormalizer) and, later, rule evaluation. Interior modules must
47
+ // not read process.platform (enforced by the eslint guard scoped to src/).
48
+ const hostPlatform = process.platform;
49
+ const permissionManager = new PermissionManager({
50
+ agentDir,
51
+ platform: hostPlatform,
52
+ });
46
53
  const sessionRules = new SessionRules();
47
54
  const subagentRegistry = getSubagentSessionRegistry();
48
55
  const formatterRegistry = new ToolInputFormatterRegistry();
@@ -73,6 +80,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
73
80
  const forwardingDeps: PermissionForwarderDeps = {
74
81
  forwardingDir: paths.forwardingDir,
75
82
  subagentSessionsDir: paths.subagentSessionsDir,
83
+ platform: hostPlatform,
76
84
  registry: subagentRegistry,
77
85
  events: pi.events,
78
86
  logger,
@@ -91,6 +99,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
91
99
  const gateway = new PromptingGateway({
92
100
  config: configStore,
93
101
  subagentSessionsDir: paths.subagentSessionsDir,
102
+ platform: hostPlatform,
94
103
  registry: subagentRegistry,
95
104
  prompter,
96
105
  });
@@ -100,12 +109,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
100
109
  new ForwardingManager(
101
110
  paths.subagentSessionsDir,
102
111
  forwarder,
112
+ hostPlatform,
103
113
  subagentRegistry,
104
114
  ),
105
115
  permissionManager,
106
116
  sessionRules,
107
117
  configStore,
108
118
  gateway,
119
+ hostPlatform,
109
120
  );
110
121
 
111
122
  // refresh() must run after `session` is assigned: a debug-write IO failure
@@ -66,13 +66,14 @@ export function normalizeInput(
66
66
  toolName: string,
67
67
  input: unknown,
68
68
  configuredMcpServerNames: readonly string[],
69
+ platform: NodeJS.Platform,
69
70
  cwd?: string,
70
71
  ): NormalizedInput {
71
72
  // --- Special surfaces (path, external_directory) ---
72
73
  if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
73
74
  return {
74
75
  surface: toolName,
75
- values: normalizePathSurfaceValues(input, cwd),
76
+ values: normalizePathSurfaceValues(input, platform, cwd),
76
77
  resultExtras: {},
77
78
  };
78
79
  }
@@ -123,7 +124,7 @@ export function normalizeInput(
123
124
  if (PATH_BEARING_TOOLS.has(toolName)) {
124
125
  return {
125
126
  surface: toolName,
126
- values: normalizePathSurfaceValues(input, cwd),
127
+ values: normalizePathSurfaceValues(input, platform, cwd),
127
128
  resultExtras: {},
128
129
  };
129
130
  }
@@ -149,9 +150,13 @@ export function normalizeInput(
149
150
  * Only `input.path` is read — policy values are never sourced from any other
150
151
  * (potentially attacker-controlled) field on the raw tool input.
151
152
  */
152
- function normalizePathSurfaceValues(input: unknown, cwd?: string): string[] {
153
+ function normalizePathSurfaceValues(
154
+ input: unknown,
155
+ platform: NodeJS.Platform,
156
+ cwd?: string,
157
+ ): string[] {
153
158
  const path = getNonEmptyString(toRecord(input).path);
154
159
  if (path === null) return ["*"];
155
- const values = getPathPolicyValues(path, cwd ? { cwd } : {});
160
+ const values = getPathPolicyValues(path, cwd ? { cwd } : {}, platform);
156
161
  return values.length > 0 ? values : ["*"];
157
162
  }
@@ -0,0 +1,70 @@
1
+ import { posix as posixPath, win32 as winPath } from "node:path";
2
+
3
+ import { AccessPath } from "./access-intent/access-path";
4
+ import {
5
+ isPathOutsideWorkingDirectory,
6
+ isPathWithinDirectory,
7
+ } from "./path-utils";
8
+
9
+ /**
10
+ * Path-interpretation collaborator, constructed once at the session edge with
11
+ * the two ambient inputs — the host `platform` and the session `cwd` — baked
12
+ * in, and handed raw path tokens thereafter.
13
+ *
14
+ * The bash path pipeline and the per-tool/external-directory gates ask this
15
+ * object the platform-dependent questions ("is this path absolute *under our
16
+ * platform*?", "resolve this `cd` offset *against our cwd*") and receive
17
+ * prepared {@link AccessPath} values, instead of reading `process.platform`
18
+ * ambiently or threading `cwd` through every call. Internally it selects the
19
+ * `win32`/`posix` path flavor once and delegates to the platform-parameterized
20
+ * `path-utils` / `AccessPath` primitives.
21
+ */
22
+ export class PathNormalizer {
23
+ private readonly impl: typeof posixPath;
24
+
25
+ constructor(
26
+ private readonly platform: NodeJS.Platform,
27
+ private readonly cwd: string,
28
+ ) {
29
+ this.impl = platform === "win32" ? winPath : posixPath;
30
+ }
31
+
32
+ /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
33
+ forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath {
34
+ return AccessPath.forPath(pathValue, {
35
+ cwd: this.cwd,
36
+ resolveBase: options?.resolveBase,
37
+ platform: this.platform,
38
+ });
39
+ }
40
+
41
+ /** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
42
+ forLiteral(literal: string): AccessPath {
43
+ return AccessPath.forLiteral(literal);
44
+ }
45
+
46
+ /** Platform-aware absoluteness (`win32` vs `posix` rules). */
47
+ isAbsolute(pathValue: string): boolean {
48
+ return this.impl.isAbsolute(pathValue);
49
+ }
50
+
51
+ /** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
52
+ resolveBase(offset: string): string {
53
+ return this.impl.resolve(this.cwd, offset);
54
+ }
55
+
56
+ /** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
57
+ joinBase(offset: string, target: string): string {
58
+ return this.impl.join(offset, target);
59
+ }
60
+
61
+ /** Containment of `pathValue` within `directory` (platform-aware). */
62
+ isWithinDirectory(pathValue: string, directory: string): boolean {
63
+ return isPathWithinDirectory(pathValue, directory, this.platform);
64
+ }
65
+
66
+ /** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
67
+ isOutsideWorkingDirectory(pathValue: string): boolean {
68
+ return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
69
+ }
70
+ }
package/src/path-utils.ts CHANGED
@@ -1,11 +1,4 @@
1
- import {
2
- join,
3
- normalize,
4
- posix as posixPath,
5
- relative,
6
- resolve,
7
- win32 as winPath,
8
- } from "node:path";
1
+ import { join, posix as posixPath, win32 as winPath } from "node:path";
9
2
 
10
3
  import { canonicalizePath } from "./canonicalize-path";
11
4
  import { expandHomePath } from "./expand-home";
@@ -16,6 +9,7 @@ import { wildcardMatch } from "./wildcard-matcher";
16
9
  export function normalizePathForComparison(
17
10
  pathValue: string,
18
11
  cwd: string,
12
+ platform: NodeJS.Platform,
19
13
  ): string {
20
14
  const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
21
15
  if (!trimmed) {
@@ -25,9 +19,10 @@ export function normalizePathForComparison(
25
19
  let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
26
20
  normalizedPath = expandHomePath(normalizedPath);
27
21
 
28
- const absolutePath = resolve(cwd, normalizedPath);
29
- const normalizedAbsolutePath = normalize(absolutePath);
30
- return process.platform === "win32"
22
+ const impl = platform === "win32" ? winPath : posixPath;
23
+ const absolutePath = impl.resolve(cwd, normalizedPath);
24
+ const normalizedAbsolutePath = impl.normalize(absolutePath);
25
+ return platform === "win32"
31
26
  ? normalizedAbsolutePath.toLowerCase()
32
27
  : normalizedAbsolutePath;
33
28
  }
@@ -38,13 +33,13 @@ export function normalizePathForComparison(
38
33
  * Containment is decided with Node's platform-native `path.relative` rather
39
34
  * than a hand-rolled prefix check: on `win32` the comparison folds case (and
40
35
  * tolerates either separator), matching the case-insensitive filesystem.
41
- * `platform` defaults to `process.platform` and is injectable so Windows
42
- * behavior is testable on a POSIX CI.
36
+ * `platform` is injected from the composition root so Windows behavior is
37
+ * testable on a POSIX CI.
43
38
  */
44
39
  export function isPathWithinDirectory(
45
40
  pathValue: string,
46
41
  directory: string,
47
- platform: NodeJS.Platform = process.platform,
42
+ platform: NodeJS.Platform,
48
43
  ): boolean {
49
44
  if (!pathValue || !directory) {
50
45
  return false;
@@ -101,46 +96,56 @@ export function normalizePathPolicyLiteral(pathValue: string): string {
101
96
  */
102
97
  export function getPathPolicyValues(
103
98
  pathValue: string,
104
- options: PathPolicyValueOptions = {},
99
+ options: PathPolicyValueOptions,
100
+ platform: NodeJS.Platform,
105
101
  ): string[] {
106
102
  const literal = normalizePathPolicyLiteral(pathValue);
107
103
  if (!literal) return [];
108
104
  if (literal === "*") return ["*"];
109
105
 
110
106
  return [
111
- ...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
107
+ ...new Set([
108
+ ...getAbsolutePathPolicyValues(pathValue, options, platform),
109
+ literal,
110
+ ]),
112
111
  ];
113
112
  }
114
113
 
115
114
  function getAbsolutePathPolicyValues(
116
115
  pathValue: string,
117
116
  options: PathPolicyValueOptions,
117
+ platform: NodeJS.Platform,
118
118
  ): string[] {
119
119
  const resolveBase = options.resolveBase ?? options.cwd;
120
120
  if (!resolveBase) return [];
121
121
 
122
- const absolute = normalizePathForComparison(pathValue, resolveBase);
122
+ const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
123
123
  if (!absolute) return [];
124
124
 
125
- return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
125
+ return [
126
+ absolute,
127
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
128
+ ];
126
129
  }
127
130
 
128
131
  function getCwdRelativePathPolicyValues(
129
132
  absolute: string,
130
133
  cwd: string | undefined,
134
+ platform: NodeJS.Platform,
131
135
  ): string[] {
132
136
  if (!cwd) return [];
133
137
 
134
- const normalizedCwd = normalizePathForComparison(cwd, cwd);
138
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
135
139
  if (!normalizedCwd) return [];
136
140
  if (
137
141
  absolute !== normalizedCwd &&
138
- !isPathWithinDirectory(absolute, normalizedCwd)
142
+ !isPathWithinDirectory(absolute, normalizedCwd, platform)
139
143
  ) {
140
144
  return [];
141
145
  }
142
146
 
143
- const relativeValue = relative(normalizedCwd, absolute);
147
+ const impl = platform === "win32" ? winPath : posixPath;
148
+ const relativeValue = impl.relative(normalizedCwd, absolute);
144
149
  return relativeValue ? [relativeValue] : [];
145
150
  }
146
151
 
@@ -253,26 +258,32 @@ export function getToolInputPath(
253
258
  export function canonicalNormalizePathForComparison(
254
259
  pathValue: string,
255
260
  cwd: string,
261
+ platform: NodeJS.Platform,
256
262
  ): string {
257
- const lexical = normalizePathForComparison(pathValue, cwd);
263
+ const lexical = normalizePathForComparison(pathValue, cwd, platform);
258
264
  if (!lexical) return "";
259
- const canonical = canonicalizePath(lexical);
260
- return process.platform === "win32" ? canonical.toLowerCase() : canonical;
265
+ const canonical = canonicalizePath(lexical, platform);
266
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
261
267
  }
262
268
 
263
269
  export function isPathOutsideWorkingDirectory(
264
270
  pathValue: string,
265
271
  cwd: string,
272
+ platform: NodeJS.Platform,
266
273
  ): boolean {
267
- const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
268
- const normalizedPath = canonicalNormalizePathForComparison(pathValue, cwd);
274
+ const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
275
+ const normalizedPath = canonicalNormalizePathForComparison(
276
+ pathValue,
277
+ cwd,
278
+ platform,
279
+ );
269
280
  if (!normalizedCwd || !normalizedPath) {
270
281
  return false;
271
282
  }
272
283
  if (isSafeSystemPath(normalizedPath)) {
273
284
  return false;
274
285
  }
275
- return !isPathWithinDirectory(normalizedPath, normalizedCwd);
286
+ return !isPathWithinDirectory(normalizedPath, normalizedCwd, platform);
276
287
  }
277
288
 
278
289
  function containsGlobChars(value: string): boolean {
@@ -299,7 +310,7 @@ export function isPiInfrastructureRead(
299
310
  normalizedPath: string,
300
311
  infrastructureDirs: readonly string[],
301
312
  cwd: string,
302
- platform: NodeJS.Platform = process.platform,
313
+ platform: NodeJS.Platform,
303
314
  ): boolean {
304
315
  if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
305
316
  return false;