@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [20.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.3.0...pi-permission-system-v20.4.0) (2026-07-12)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add bash advisory decompose-or-fallback resolver ([e0637f1](https://github.com/gotgenes/pi-packages/commit/e0637f150d5ae0dcb16b92ccaa8520367636cc3c))
14
+ * **pi-permission-system:** add warm tree-sitter parser and sync bash-command parse ([66470f0](https://github.com/gotgenes/pi-packages/commit/66470f084e639e4c7282d4a8e49b3fd6937bcb9e))
15
+ * **pi-permission-system:** decompose advisory bash checkPermission at gate parity ([d8d7ef0](https://github.com/gotgenes/pi-packages/commit/d8d7ef010263b831e512dc26e97dde5bb93c5337))
16
+ * **pi-permission-system:** warm bash parser on before_agent_start ([509c597](https://github.com/gotgenes/pi-packages/commit/509c597ffcf24bb9ef8af64ba91b7ae849ad02a8))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** document advisory bash decomposition and complete roadmap step 4 ([aeb8633](https://github.com/gotgenes/pi-packages/commit/aeb86330f0f7b179796879f3fe4abc1a3642a57d))
22
+
8
23
  ## [20.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.2.0...pi-permission-system-v20.3.0) (2026-07-09)
9
24
 
10
25
 
@@ -90,6 +90,10 @@ Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`,
90
90
 
91
91
  For a path-shaped surface (`path`, `external_directory`, or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
92
92
 
93
+ For the `bash` surface, a `value` containing a chained or nested command (joined by `&&`, `||`, `;`, `|`, `&`, or newlines, or nested in a command substitution/subshell) is decomposed into its command-pattern units and resolved most-restrictive (`deny` > `ask` > `allow`), at parity with the enforcement gate — so `cd /repo && npm install x` returns the decision of the `npm install x` unit, not the leading `cd`.
94
+ A previously chained command that returned `allow` (riding an allowed leading command) may therefore now return `deny`/`ask`.
95
+ Decomposition needs the tree-sitter parser, which is warmed at `before_agent_start` (before any tool call); a bash query in the brief pre-warm window falls back to a whole-string match, so the answer is never weaker than the gate — only strengthened once warm.
96
+
93
97
  #### `getToolPermission`
94
98
 
95
99
  Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
@@ -3,11 +3,11 @@
3
3
  ## Native integration with `@gotgenes/pi-subagents`
4
4
 
5
5
  [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) is the only subagent extension with native permission-system integration.
6
- It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
6
+ It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
7
7
  Because the event bus dispatches synchronously, the synchronous registration completes before binding proceeds.
8
8
  This inverts the former dependency direction: the core no longer looks up this package's service ([ADR-0002] / pi-subagents [#261]).
9
9
 
10
- The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/subagent-registry.ts`.
10
+ The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
11
11
  This is necessary because each session's `ResourceLoader` creates its own `pi.events` bus: the parent emits `subagents:child:session-created` on the parent's bus, and only the parent's permission-system instance receives it.
12
12
  The child's jiti instance runs on a separate bus and never receives the event — but because both instances call `getSubagentSessionRegistry()`, they share the same store, so the parent's registration is visible to the child.
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.3.0",
3
+ "version": "20.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,5 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+
1
3
  import {
2
4
  canonicalNormalizePathForComparison,
3
5
  getPathPolicyValues,
@@ -103,13 +105,13 @@ export class AccessPath {
103
105
  */
104
106
  static forPath(
105
107
  pathValue: string,
106
- options: { cwd: string; resolveBase?: string; platform: NodeJS.Platform },
108
+ options: { cwd: string; resolveBase?: string; flavor: PathFlavor },
107
109
  ): AccessPath {
108
- const { cwd, resolveBase = cwd, platform } = options;
110
+ const { cwd, resolveBase = cwd, flavor } = options;
109
111
  return new AccessPath(
110
- normalizePathForComparison(pathValue, resolveBase, platform),
111
- getPathPolicyValues(pathValue, { cwd, resolveBase }, platform),
112
- canonicalNormalizePathForComparison(pathValue, resolveBase, platform),
112
+ normalizePathForComparison(pathValue, resolveBase, flavor),
113
+ getPathPolicyValues(pathValue, { cwd, resolveBase }, flavor),
114
+ canonicalNormalizePathForComparison(pathValue, resolveBase, flavor),
113
115
  );
114
116
  }
115
117
 
@@ -440,11 +440,10 @@ export class BashPathResolver {
440
440
  ): BashPathRuleCandidate[] {
441
441
  const seen = new Set<string>();
442
442
  const result: BashPathRuleCandidate[] = [];
443
- const windowsSeparators = this.normalizer.usesWindowsSeparators();
444
443
 
445
444
  for (const { token, base } of candidates) {
446
445
  const candidate =
447
- classifyTokenAsRuleCandidate(token, { windowsSeparators }) ??
446
+ classifyTokenAsRuleCandidate(token, this.normalizer.flavor) ??
448
447
  classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
449
448
  if (!candidate) continue;
450
449
 
@@ -22,7 +22,7 @@ import { isSafeSystemPath } from "#src/safe-system-paths";
22
22
  * - `plain` — everything else (relative tokens, `~/…`, native Windows drive
23
23
  * paths); handled by ordinary win32 resolution.
24
24
  */
25
- export type Win32BashTokenKind =
25
+ export type BashTokenShape =
26
26
  | { kind: "device" }
27
27
  | { kind: "drive-mount"; windowsPath: string }
28
28
  | { kind: "posix-absolute" }
@@ -36,7 +36,7 @@ export type Win32BashTokenKind =
36
36
  */
37
37
  const MSYS_DRIVE_MOUNT_PATTERN = /^\/([a-zA-Z])(\/.*)?$/;
38
38
 
39
- export function classifyWin32BashToken(token: string): Win32BashTokenKind {
39
+ export function classifyWin32BashToken(token: string): BashTokenShape {
40
40
  if (isSafeSystemPath(token)) return { kind: "device" };
41
41
 
42
42
  const driveMatch = MSYS_DRIVE_MOUNT_PATTERN.exec(token);
@@ -42,3 +42,42 @@ async function initParser(): Promise<TSParser> {
42
42
  // (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
43
43
  // the parser for the process lifetime.
44
44
  export const getParser = memoizeAsyncWithRetry(initParser);
45
+
46
+ // Resolved parser cached for synchronous access after warm-up. The tree-sitter
47
+ // parser is stateless (parse is a pure function of its input), so caching it at
48
+ // module scope is safe even though module state now persists across same-cwd
49
+ // session switches.
50
+ let warmedParser: TSParser | null = null;
51
+
52
+ /**
53
+ * Warm the tree-sitter parser so {@link getWarmBashParser} can hand it out
54
+ * synchronously. Triggered at `before_agent_start` (which precedes any tool
55
+ * call) so the synchronous advisory bash path can decompose at gate parity
56
+ * (#309).
57
+ *
58
+ * Best-effort and idempotent: it swallows a WASM init failure (the sync
59
+ * accessor stays cold and callers fall back to whole-string matching), and it
60
+ * returns immediately once warm, so calling it every turn is free.
61
+ */
62
+ export async function warmBashParser(): Promise<void> {
63
+ if (warmedParser) return;
64
+ try {
65
+ warmedParser = await getParser();
66
+ } catch {
67
+ // Leave cold → advisory bash queries fall back to whole-string matching.
68
+ // getParser's own retry memoization re-attempts init on the next call.
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The warmed parser for synchronous use, or `null` when it has not been warmed
74
+ * yet (the pre-warm window). Callers that get `null` must degrade gracefully.
75
+ */
76
+ export function getWarmBashParser(): TSParser | null {
77
+ return warmedParser;
78
+ }
79
+
80
+ /** Test-only: clear the warmed-parser cache so cold/warm cases are isolatable. */
81
+ export function resetWarmBashParser(): void {
82
+ warmedParser = null;
83
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ type BashCommand,
3
+ collectCommands,
4
+ } from "#src/access-intent/bash/command-enumeration";
5
+ import { getWarmBashParser } from "#src/access-intent/bash/parser";
6
+
7
+ /**
8
+ * Synchronously enumerate the command-pattern units of a bash command using the
9
+ * warmed tree-sitter parser.
10
+ *
11
+ * Returns `null` when the parser has not been warmed yet (the pre-warm window),
12
+ * so the caller can fall back to whole-string matching rather than block. Once
13
+ * warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
14
+ * chains split, nested substitutions/subshells descend, opaque wrappers flagged
15
+ * (#306). Only the command-pattern surface is produced — no path slices, so no
16
+ * `PathNormalizer` is needed.
17
+ *
18
+ * An unparseable command yields an empty array (the caller's decompose path
19
+ * fails it closed via `resolveBashCommandCheck`, #452).
20
+ */
21
+ export function parseBashCommandsSync(command: string): BashCommand[] | null {
22
+ const parser = getWarmBashParser();
23
+ if (!parser) return null;
24
+ const tree = parser.parse(command);
25
+ if (!tree) return [];
26
+ try {
27
+ return collectCommands(tree.rootNode);
28
+ } finally {
29
+ tree.delete();
30
+ }
31
+ }
@@ -19,14 +19,15 @@
19
19
  * the absolute-path branch. Shape recognition is platform-independent string
20
20
  * matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
21
21
  *
22
- * `classifyTokenAsRuleCandidate` also accepts a `RuleCandidateOptions.windowsSeparators`
23
- * flag: when `true`, a backslash-relative token (`dir\file`, no leading `.`, no
24
- * `/`, no `..`, not a drive-letter absolute) is accepted as path-shaped (#520).
25
- * This is the one genuinely platform-sensitive shape rule the classifier owns
26
- * on POSIX `\` is a legal filename character, so the caller (`BashPathResolver`)
27
- * derives the flag from `PathNormalizer.usesWindowsSeparators()` rather than the
28
- * classifier reading `process.platform` itself.
22
+ * `classifyTokenAsRuleCandidate` takes the resolved {@link PathFlavor}: a
23
+ * backslash-relative token (`dir\file`, no leading `.`, no `/`, no `..`, not a
24
+ * drive-letter absolute) is accepted as path-shaped only under the win32 flavor,
25
+ * whose `hasPathSeparator` counts `\` as a separator (#520). This is the one
26
+ * genuinely platform-sensitive shape rule the classifier owns on POSIX `\` is
27
+ * a legal filename character — and the flavor owns the bit, so the classifier
28
+ * never reads `process.platform` itself.
29
29
  */
30
+ import type { PathFlavor } from "#src/path/path-flavor";
30
31
  import type { PathRuleTokenMatcher } from "#src/types";
31
32
 
32
33
  // ── Public classifiers ─────────────────────────────────────────────────────
@@ -53,36 +54,22 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
53
54
  return null;
54
55
  }
55
56
 
56
- /**
57
- * Platform-sensitive options for {@link classifyTokenAsRuleCandidate}.
58
- */
59
- export interface RuleCandidateOptions {
60
- /**
61
- * True when the host platform treats `\` as a path separator (win32), so a
62
- * backslash-relative token (`dir\file`) is accepted as path-shaped (#520).
63
- * On POSIX `\` is a legal filename character, so omit this (or pass
64
- * `false`) to keep such a token bare.
65
- */
66
- readonly windowsSeparators?: boolean;
67
- }
68
-
69
57
  /**
70
58
  * Broader token classifier for cross-cutting `path` permission rules.
71
59
  *
72
60
  * Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
73
61
  * - Dot-files and `./`-relative paths (starting with `.`)
74
- * - Any relative path containing `/` (e.g. `src/foo.ts`)
62
+ * - Any token carrying a path separator under `flavor` (`src/foo.ts`, and on
63
+ * win32 the backslash-relative `dir\file`, #520) — `flavor.hasPathSeparator`
64
+ * owns the platform bit (POSIX: `/` only; win32: `/` or `\`), so this
65
+ * classifier never reads `process.platform`.
75
66
  * - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
76
- * - A backslash-relative token (`dir\file`) when `options.windowsSeparators`
77
- * is `true` (#520) — the caller (`BashPathResolver`) derives this from
78
- * `PathNormalizer.usesWindowsSeparators()` so the platform bit has a single
79
- * home; this classifier never reads `process.platform`.
80
67
  *
81
- * The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
82
- * The forward-slash drive form (`C:/…`) is also caught by `includes("/")`, but the
68
+ * The `~/foo` case is covered by `hasPathSeparator` — no separate `~/` branch needed.
69
+ * The forward-slash drive form (`C:/…`) is also caught by `hasPathSeparator`, but the
83
70
  * explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
84
- * and order-independent, and covers the backslash-only form (`D:\…`) which `includes("/")`
85
- * cannot reach.
71
+ * and order-independent, and covers the backslash-only form (`D:\…`) which the POSIX
72
+ * flavor's `hasPathSeparator` cannot reach.
86
73
  *
87
74
  * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
88
75
  * gate that the external-directory classifier uses.
@@ -91,15 +78,14 @@ export interface RuleCandidateOptions {
91
78
  */
92
79
  export function classifyTokenAsRuleCandidate(
93
80
  token: string,
94
- options?: RuleCandidateOptions,
81
+ flavor: PathFlavor,
95
82
  ): string | null {
96
83
  if (rejectNonPathToken(token)) return null;
97
84
 
98
85
  if (token.startsWith(".")) return token;
99
- if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
86
+ if (flavor.hasPathSeparator(token)) return token; // ~/ paths, relative paths with /, and win32 dir\file
100
87
  if (token.includes("..")) return token; // bare ".." (no slash)
101
88
  if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
102
- if (options?.windowsSeparators && token.includes("\\")) return token;
103
89
 
104
90
  return null;
105
91
  }
@@ -1,35 +1,21 @@
1
- import { posix as posixPath, win32 as winPath } from "node:path";
2
-
3
- import { canonicalizePath } from "#src/canonicalize-path";
4
1
  import { expandHomePath } from "#src/expand-home";
5
- import { isPathWithinDirectory } from "#src/path-containment";
2
+ import { canonicalizePath } from "#src/path/canonicalize-path";
3
+ import type { PathFlavor } from "#src/path/path-flavor";
6
4
 
7
5
  /**
8
6
  * Representation derivation backing {@link AccessPath}: turn an accessed path
9
7
  * into the lexical / canonical / policy-value forms the resolver matches
10
8
  * against rules. Pure (no filesystem access except `canonicalizePath`'s
11
- * best-effort symlink resolution); the `platform` is injected, never read
12
- * ambiently.
9
+ * best-effort symlink resolution); the platform's path semantics arrive as an
10
+ * injected {@link PathFlavor}, never read ambiently.
13
11
  */
14
12
  export function normalizePathForComparison(
15
13
  pathValue: string,
16
- cwd: string,
17
- platform: NodeJS.Platform,
14
+ base: string,
15
+ flavor: PathFlavor,
18
16
  ): string {
19
- const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
20
- if (!trimmed) {
21
- return "";
22
- }
23
-
24
- let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
- normalizedPath = expandHomePath(normalizedPath);
26
-
27
- const impl = platform === "win32" ? winPath : posixPath;
28
- const absolutePath = impl.resolve(cwd, normalizedPath);
29
- const normalizedAbsolutePath = impl.normalize(absolutePath);
30
- return platform === "win32"
31
- ? normalizedAbsolutePath.toLowerCase()
32
- : normalizedAbsolutePath;
17
+ const cleaned = normalizePathPolicyLiteral(pathValue);
18
+ return cleaned ? flavor.comparable(cleaned, base) : "";
33
19
  }
34
20
 
35
21
  export interface PathPolicyValueOptions {
@@ -49,9 +35,9 @@ export interface PathPolicyValueOptions {
49
35
  * Normalize a single path-like lookup value without resolving it against CWD.
50
36
  *
51
37
  * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
52
- * while applying the same lexical cleanup as
53
- * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
54
- * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
38
+ * while applying the lexical cleanup {@link normalizePathForComparison} shares:
39
+ * trim, strip simple wrapping quotes, strip the OpenCode-style leading `@`, and
40
+ * expand `~` / `$HOME`.
55
41
  */
56
42
  export function normalizePathPolicyLiteral(pathValue: string): string {
57
43
  const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
@@ -70,7 +56,7 @@ export function normalizePathPolicyLiteral(pathValue: string): string {
70
56
  export function getPathPolicyValues(
71
57
  pathValue: string,
72
58
  options: PathPolicyValueOptions,
73
- platform: NodeJS.Platform,
59
+ flavor: PathFlavor,
74
60
  ): string[] {
75
61
  const literal = normalizePathPolicyLiteral(pathValue);
76
62
  if (!literal) return [];
@@ -78,7 +64,7 @@ export function getPathPolicyValues(
78
64
 
79
65
  return [
80
66
  ...new Set([
81
- ...getAbsolutePathPolicyValues(pathValue, options, platform),
67
+ ...getAbsolutePathPolicyValues(pathValue, options, flavor),
82
68
  literal,
83
69
  ]),
84
70
  ];
@@ -87,38 +73,34 @@ export function getPathPolicyValues(
87
73
  function getAbsolutePathPolicyValues(
88
74
  pathValue: string,
89
75
  options: PathPolicyValueOptions,
90
- platform: NodeJS.Platform,
76
+ flavor: PathFlavor,
91
77
  ): string[] {
92
78
  const resolveBase = options.resolveBase ?? options.cwd;
93
79
  if (!resolveBase) return [];
94
80
 
95
- const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
81
+ const absolute = normalizePathForComparison(pathValue, resolveBase, flavor);
96
82
  if (!absolute) return [];
97
83
 
98
84
  return [
99
85
  absolute,
100
- ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
86
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, flavor),
101
87
  ];
102
88
  }
103
89
 
104
90
  function getCwdRelativePathPolicyValues(
105
91
  absolute: string,
106
92
  cwd: string | undefined,
107
- platform: NodeJS.Platform,
93
+ flavor: PathFlavor,
108
94
  ): string[] {
109
95
  if (!cwd) return [];
110
96
 
111
- const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
97
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, flavor);
112
98
  if (!normalizedCwd) return [];
113
- if (
114
- absolute !== normalizedCwd &&
115
- !isPathWithinDirectory(absolute, normalizedCwd, platform)
116
- ) {
99
+ if (absolute !== normalizedCwd && !flavor.isWithin(absolute, normalizedCwd)) {
117
100
  return [];
118
101
  }
119
102
 
120
- const impl = platform === "win32" ? winPath : posixPath;
121
- const relativeValue = impl.relative(normalizedCwd, absolute);
103
+ const relativeValue = flavor.impl.relative(normalizedCwd, absolute);
122
104
  return relativeValue ? [relativeValue] : [];
123
105
  }
124
106
 
@@ -129,11 +111,10 @@ function getCwdRelativePathPolicyValues(
129
111
  */
130
112
  export function canonicalNormalizePathForComparison(
131
113
  pathValue: string,
132
- cwd: string,
133
- platform: NodeJS.Platform,
114
+ base: string,
115
+ flavor: PathFlavor,
134
116
  ): string {
135
- const lexical = normalizePathForComparison(pathValue, cwd, platform);
117
+ const lexical = normalizePathForComparison(pathValue, base, flavor);
136
118
  if (!lexical) return "";
137
- const canonical = canonicalizePath(lexical, platform);
138
- return platform === "win32" ? canonical.toLowerCase() : canonical;
119
+ return flavor.fold(canonicalizePath(lexical, flavor));
139
120
  }
@@ -0,0 +1,57 @@
1
+ import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
2
+
3
+ /**
4
+ * What a tool invocation accesses — decided once from the tool name at the
5
+ * point an invocation enters the system.
6
+ *
7
+ * This is the single dispatch point that replaces the scattered
8
+ * `toolName === "bash"`/`"mcp"` re-derivation across the extraction consumers
9
+ * (`input-normalizer`, `tool-input-path`, the tool-call gate pipeline, and
10
+ * `permission-manager`'s source derivation) and the presentation consumers
11
+ * (`tool-preview-formatter`, `permission-prompts`, `denial-messages`, and
12
+ * `deriveDecisionValue`), which dispatch on {@link classifyToolKind} or
13
+ * {@link isMcpCheck}. Adding a tool kind means editing {@link classifyToolKind}
14
+ * plus the exhaustive switches the compiler flags — an OCP win over silent
15
+ * `===` comparisons a new variant sails past (#561).
16
+ *
17
+ * The value is plain data (a string union): `tool-kind.ts` imports no
18
+ * `AccessPath`, so `permission-manager.ts` may consume it without breaching the
19
+ * string boundary formalized in ADR-0002
20
+ * (`docs/decisions/0002-path-values-string-boundary.md`).
21
+ *
22
+ * - `bash` — its own token-based path gates; extraction product is the command.
23
+ * - `mcp` — extraction product is the qualified target.
24
+ * - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
25
+ * - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
26
+ * extraction product is `input.path`.
27
+ * - `extension` — every other tool, plus the `external_directory`/`path` special
28
+ * surfaces that reach `deriveSource` as normalized names.
29
+ */
30
+ export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
31
+
32
+ /** Classify a tool name into its {@link ToolKind}. */
33
+ export function classifyToolKind(toolName: string): ToolKind {
34
+ const name = toolName.trim();
35
+ if (name === "bash") return "bash";
36
+ if (name === "mcp") return "mcp";
37
+ if (name === "skill") return "skill";
38
+ if (PATH_BEARING_TOOLS.has(name)) return "path";
39
+ return "extension";
40
+ }
41
+
42
+ /** The resolved-check fields that decide MCP-ness. */
43
+ interface McpKindFields {
44
+ toolName: string;
45
+ source: string;
46
+ }
47
+
48
+ /**
49
+ * True when a resolved check concerns an MCP call — either the invoked tool is
50
+ * `mcp`, or the winning rule matched on the `mcp` surface (`source`). The
51
+ * `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`:
52
+ * `deriveSource` can set `source` to `mcp` on a result whose `toolName` is a
53
+ * server-qualified string.
54
+ */
55
+ export function isMcpCheck(check: McpKindFields): boolean {
56
+ return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
57
+ }
@@ -18,7 +18,7 @@ import {
18
18
  sleep,
19
19
  writeJsonFileAtomic,
20
20
  } from "#src/authority/forwarding-io";
21
- import type { PermissionPromptDecision } from "#src/permission-dialog";
21
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
22
22
  import {
23
23
  type ForwardedPermissionRequest,
24
24
  type ForwardedPromptDisplay,
@@ -28,10 +28,10 @@ import {
28
28
  type PermissionForwardingLocation,
29
29
  resolvePermissionForwardingTargetSessionId,
30
30
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
31
- } from "#src/permission-forwarding";
31
+ } from "#src/authority/permission-forwarding";
32
+ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
32
33
  import { buildUiPrompt } from "#src/permission-ui-prompt";
33
34
  import type { DebugReviewLogger } from "#src/session-logger";
34
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
35
35
  import { toRecord } from "#src/value-guards";
36
36
  import type { Authorizer } from "./authorizer";
37
37
  import type { PromptPermissionDetails } from "./permission-prompter";
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
3
  import {
4
4
  type Authorizer,
5
5
  type AuthorizerSelectionDeps,
@@ -2,10 +2,10 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type {
3
3
  PermissionPromptDecision,
4
4
  requestPermissionDecisionFromUi,
5
- } from "#src/permission-dialog";
5
+ } from "#src/authority/permission-dialog";
6
+ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
6
7
  import type { PermissionEventBus } from "#src/permission-events";
7
8
  import type { DebugReviewLogger } from "#src/session-logger";
8
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
9
9
  import { ParentAuthorizer } from "./approval-escalator";
10
10
  import { DenyingAuthorizer } from "./denying-authorizer";
11
11
  import { LocalUserAuthorizer } from "./local-user-authorizer";
@@ -1,4 +1,4 @@
1
- import type { PermissionPromptDecision } from "#src/permission-dialog";
1
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
2
  import type { Authorizer } from "./authorizer";
3
3
 
4
4
  /**
@@ -3,17 +3,17 @@ import {
3
3
  type ForwarderContext,
4
4
  getSessionId,
5
5
  } from "#src/authority/forwarder-context";
6
- import type { PermissionPromptDecision } from "#src/permission-dialog";
6
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
7
7
  import {
8
8
  type ForwardedPermissionRequest,
9
9
  type ForwardedPermissionResponse,
10
10
  isForwardedPermissionRequestForSession,
11
11
  type PermissionForwardingLocation,
12
- } from "#src/permission-forwarding";
12
+ } from "#src/authority/permission-forwarding";
13
+ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
13
14
  import { SessionApproval } from "#src/session-approval";
14
15
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
15
16
  import type { DebugReviewLogger } from "#src/session-logger";
16
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
17
17
  import type { PermissionCheckResult } from "#src/types";
18
18
  import type { AskEscalator } from "./authorizer-selection";
19
19
  import {
@@ -1,5 +1,5 @@
1
1
  import type { SessionEntryView } from "#src/active-agent";
2
- import type { PermissionDecisionUi } from "#src/permission-dialog";
2
+ import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
3
3
 
4
4
  /**
5
5
  * Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
@@ -9,15 +9,15 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
- import { isPermissionDecisionState } from "#src/permission-dialog";
13
- import type { PermissionUiPromptSource } from "#src/permission-events";
12
+ import { isPermissionDecisionState } from "#src/authority/permission-dialog";
14
13
  import {
15
14
  createPermissionForwardingLocation,
16
15
  type ForwardedPermissionRequest,
17
16
  type ForwardedPermissionResponse,
18
17
  type ForwardedSessionApproval,
19
18
  type PermissionForwardingLocation,
20
- } from "#src/permission-forwarding";
19
+ } from "#src/authority/permission-forwarding";
20
+ import type { PermissionUiPromptSource } from "#src/permission-events";
21
21
  import type { DebugReviewLogger } from "#src/session-logger";
22
22
 
23
23
  /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { InboxProcessor } from "./authority/forwarded-request-server";
3
- import type { SubagentDetector } from "./authority/subagent-detection";
2
+ import type { InboxProcessor } from "./forwarded-request-server";
4
3
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
4
+ import type { SubagentDetector } from "./subagent-detection";
5
5
 
6
6
  /**
7
7
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -1,10 +1,10 @@
1
- import { buildForwardedScopeLabels } from "#src/pattern-suggest";
2
1
  import type {
3
2
  PermissionDecisionUi,
4
3
  PermissionPromptDecision,
5
4
  RequestPermissionOptions,
6
5
  requestPermissionDecisionFromUi,
7
- } from "#src/permission-dialog";
6
+ } from "#src/authority/permission-dialog";
7
+ import { buildForwardedScopeLabels } from "#src/pattern-suggest";
8
8
  import {
9
9
  emitUiPromptEvent,
10
10
  type PermissionEventBus,
@@ -1,7 +1,6 @@
1
1
  import { join } from "node:path";
2
-
2
+ import type { PermissionUiPromptSource } from "#src/permission-events";
3
3
  import type { PermissionDecisionState } from "./permission-dialog";
4
- import type { PermissionUiPromptSource } from "./permission-events";
5
4
  import type { SubagentSessionRegistry } from "./subagent-registry";
6
5
 
7
6
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
@@ -1,5 +1,5 @@
1
- import type { PermissionPromptDecision } from "#src/permission-dialog";
2
- import type { ForwardedSessionApproval } from "#src/permission-forwarding";
1
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
+ import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
3
3
  import type { ReviewLogger } from "#src/session-logger";
4
4
  import type { Authorizer } from "./authorizer";
5
5