@gotgenes/pi-permission-system 20.2.0 → 20.4.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 (53) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/docs/cross-extension-api.md +4 -0
  3. package/docs/subagent-integration.md +4 -2
  4. package/package.json +1 -1
  5. package/src/access-intent/access-path.ts +7 -5
  6. package/src/access-intent/bash/bash-path-resolver.ts +1 -2
  7. package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
  8. package/src/access-intent/bash/parser.ts +39 -0
  9. package/src/access-intent/bash/sync-commands.ts +31 -0
  10. package/src/access-intent/bash/token-classification.ts +18 -32
  11. package/src/access-intent/path-normalization.ts +24 -43
  12. package/src/access-intent/tool-kind.ts +57 -0
  13. package/src/authority/approval-escalator.ts +18 -8
  14. package/src/authority/authorizer-selection.ts +1 -1
  15. package/src/authority/authorizer.ts +2 -2
  16. package/src/authority/denying-authorizer.ts +1 -1
  17. package/src/authority/forwarded-request-server.ts +52 -4
  18. package/src/authority/forwarder-context.ts +1 -1
  19. package/src/authority/forwarding-io.ts +30 -3
  20. package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
  21. package/src/authority/local-user-authorizer.ts +27 -2
  22. package/src/{permission-dialog.ts → authority/permission-dialog.ts} +26 -0
  23. package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +22 -2
  24. package/src/authority/permission-prompter.ts +9 -1
  25. package/src/authority/subagent-context.ts +11 -14
  26. package/src/authority/subagent-detection.ts +5 -4
  27. package/src/bash-advisory-check.ts +38 -0
  28. package/src/denial-messages.ts +6 -9
  29. package/src/handlers/before-agent-start.ts +8 -0
  30. package/src/handlers/gates/helpers.ts +12 -4
  31. package/src/handlers/gates/runner.ts +4 -1
  32. package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
  33. package/src/handlers/gates/tool.ts +9 -4
  34. package/src/index.ts +27 -12
  35. package/src/input-normalizer.ts +53 -48
  36. package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
  37. package/src/path/path-containment.ts +24 -0
  38. package/src/path/path-flavor.ts +114 -0
  39. package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
  40. package/src/path-normalizer.ts +35 -59
  41. package/src/pattern-suggest.ts +29 -0
  42. package/src/permission-gate.ts +1 -1
  43. package/src/permission-manager.ts +36 -56
  44. package/src/permission-prompts.ts +4 -3
  45. package/src/permission-session.ts +6 -5
  46. package/src/permissions-service.ts +8 -0
  47. package/src/rule.ts +19 -21
  48. package/src/session-approval.ts +11 -0
  49. package/src/tool-input-path.ts +17 -19
  50. package/src/tool-preview-formatter.ts +2 -5
  51. package/src/path-containment.ts +0 -56
  52. /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
  53. /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
package/src/index.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
+ import { warmBashParser } from "./access-intent/bash/parser";
3
4
  import { AuthorizerSelection } from "./authority/authorizer-selection";
4
5
  import {
5
6
  ForwardedRequestServer,
6
7
  type ServingPolicy,
7
8
  } from "./authority/forwarded-request-server";
9
+ import { ForwardingManager } from "./authority/forwarding-manager";
10
+ import { requestPermissionDecisionFromUi } from "./authority/permission-dialog";
8
11
  import { PermissionPrompter } from "./authority/permission-prompter";
9
12
  import { SubagentDetection } from "./authority/subagent-detection";
13
+ import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
14
+ import { getSubagentSessionRegistry } from "./authority/subagent-registry";
10
15
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
11
16
  import { registerPermissionSystemCommand } from "./config-modal";
12
17
  import { getGlobalConfigPath } from "./config-paths";
@@ -15,7 +20,6 @@ import { DecisionAudit } from "./decision-audit";
15
20
  import { GateDecisionReporter } from "./decision-reporter";
16
21
  import { isYoloModeEnabled } from "./extension-config";
17
22
  import { computeExtensionPaths } from "./extension-paths";
18
- import { ForwardingManager } from "./forwarding-manager";
19
23
  import {
20
24
  AgentPrepHandler,
21
25
  PermissionGateHandler,
@@ -26,7 +30,7 @@ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeli
26
30
  import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
27
31
  import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
28
32
  import { buildAccessIntentForSurface } from "./input-normalizer";
29
- import { requestPermissionDecisionFromUi } from "./permission-dialog";
33
+ import { pathFlavorForPlatform } from "./path/path-flavor";
30
34
  import { PermissionManager } from "./permission-manager";
31
35
  import { PermissionResolver } from "./permission-resolver";
32
36
  import { PermissionSession } from "./permission-session";
@@ -34,8 +38,6 @@ import { LocalPermissionsService } from "./permissions-service";
34
38
  import { PermissionServiceLifecycle } from "./service-lifecycle";
35
39
  import { PermissionSessionLogger } from "./session-logger";
36
40
  import { SessionRules } from "./session-rules";
37
- import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
38
- import { getSubagentSessionRegistry } from "./subagent-registry";
39
41
  import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
40
42
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
41
43
 
@@ -44,17 +46,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
44
46
  // getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
45
47
  // so the agent can read Pi's bundled docs/examples regardless of layout.
46
48
  const paths = computeExtensionPaths(agentDir, getPackageDir());
47
- // The single process.platform read for the whole extension; injected into the
48
- // session (PathNormalizer) and, later, rule evaluation. Interior modules must
49
- // not read process.platform (enforced by the eslint guard scoped to src/).
50
- const hostPlatform = process.platform;
49
+ // The single process.platform read for the whole extension, resolved once
50
+ // into the path-language flavor that every consumer shares (the session's
51
+ // PathNormalizer, rule evaluation, and subagent detection). Interior modules
52
+ // must not read process.platform (enforced by the eslint guard scoped to
53
+ // src/) and never re-derive the win32 flavor — they receive this product.
54
+ const hostFlavor = pathFlavorForPlatform(process.platform);
51
55
  const sessionRules = new SessionRules();
52
56
  const subagentRegistry = getSubagentSessionRegistry();
53
57
  // Single owner of subagent detection, shared across every consumer instead of
54
58
  // threading the (subagentSessionsDir, platform, registry) triple into each.
55
59
  const subagentDetection = new SubagentDetection({
56
60
  subagentSessionsDir: paths.subagentSessionsDir,
57
- platform: hostPlatform,
61
+ flavor: hostFlavor,
58
62
  registry: subagentRegistry,
59
63
  });
60
64
  const formatterRegistry = new ToolInputFormatterRegistry();
@@ -75,7 +79,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
75
79
  // assigned below. yolo becomes a composition-stage ask→allow rewrite (#526).
76
80
  const permissionManager = new PermissionManager({
77
81
  agentDir,
78
- platform: hostPlatform,
82
+ flavor: hostFlavor,
79
83
  isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
80
84
  });
81
85
 
@@ -132,6 +136,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
132
136
  logger,
133
137
  policy: servingPolicy,
134
138
  escalator: authorizerSelection,
139
+ // Records a whole-session grant into the same SessionRules the resolver and
140
+ // gate runner read, so a serving-scope grant governs the parent and future
141
+ // forwarded resolutions.
142
+ recorder: sessionRules,
135
143
  registry: subagentRegistry,
136
144
  });
137
145
 
@@ -142,7 +150,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
142
150
  sessionRules,
143
151
  configStore,
144
152
  authorizerSelection,
145
- hostPlatform,
153
+ hostFlavor,
146
154
  );
147
155
 
148
156
  // refresh() must run after `session` is assigned: a debug-write IO failure
@@ -200,7 +208,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
200
208
  logger,
201
209
  audit,
202
210
  );
203
- const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
211
+ const agentPrep = new AgentPrepHandler(
212
+ session,
213
+ resolver,
214
+ toolRegistry,
215
+ () => {
216
+ void warmBashParser();
217
+ },
218
+ );
204
219
 
205
220
  const reporter = new GateDecisionReporter(logger, pi.events);
206
221
  const gateRunner = new GateRunner(
@@ -1,4 +1,5 @@
1
1
  import type { AccessIntent } from "./access-intent/access-intent";
2
+ import { classifyToolKind } from "./access-intent/tool-kind";
2
3
  import { stripBashCommentLines } from "./bash-arity";
3
4
  import { createMcpPermissionTargets } from "./mcp-targets";
4
5
  import type { PathNormalizer } from "./path-normalizer";
@@ -106,55 +107,59 @@ export function normalizeInput(
106
107
  input: unknown,
107
108
  configuredMcpServerNames: readonly string[],
108
109
  ): NormalizedInput {
109
- // --- Skill ---
110
- if (toolName === "skill") {
111
- const record = toRecord(input);
112
- const skillName = record.name;
113
- const lookupValue = typeof skillName === "string" ? skillName : "*";
114
- return {
115
- surface: "skill",
116
- values: [lookupValue],
117
- resultExtras: {},
118
- };
119
- }
110
+ switch (classifyToolKind(toolName)) {
111
+ // --- Skill ---
112
+ case "skill": {
113
+ const record = toRecord(input);
114
+ const skillName = record.name;
115
+ const lookupValue = typeof skillName === "string" ? skillName : "*";
116
+ return {
117
+ surface: "skill",
118
+ values: [lookupValue],
119
+ resultExtras: {},
120
+ };
121
+ }
120
122
 
121
- // --- Bash ---
122
- if (toolName === "bash") {
123
- const record = toRecord(input);
124
- const command = typeof record.command === "string" ? record.command : "";
125
- // Strip leading shell comment lines so pattern matching operates on the
126
- // actual command, not a `# description` prefix agents often prepend.
127
- // Fall back to the raw command when stripping leaves nothing, so an
128
- // all-comment command still evaluates against its literal text.
129
- const matchValue = stripBashCommentLines(command) || command;
130
- return {
131
- surface: "bash",
132
- values: [matchValue],
133
- resultExtras: { command },
134
- };
135
- }
123
+ // --- Bash ---
124
+ case "bash": {
125
+ const record = toRecord(input);
126
+ const command = typeof record.command === "string" ? record.command : "";
127
+ // Strip leading shell comment lines so pattern matching operates on the
128
+ // actual command, not a `# description` prefix agents often prepend.
129
+ // Fall back to the raw command when stripping leaves nothing, so an
130
+ // all-comment command still evaluates against its literal text.
131
+ const matchValue = stripBashCommentLines(command) || command;
132
+ return {
133
+ surface: "bash",
134
+ values: [matchValue],
135
+ resultExtras: { command },
136
+ };
137
+ }
136
138
 
137
- // --- MCP ---
138
- if (toolName === "mcp") {
139
- const mcpTargets = [
140
- ...createMcpPermissionTargets(input, configuredMcpServerNames),
141
- "mcp",
142
- ];
143
- const fallbackTarget = mcpTargets[0] ?? "mcp";
144
- return {
145
- surface: "mcp",
146
- values: mcpTargets,
147
- resultExtras: { target: fallbackTarget },
148
- };
149
- }
139
+ // --- MCP ---
140
+ case "mcp": {
141
+ const mcpTargets = [
142
+ ...createMcpPermissionTargets(input, configuredMcpServerNames),
143
+ "mcp",
144
+ ];
145
+ const fallbackTarget = mcpTargets[0] ?? "mcp";
146
+ return {
147
+ surface: "mcp",
148
+ values: mcpTargets,
149
+ resultExtras: { target: fallbackTarget },
150
+ };
151
+ }
150
152
 
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.
155
- return {
156
- surface: toolName,
157
- values: ["*"],
158
- resultExtras: {},
159
- };
153
+ // --- All other surfaces (path-bearing tools and extension tools) ---
154
+ // Path-bearing tools with a present path never reach here — the gate emits
155
+ // an access-path intent (#502). Missing-path and extension-tool cases both
156
+ // collapse to the surface catch-all.
157
+ case "path":
158
+ case "extension":
159
+ return {
160
+ surface: toolName,
161
+ values: ["*"],
162
+ resultExtras: {},
163
+ };
164
+ }
160
165
  }
@@ -1,5 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { posix as posixPath, win32 as winPath } from "node:path";
2
+
3
+ import type { PathFlavor } from "#src/path/path-flavor";
3
4
 
4
5
  /**
5
6
  * Resolve symlinks in an absolute path, best-effort.
@@ -13,11 +14,11 @@ import { posix as posixPath, win32 as winPath } from "node:path";
13
14
  */
14
15
  export function canonicalizePath(
15
16
  absolutePath: string,
16
- platform: NodeJS.Platform,
17
+ flavor: PathFlavor,
17
18
  ): string {
18
19
  if (!absolutePath) return absolutePath;
19
20
 
20
- const impl = platform === "win32" ? winPath : posixPath;
21
+ const { impl } = flavor;
21
22
  const root = impl.parse(absolutePath).root;
22
23
  const rest = absolutePath.slice(root.length);
23
24
  const parts = rest.split(impl.sep).filter(Boolean);
@@ -0,0 +1,24 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+ import { isSafeSystemPath } from "#src/safe-system-paths";
3
+
4
+ /**
5
+ * Pure geometry: is `canonicalPath` outside `canonicalCwd`?
6
+ *
7
+ * Both operands must already be canonical (symlink-resolved, win32-lowercased)
8
+ * — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
9
+ * This predicate touches no filesystem and does no derivation; the containment
10
+ * geometry lives on {@link PathFlavor.isWithin}.
11
+ */
12
+ export function isPathOutsideWorkingDirectory(
13
+ canonicalPath: string,
14
+ canonicalCwd: string,
15
+ flavor: PathFlavor,
16
+ ): boolean {
17
+ if (!canonicalCwd || !canonicalPath) {
18
+ return false;
19
+ }
20
+ if (isSafeSystemPath(canonicalPath)) {
21
+ return false;
22
+ }
23
+ return !flavor.isWithin(canonicalPath, canonicalCwd);
24
+ }
@@ -0,0 +1,114 @@
1
+ import type { PlatformPath } from "node:path";
2
+ import { posix as posixPath, win32 as winPath } from "node:path";
3
+
4
+ import {
5
+ type BashTokenShape,
6
+ classifyWin32BashToken,
7
+ } from "#src/access-intent/bash/msys-bash-tokens";
8
+ import type { WildcardMatchOptions } from "#src/wildcard-matcher";
9
+
10
+ /**
11
+ * The resolved product of the single win32-vs-POSIX platform decision: the
12
+ * platform's path *language* as one immutable collaborator.
13
+ *
14
+ * The win32-vs-POSIX difference is not variant growth (the set is closed) but
15
+ * **connascence of algorithm** — every path leaf must re-derive the same
16
+ * mapping identically, and in a permission system a leaf that misses the case
17
+ * fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
18
+ * captures that mapping once so the leaves consume the resolved capability
19
+ * instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
20
+ * **semantics** — syntax ({@link hasPathSeparator}), token shape
21
+ * ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
22
+ * {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
23
+ * (lexical cleanup, alias generation, safe-system-path exclusions, rule
24
+ * dispatch) stays in the functions that consume it.
25
+ */
26
+ export interface PathFlavor {
27
+ /**
28
+ * Node's own platform path strategy (`path.win32` | `path.posix`). Exposed
29
+ * directly — its post-migration consumers are all path-domain primitives and
30
+ * `PlatformPath` is itself a maintained strategy object, so wrapping it would
31
+ * be pure forwarding.
32
+ */
33
+ readonly impl: PlatformPath;
34
+ /**
35
+ * Wildcard match options for path-surface rule matching: the win32
36
+ * case-and-separator fold, or `undefined` on POSIX.
37
+ */
38
+ readonly matchOptions: WildcardMatchOptions | undefined;
39
+ /** Comparison case fold: win32 lowercases, POSIX returns the value unchanged. */
40
+ fold(value: string): string;
41
+ /**
42
+ * Resolve `pathValue` against `base`, normalize, and fold — the single home
43
+ * of the #382 case-fold invariant for absolute comparison values.
44
+ */
45
+ comparable(pathValue: string, base: string): string;
46
+ /** `path.relative`-based containment: is `pathValue` `directory` itself or nested inside it? */
47
+ isWithin(pathValue: string, directory: string): boolean;
48
+ /**
49
+ * True when `token` contains a path separator under this platform: `/` on
50
+ * POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
51
+ */
52
+ hasPathSeparator(token: string): boolean;
53
+ /**
54
+ * The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
55
+ * carries device / drive-mount / posix-absolute / plain semantics; on POSIX
56
+ * every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
57
+ */
58
+ bashTokenShape(token: string): BashTokenShape;
59
+ }
60
+
61
+ class PlatformPathFlavor implements PathFlavor {
62
+ readonly matchOptions: WildcardMatchOptions | undefined;
63
+
64
+ constructor(
65
+ readonly impl: PlatformPath,
66
+ private readonly windows: boolean,
67
+ ) {
68
+ this.matchOptions = windows
69
+ ? { caseInsensitive: true, windowsSeparators: true }
70
+ : undefined;
71
+ }
72
+
73
+ fold(value: string): string {
74
+ return this.windows ? value.toLowerCase() : value;
75
+ }
76
+
77
+ comparable(pathValue: string, base: string): string {
78
+ return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
79
+ }
80
+
81
+ isWithin(pathValue: string, directory: string): boolean {
82
+ if (!pathValue || !directory) return false;
83
+ if (pathValue === directory) return true;
84
+ const rel = this.impl.relative(directory, pathValue);
85
+ return (
86
+ rel !== "" &&
87
+ rel !== ".." &&
88
+ !rel.startsWith(`..${this.impl.sep}`) &&
89
+ !this.impl.isAbsolute(rel)
90
+ );
91
+ }
92
+
93
+ hasPathSeparator(token: string): boolean {
94
+ return token.includes("/") || (this.windows && token.includes("\\"));
95
+ }
96
+
97
+ bashTokenShape(token: string): BashTokenShape {
98
+ return this.windows ? classifyWin32BashToken(token) : { kind: "plain" };
99
+ }
100
+ }
101
+
102
+ export const posixPathFlavor: PathFlavor = new PlatformPathFlavor(
103
+ posixPath,
104
+ false,
105
+ );
106
+ export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
107
+ winPath,
108
+ true,
109
+ );
110
+
111
+ /** The one win32-vs-POSIX platform decision in the package. */
112
+ export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
113
+ return platform === "win32" ? win32PathFlavor : posixPathFlavor;
114
+ }
@@ -1,9 +1,9 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { expandHomePath } from "./expand-home";
4
- import { isPathWithinDirectory } from "./path-containment";
5
- import { READ_ONLY_PATH_BEARING_TOOLS } from "./path-surfaces";
6
- import { wildcardMatch } from "./wildcard-matcher";
3
+ import { expandHomePath } from "#src/expand-home";
4
+ import type { PathFlavor } from "#src/path/path-flavor";
5
+ import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/path-surfaces";
6
+ import { wildcardMatch } from "#src/wildcard-matcher";
7
7
 
8
8
  function containsGlobChars(value: string): boolean {
9
9
  return value.includes("*") || value.includes("?");
@@ -29,35 +29,30 @@ export function isPiInfrastructureRead(
29
29
  normalizedPath: string,
30
30
  infrastructureDirs: readonly string[],
31
31
  cwd: string,
32
- platform: NodeJS.Platform,
32
+ flavor: PathFlavor,
33
33
  ): boolean {
34
34
  if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
35
35
  return false;
36
36
  }
37
37
 
38
- // On Windows the path value is canonicalized + lowercased; fold case (and
39
- // separators) so mixed-case infra dirs and glob patterns still match.
40
- const matchOptions =
41
- platform === "win32"
42
- ? { caseInsensitive: true, windowsSeparators: true }
43
- : undefined;
44
-
38
+ // On Windows the path value is canonicalized + lowercased; the flavor's match
39
+ // options fold case (and separators) so mixed-case infra dirs and glob
40
+ // patterns still match.
45
41
  for (const dir of infrastructureDirs) {
46
42
  if (containsGlobChars(dir)) {
47
- if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
43
+ if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
48
44
  } else {
49
- if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
50
- return true;
45
+ if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
51
46
  }
52
47
  }
53
48
 
54
49
  // Project-local Pi packages — checked fresh every call so CWD changes work.
55
50
  const projectNpmDir = join(cwd, ".pi", "npm");
56
51
  const projectGitDir = join(cwd, ".pi", "git");
57
- if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
52
+ if (flavor.isWithin(normalizedPath, projectNpmDir)) {
58
53
  return true;
59
54
  }
60
- if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
55
+ if (flavor.isWithin(normalizedPath, projectGitDir)) {
61
56
  return true;
62
57
  }
63
58
 
@@ -1,17 +1,13 @@
1
- import { posix as posixPath, win32 as winPath } from "node:path";
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
2
 
3
3
  import { AccessPath } from "./access-intent/access-path";
4
- import { classifyWin32BashToken } from "./access-intent/bash/msys-bash-tokens";
5
4
  import {
6
5
  canonicalNormalizePathForComparison,
7
6
  normalizePathForComparison,
8
7
  normalizePathPolicyLiteral,
9
8
  } from "./access-intent/path-normalization";
10
- import {
11
- isPathOutsideWorkingDirectory,
12
- isPathWithinDirectory,
13
- } from "./path-containment";
14
- import { isPiInfrastructureRead } from "./pi-infrastructure-read";
9
+ import { isPathOutsideWorkingDirectory } from "./path/path-containment";
10
+ import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
15
11
 
16
12
  /**
17
13
  * The interpreted effect of a literal `cd` target on the effective base, under
@@ -31,28 +27,27 @@ export type BashCdTarget =
31
27
 
32
28
  /**
33
29
  * Path-interpretation collaborator, constructed once at the session edge with
34
- * the two ambient inputs — the host `platform` and the session `cwd` — baked
35
- * in, and handed raw path tokens thereafter.
30
+ * the two ambient inputs — the resolved {@link PathFlavor} and the session
31
+ * `cwd` — baked in, and handed raw path tokens thereafter.
36
32
  *
37
33
  * The bash path pipeline and the per-tool/external-directory gates ask this
38
34
  * object the platform-dependent questions ("is this path absolute *under our
39
- * platform*?", "resolve this `cd` offset *against our cwd*") and receive
40
- * prepared {@link AccessPath} values, instead of reading `process.platform`
41
- * ambiently or threading `cwd` through every call. Internally it selects the
42
- * `win32`/`posix` path flavor once and delegates to the platform-parameterized
35
+ * flavor*?", "resolve this `cd` offset *against our cwd*") and receive prepared
36
+ * {@link AccessPath} values, instead of reading `process.platform` ambiently or
37
+ * threading `cwd` through every call. All platform semantics live on the
38
+ * injected `flavor`; this class holds no platform discriminator and no
39
+ * `win32`/`posix` branch — it delegates to `flavor` and the flavor-parameterized
43
40
  * `path-containment` / `path-normalization` / `AccessPath` primitives.
44
41
  */
45
42
  export class PathNormalizer {
46
- private readonly impl: typeof posixPath;
47
43
  /** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
48
44
  private readonly canonicalCwd: string;
49
45
 
50
46
  constructor(
51
- private readonly platform: NodeJS.Platform,
47
+ readonly flavor: PathFlavor,
52
48
  private readonly cwd: string,
53
49
  ) {
54
- this.impl = platform === "win32" ? winPath : posixPath;
55
- this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
50
+ this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor);
56
51
  }
57
52
 
58
53
  /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
@@ -60,7 +55,7 @@ export class PathNormalizer {
60
55
  return AccessPath.forPath(pathValue, {
61
56
  cwd: this.cwd,
62
57
  resolveBase: options?.resolveBase,
63
- platform: this.platform,
58
+ flavor: this.flavor,
64
59
  });
65
60
  }
66
61
 
@@ -74,18 +69,16 @@ export class PathNormalizer {
74
69
  * semantics on a win32 host.
75
70
  *
76
71
  * Pi core always executes bash through Git Bash on Windows, so a POSIX-shaped
77
- * absolute token carries MSYS semantics, not `node:path.win32` semantics. On
78
- * win32 the recognized safe device paths (`/dev/null`, `/dev/std{in,out,err}`)
79
- * are preserved verbatim as devices instead of being resolved into
80
- * `c:\dev\null`, and MSYS drive mounts (`/c/…`) are translated to their
81
- * Windows equivalent (`C:\…`) before resolution; every other token delegates
82
- * to {@link forPath}. On POSIX this is a straight delegation to
83
- * {@link forPath}.
72
+ * absolute token carries MSYS semantics, not `node:path.win32` semantics. The
73
+ * flavor classifies the token's shape: on win32 the recognized safe device
74
+ * paths (`/dev/null`, `/dev/std{in,out,err}`) are preserved verbatim as
75
+ * devices instead of being resolved into `c:\dev\null`, and MSYS drive mounts
76
+ * (`/c/…`) are translated to their Windows equivalent (`C:\…`) before
77
+ * resolution; every other token delegates to {@link forPath}. On POSIX every
78
+ * token is `plain`, so this is a straight delegation to {@link forPath}.
84
79
  */
85
80
  forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
86
- if (this.platform !== "win32") return this.forPath(token, options);
87
-
88
- const shape = classifyWin32BashToken(token);
81
+ const shape = this.flavor.bashTokenShape(token);
89
82
  switch (shape.kind) {
90
83
  case "device":
91
84
  return AccessPath.forDevice(token);
@@ -109,19 +102,7 @@ export class PathNormalizer {
109
102
 
110
103
  /** Platform-aware absoluteness (`win32` vs `posix` rules). */
111
104
  isAbsolute(pathValue: string): boolean {
112
- return this.impl.isAbsolute(pathValue);
113
- }
114
-
115
- /**
116
- * True when the host platform treats a backslash as a path separator (win32).
117
- *
118
- * The bash rule-candidate classifier reads this to decide whether a
119
- * backslash-relative token (`dir\file`) is path-shaped: on win32 `\` is a
120
- * separator, but on POSIX it is a legal filename character (#520). Keeping the
121
- * decision here means no bash-layer module re-reads `process.platform`.
122
- */
123
- usesWindowsSeparators(): boolean {
124
- return this.platform === "win32";
105
+ return this.flavor.impl.isAbsolute(pathValue);
125
106
  }
126
107
 
127
108
  /**
@@ -131,16 +112,11 @@ export class PathNormalizer {
131
112
  * (`cd /c/x`) resolves to a translated Windows base (`C:\x`), a non-mount
132
113
  * POSIX absolute (`cd /tmp`) is not deterministically resolvable and yields an
133
114
  * `unknown` base, and a native/relative target is handled as usual. On POSIX
134
- * an absolute target is absolute and everything else is relative.
115
+ * every token is `plain`, so an absolute target is absolute and everything
116
+ * else is relative.
135
117
  */
136
118
  interpretBashCdTarget(target: string): BashCdTarget {
137
- if (this.platform !== "win32") {
138
- return this.impl.isAbsolute(target)
139
- ? { kind: "absolute", value: target }
140
- : { kind: "relative" };
141
- }
142
-
143
- const shape = classifyWin32BashToken(target);
119
+ const shape = this.flavor.bashTokenShape(target);
144
120
  switch (shape.kind) {
145
121
  case "drive-mount":
146
122
  return { kind: "absolute", value: shape.windowsPath };
@@ -148,7 +124,7 @@ export class PathNormalizer {
148
124
  case "posix-absolute":
149
125
  return { kind: "unknown" };
150
126
  case "plain":
151
- return this.impl.isAbsolute(target)
127
+ return this.flavor.impl.isAbsolute(target)
152
128
  ? { kind: "absolute", value: target }
153
129
  : { kind: "relative" };
154
130
  }
@@ -156,17 +132,17 @@ export class PathNormalizer {
156
132
 
157
133
  /** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
158
134
  resolveBase(offset: string): string {
159
- return this.impl.resolve(this.cwd, offset);
135
+ return this.flavor.impl.resolve(this.cwd, offset);
160
136
  }
161
137
 
162
138
  /** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
163
139
  joinBase(offset: string, target: string): string {
164
- return this.impl.join(offset, target);
140
+ return this.flavor.impl.join(offset, target);
165
141
  }
166
142
 
167
143
  /** Containment of `pathValue` within `directory` (platform-aware). */
168
144
  isWithinDirectory(pathValue: string, directory: string): boolean {
169
- return isPathWithinDirectory(pathValue, directory, this.platform);
145
+ return this.flavor.isWithin(pathValue, directory);
170
146
  }
171
147
 
172
148
  /** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
@@ -174,12 +150,12 @@ export class PathNormalizer {
174
150
  const canonicalPath = canonicalNormalizePathForComparison(
175
151
  pathValue,
176
152
  this.cwd,
177
- this.platform,
153
+ this.flavor,
178
154
  );
179
155
  return isPathOutsideWorkingDirectory(
180
156
  canonicalPath,
181
157
  this.canonicalCwd,
182
- this.platform,
158
+ this.flavor,
183
159
  );
184
160
  }
185
161
 
@@ -196,7 +172,7 @@ export class PathNormalizer {
196
172
  return isPathOutsideWorkingDirectory(
197
173
  canonicalPath,
198
174
  this.canonicalCwd,
199
- this.platform,
175
+ this.flavor,
200
176
  );
201
177
  }
202
178
 
@@ -206,12 +182,12 @@ export class PathNormalizer {
206
182
  * touches no filesystem, unlike {@link forPath}'s canonical alias.
207
183
  */
208
184
  comparableValue(pathValue: string): string {
209
- return normalizePathForComparison(pathValue, this.cwd, this.platform);
185
+ return normalizePathForComparison(pathValue, this.cwd, this.flavor);
210
186
  }
211
187
 
212
188
  /**
213
189
  * Pi infrastructure-read containment for a read-only tool, decided against
214
- * the canonical (symlink-resolved) path and the baked cwd/platform. Takes the
190
+ * the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the
215
191
  * already-built {@link AccessPath} so the caller does not re-resolve it.
216
192
  */
217
193
  isInfrastructureRead(
@@ -224,7 +200,7 @@ export class PathNormalizer {
224
200
  accessPath.boundaryValue(),
225
201
  infraDirs,
226
202
  this.cwd,
227
- this.platform,
203
+ this.flavor,
228
204
  );
229
205
  }
230
206
  }