@gotgenes/pi-permission-system 20.3.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 (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/docs/cross-extension-api.md +4 -0
  3. package/docs/subagent-integration.md +2 -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 +3 -3
  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 +3 -3
  18. package/src/authority/forwarder-context.ts +1 -1
  19. package/src/authority/forwarding-io.ts +3 -3
  20. package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
  21. package/src/authority/local-user-authorizer.ts +2 -2
  22. package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +1 -2
  23. package/src/authority/permission-prompter.ts +2 -2
  24. package/src/authority/subagent-context.ts +11 -14
  25. package/src/authority/subagent-detection.ts +5 -4
  26. package/src/bash-advisory-check.ts +38 -0
  27. package/src/denial-messages.ts +6 -9
  28. package/src/handlers/before-agent-start.ts +8 -0
  29. package/src/handlers/gates/helpers.ts +12 -4
  30. package/src/handlers/gates/runner.ts +1 -1
  31. package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
  32. package/src/handlers/gates/tool.ts +9 -4
  33. package/src/index.ts +23 -12
  34. package/src/input-normalizer.ts +53 -48
  35. package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
  36. package/src/path/path-containment.ts +24 -0
  37. package/src/path/path-flavor.ts +114 -0
  38. package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
  39. package/src/path-normalizer.ts +35 -59
  40. package/src/permission-gate.ts +1 -1
  41. package/src/permission-manager.ts +36 -56
  42. package/src/permission-prompts.ts +4 -3
  43. package/src/permission-session.ts +6 -5
  44. package/src/permissions-service.ts +8 -0
  45. package/src/rule.ts +19 -21
  46. package/src/session-approval.ts +1 -1
  47. package/src/tool-input-path.ts +17 -19
  48. package/src/tool-preview-formatter.ts +2 -5
  49. package/src/path-containment.ts +0 -56
  50. /package/src/{permission-dialog.ts → authority/permission-dialog.ts} +0 -0
  51. /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
  52. /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import type { PermissionPromptDecision } from "./permission-dialog";
1
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
2
 
3
3
  /** Result of applying the permission gate. */
4
4
  export type PermissionGateResult =
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "./access-intent/access-intent";
3
+ import { classifyToolKind } from "./access-intent/tool-kind";
3
4
  import {
4
5
  getGlobalConfigPath,
5
6
  getProjectAgentsDir,
@@ -7,6 +8,7 @@ import {
7
8
  } from "./config-paths";
8
9
  import { normalizeInput } from "./input-normalizer";
9
10
  import { normalizeFlatConfig } from "./normalize";
11
+ import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
10
12
  import { PATH_SURFACES } from "./path-surfaces";
11
13
  import {
12
14
  FilePolicyLoader,
@@ -37,15 +39,6 @@ import type {
37
39
  import { isPermissionState } from "./types";
38
40
  import { wildcardMatch } from "./wildcard-matcher";
39
41
 
40
- const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
41
- "bash",
42
- "read",
43
- "write",
44
- "edit",
45
- "grep",
46
- "find",
47
- "ls",
48
- ]);
49
42
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
50
43
 
51
44
  /** Universal fallback when permission["*"] is absent from all scopes. */
@@ -109,11 +102,12 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
109
102
  */
110
103
  agentDir?: string;
111
104
  /**
112
- * Host platform, injected from the composition root, that decides whether
113
- * path-surface rule matching folds case (and separators) on Windows.
114
- * Defaults to a POSIX flavor; production always supplies the real platform.
105
+ * Resolved path-language flavor, injected from the composition root, that
106
+ * decides whether path-surface rule matching folds case (and separators) on
107
+ * Windows. Defaults to the POSIX flavor; production always supplies the real
108
+ * platform's flavor.
115
109
  */
116
- platform?: NodeJS.Platform;
110
+ flavor?: PathFlavor;
117
111
  /**
118
112
  * yolo-mode reader, injected from the composition root. When it reports
119
113
  * true, {@link PermissionManager.check} rewrites every matched `ask` to a
@@ -126,7 +120,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
126
120
 
127
121
  export class PermissionManager implements ScopedPermissionManager {
128
122
  private readonly agentDir: string | undefined;
129
- private readonly platform: NodeJS.Platform;
123
+ private readonly flavor: PathFlavor;
130
124
  private readonly isYoloEnabled: () => boolean;
131
125
  private loader: PolicyLoader;
132
126
  private readonly resolvedPermissionsCache = new Map<
@@ -136,7 +130,7 @@ export class PermissionManager implements ScopedPermissionManager {
136
130
 
137
131
  constructor(options: PermissionManagerOptions = {}) {
138
132
  this.agentDir = options.agentDir;
139
- this.platform = options.platform ?? "linux";
133
+ this.flavor = options.flavor ?? posixPathFlavor;
140
134
  this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
141
135
  this.loader =
142
136
  options.policyLoader ??
@@ -270,7 +264,7 @@ export class PermissionManager implements ScopedPermissionManager {
270
264
  .map((r) => r.pattern);
271
265
  if (patterns.length === 0) return NO_PROMOTION;
272
266
 
273
- const matchOptions = pathMatchOptions("path", this.platform);
267
+ const matchOptions = pathMatchOptions("path", this.flavor);
274
268
  return (token) =>
275
269
  patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
276
270
  }
@@ -281,29 +275,10 @@ export class PermissionManager implements ScopedPermissionManager {
281
275
  */
282
276
  getToolPermission(toolName: string, agentName?: string): PermissionState {
283
277
  const { composedRules } = this.resolvePermissions(agentName);
284
- const normalizedToolName = toolName.trim();
285
-
286
- // Special surfaces (external_directory): evaluate directly by surface name.
287
- if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
288
- return evaluate(normalizedToolName, "*", composedRules, this.platform)
289
- .action;
290
- }
291
-
292
- // Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
293
- // (or universal default) handles this correctly.
294
- if (normalizedToolName === "bash") {
295
- return evaluate("bash", "*", composedRules, this.platform).action;
296
- }
297
- if (normalizedToolName === "mcp") {
298
- return evaluate("mcp", "*", composedRules, this.platform).action;
299
- }
300
- if (normalizedToolName === "skill") {
301
- return evaluate("skill", "*", composedRules, this.platform).action;
302
- }
303
-
304
- // Tool-name surfaces (read, write, etc. and extension tools).
305
- return evaluate(normalizedToolName, "*", composedRules, this.platform)
306
- .action;
278
+ // Every surface (special, bash, mcp, skill, path-bearing, and extension
279
+ // tools) resolves its tool-level state identically: evaluate the surface
280
+ // name against the "*" catch-all value. There is no per-kind branch.
281
+ return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
307
282
  }
308
283
 
309
284
  /**
@@ -345,7 +320,7 @@ export class PermissionManager implements ScopedPermissionManager {
345
320
  intent.surface,
346
321
  intent.surface,
347
322
  fullRules,
348
- this.platform,
323
+ this.flavor,
349
324
  );
350
325
  }
351
326
 
@@ -363,7 +338,7 @@ export class PermissionManager implements ScopedPermissionManager {
363
338
  toolName,
364
339
  intent.surface,
365
340
  fullRules,
366
- this.platform,
341
+ this.flavor,
367
342
  );
368
343
  }
369
344
  }
@@ -382,16 +357,18 @@ function buildCheckResult(
382
357
  normalizedToolName: string,
383
358
  toolName: string,
384
359
  fullRules: Ruleset,
385
- platform: NodeJS.Platform,
360
+ flavor: PathFlavor,
386
361
  ): PermissionCheckResult {
387
362
  const { rule, value } = PATH_SURFACES.has(surface)
388
- ? evaluateAnyValue(surface, values, fullRules, platform)
389
- : evaluateFirst(surface, values, fullRules, platform);
363
+ ? evaluateAnyValue(surface, values, fullRules, flavor)
364
+ : evaluateFirst(surface, values, fullRules, flavor);
390
365
 
391
366
  // For MCP, replace the normalizer's fallback target with the actual
392
367
  // matched candidate value so PermissionCheckResult.target is accurate.
393
368
  const extras =
394
- surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
369
+ classifyToolKind(surface) === "mcp"
370
+ ? { ...resultExtras, target: value }
371
+ : resultExtras;
395
372
 
396
373
  return {
397
374
  toolName,
@@ -444,19 +421,22 @@ function deriveSource(
444
421
  toolName: string,
445
422
  ): PermissionCheckResult["source"] {
446
423
  if (rule.layer === "session") return "session";
447
-
448
- if (toolName === "mcp") {
449
- if (rule.layer === "default") return "default";
450
- return "mcp";
451
- }
452
-
453
424
  if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
454
- if (toolName === "skill") return "skill";
455
- if (toolName === "bash") return "bash";
456
425
 
457
- // Built-in tools always report "tool"; extension tools distinguish default.
458
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
459
- return rule.layer === "default" ? "default" : "tool";
426
+ switch (classifyToolKind(toolName)) {
427
+ case "mcp":
428
+ return rule.layer === "default" ? "default" : "mcp";
429
+ case "skill":
430
+ return "skill";
431
+ case "bash":
432
+ return "bash";
433
+ case "path":
434
+ // Built-in path-bearing tools (read/write/edit/grep/find/ls).
435
+ return "tool";
436
+ case "extension":
437
+ // Extension tools distinguish a synthesized-default match from a rule.
438
+ return rule.layer === "default" ? "default" : "tool";
439
+ }
460
440
  }
461
441
 
462
442
  // Re-export types that external modules import from this file.
@@ -1,3 +1,4 @@
1
+ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
1
2
  import { matchQualifier } from "./denial-messages";
2
3
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
3
4
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
@@ -22,7 +23,7 @@ export function formatUnknownToolReason(
22
23
  preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
23
24
 
24
25
  const mcpHint =
25
- toolName === "mcp"
26
+ classifyToolKind(toolName) === "mcp"
26
27
  ? ""
27
28
  : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
28
29
 
@@ -37,7 +38,7 @@ export function formatAskPrompt(
37
38
  ): string {
38
39
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
39
40
 
40
- if (result.toolName === "bash") {
41
+ if (classifyToolKind(result.toolName) === "bash") {
41
42
  const subCommand = result.command ?? "";
42
43
  const qualifier = matchQualifier(
43
44
  result.matchedPattern,
@@ -52,7 +53,7 @@ export function formatAskPrompt(
52
53
  return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
53
54
  }
54
55
 
55
- if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
56
+ if (isMcpCheck(result) && result.target) {
56
57
  const patternInfo = result.matchedPattern
57
58
  ? ` (matched '${result.matchedPattern}')`
58
59
  : "";
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ForwardingController } from "#src/authority/forwarding-manager";
2
3
  import {
3
4
  getActiveAgentName,
4
5
  getActiveAgentNameFromSystemPrompt,
@@ -7,8 +8,8 @@ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-select
7
8
  import type { SessionConfigStore } from "./config-store";
8
9
  import type { PermissionSystemExtensionConfig } from "./extension-config";
9
10
  import type { ExtensionPaths } from "./extension-paths";
10
- import type { ForwardingController } from "./forwarding-manager";
11
11
  import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
12
+ import type { PathFlavor } from "./path/path-flavor";
12
13
  import { PathNormalizer } from "./path-normalizer";
13
14
  import type { ScopedPermissionManager } from "./permission-manager";
14
15
 
@@ -47,12 +48,12 @@ export class PermissionSession implements ToolCallGateInputs {
47
48
  private readonly sessionRules: SessionRules,
48
49
  private readonly configStore: SessionConfigStore,
49
50
  private readonly authorizerSelection: AuthorizerSelectionLifecycle,
50
- private readonly platform: NodeJS.Platform,
51
+ private readonly flavor: PathFlavor,
51
52
  ) {
52
53
  // Placeholder until the first activate(ctx) binds the real cwd; every gate
53
54
  // evaluate runs after activate (handleToolCall activates first), so this
54
55
  // empty-cwd value is never read.
55
- this.pathNormalizer = new PathNormalizer(platform, "");
56
+ this.pathNormalizer = new PathNormalizer(flavor, "");
56
57
  }
57
58
 
58
59
  // ── Context lifecycle ──────────────────────────────────────────────────
@@ -68,7 +69,7 @@ export class PermissionSession implements ToolCallGateInputs {
68
69
  */
69
70
  activate(ctx: ExtensionContext): void {
70
71
  this.context = ctx;
71
- this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
72
+ this.pathNormalizer = new PathNormalizer(this.flavor, ctx.cwd);
72
73
  this.forwarding.start(ctx);
73
74
  this.authorizerSelection.activate(ctx);
74
75
  }
@@ -207,7 +208,7 @@ export class PermissionSession implements ToolCallGateInputs {
207
208
  // ── Path normalization ────────────────────────────────────────────────
208
209
 
209
210
  /**
210
- * The session's {@link PathNormalizer}, carrying the host platform and the
211
+ * The session's {@link PathNormalizer}, carrying the host path flavor and the
211
212
  * session cwd. Rebuilt on every `resetForNewSession` so a session switch
212
213
  * rebinds the cwd.
213
214
  */
@@ -1,4 +1,5 @@
1
1
  import type { AccessIntent } from "./access-intent/access-intent";
2
+ import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
2
3
  import { buildAccessIntentForSurface } from "./input-normalizer";
3
4
  import type { PathNormalizer } from "./path-normalizer";
4
5
  import type { PermissionsService } from "./service";
@@ -50,6 +51,13 @@ export class LocalPermissionsService implements PermissionsService {
50
51
  value?: string,
51
52
  agentName?: string,
52
53
  ): ReturnType<PermissionsService["checkPermission"]> {
54
+ // Bash decomposes at gate parity: a chained/nested command is split into
55
+ // its command-pattern units and resolved most-restrictive, matching what
56
+ // the enforcement gate enforces (#309). A cold parser falls back to the
57
+ // whole-string match inside resolveBashAdvisoryCheck.
58
+ if (surface === "bash") {
59
+ return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
60
+ }
53
61
  const intent = buildAccessIntentForSurface(
54
62
  surface,
55
63
  value,