@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
@@ -1,7 +1,6 @@
1
- import { posix as posixPath, win32 as winPath } from "node:path";
2
-
3
- import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
4
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
1
+ import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
2
+ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
3
+ import type { PathFlavor } from "#src/path/path-flavor";
5
4
 
6
5
  /**
7
6
  * Narrow context for subagent detection — the only session-manager readers
@@ -17,17 +16,15 @@ export interface SubagentDetectionContext {
17
16
 
18
17
  export function normalizeFilesystemPath(
19
18
  pathValue: string,
20
- platform: NodeJS.Platform,
19
+ flavor: PathFlavor,
21
20
  ): string {
22
- const impl = platform === "win32" ? winPath : posixPath;
23
- const normalizedPath = impl.normalize(pathValue);
24
- return platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
21
+ return flavor.fold(flavor.impl.normalize(pathValue));
25
22
  }
26
23
 
27
24
  function isPathWithinDirectoryForSubagent(
28
25
  pathValue: string,
29
26
  directory: string,
30
- platform: NodeJS.Platform,
27
+ flavor: PathFlavor,
31
28
  ): boolean {
32
29
  if (!pathValue || !directory) {
33
30
  return false;
@@ -37,7 +34,7 @@ function isPathWithinDirectoryForSubagent(
37
34
  return true;
38
35
  }
39
36
 
40
- const sep = platform === "win32" ? "\\" : "/";
37
+ const sep = flavor.impl.sep;
41
38
  const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
42
39
  return pathValue.startsWith(prefix);
43
40
  }
@@ -71,7 +68,7 @@ export function isRegisteredSubagentChild(
71
68
  export function isSubagentExecutionContext(
72
69
  ctx: SubagentDetectionContext,
73
70
  subagentSessionsDir: string,
74
- platform: NodeJS.Platform,
71
+ flavor: PathFlavor,
75
72
  registry?: SubagentSessionRegistry,
76
73
  ): boolean {
77
74
  // 1. Explicit registry — in-process subagent extensions register by child
@@ -99,14 +96,14 @@ export function isSubagentExecutionContext(
99
96
  return false;
100
97
  }
101
98
 
102
- const normalizedSessionDir = normalizeFilesystemPath(sessionDir, platform);
99
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
103
100
  const normalizedSubagentRoot = normalizeFilesystemPath(
104
101
  subagentSessionsDir,
105
- platform,
102
+ flavor,
106
103
  );
107
104
  return isPathWithinDirectoryForSubagent(
108
105
  normalizedSessionDir,
109
106
  normalizedSubagentRoot,
110
- platform,
107
+ flavor,
111
108
  );
112
109
  }
@@ -3,7 +3,8 @@ import {
3
3
  isSubagentExecutionContext,
4
4
  type SubagentDetectionContext,
5
5
  } from "#src/authority/subagent-context";
6
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
6
+ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
7
+ import type { PathFlavor } from "#src/path/path-flavor";
7
8
 
8
9
  /**
9
10
  * Narrow seam for the ask-path consumers: "is the current session a subagent?"
@@ -30,7 +31,7 @@ export interface RegisteredChildDetector {
30
31
  /** Composition-root inputs for {@link SubagentDetection}. */
31
32
  export interface SubagentDetectionDeps {
32
33
  subagentSessionsDir: string;
33
- platform: NodeJS.Platform;
34
+ flavor: PathFlavor;
34
35
  registry?: SubagentSessionRegistry;
35
36
  }
36
37
 
@@ -38,7 +39,7 @@ export interface SubagentDetectionDeps {
38
39
  * Single owner of subagent detection.
39
40
  *
40
41
  * Constructed once in the composition root with the detection inputs
41
- * (`subagentSessionsDir`, `platform`, `registry`) and shared across every
42
+ * (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
42
43
  * consumer, replacing the dep triple those consumers previously threaded
43
44
  * individually. Delegates to the pure detection functions in
44
45
  * {@link ./subagent-context}, holding only the deps.
@@ -52,7 +53,7 @@ export class SubagentDetection
52
53
  return isSubagentExecutionContext(
53
54
  ctx,
54
55
  this.deps.subagentSessionsDir,
55
- this.deps.platform,
56
+ this.deps.flavor,
56
57
  this.deps.registry,
57
58
  );
58
59
  }
@@ -0,0 +1,38 @@
1
+ import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
2
+ import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
3
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import type { PermissionCheckResult } from "#src/types";
5
+
6
+ /**
7
+ * Resolve an advisory bash query at the gate's decomposed fidelity.
8
+ *
9
+ * When the tree-sitter parser is warm, the command is decomposed into its
10
+ * command-pattern units and routed through the same shared orchestrator the
11
+ * enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
12
+ * command returns the most-restrictive decision (`deny > ask > allow`) and
13
+ * inherits the opaque-wrapper floor (#481) and the fail-closed
14
+ * `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
15
+ *
16
+ * In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
17
+ * to the pre-#309 whole-string match, so the advisory answer is never *weaker*
18
+ * than before — only strengthened once warm.
19
+ *
20
+ * Synchronous, preserving `PermissionsService.checkPermission`'s sync contract:
21
+ * the only async step (parser init) happens earlier, at `before_agent_start`.
22
+ */
23
+ export function resolveBashAdvisoryCheck(
24
+ command: string,
25
+ agentName: string | undefined,
26
+ resolver: ScopedPermissionResolver,
27
+ ): PermissionCheckResult {
28
+ const commands = parseBashCommandsSync(command);
29
+ if (commands === null) {
30
+ return resolver.resolve({
31
+ kind: "tool",
32
+ surface: "bash",
33
+ input: { command },
34
+ agentName,
35
+ });
36
+ }
37
+ return resolveBashCommandCheck(command, commands, agentName, resolver);
38
+ }
@@ -1,3 +1,4 @@
1
+ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
1
2
  import { EXTENSION_ID } from "./extension-config";
2
3
  import type { BashCommandContext, PermissionCheckResult } from "./types";
3
4
 
@@ -127,7 +128,7 @@ function buildToolDenyBody(
127
128
  parts.push(`Agent '${agentName}'`);
128
129
  }
129
130
 
130
- if (isMcpCheck(check)) {
131
+ if (isMcpCheck(check) && check.target) {
131
132
  parts.push(`is not permitted to run MCP target '${check.target}'`);
132
133
  } else {
133
134
  parts.push(`is not permitted to run '${check.toolName}'`);
@@ -190,10 +191,10 @@ function buildUnavailableBody(ctx: DenialContext): string {
190
191
  switch (ctx.kind) {
191
192
  case "tool": {
192
193
  const { check } = ctx;
193
- if (check.toolName === "bash" && check.command) {
194
+ if (classifyToolKind(check.toolName) === "bash" && check.command) {
194
195
  return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
195
196
  }
196
- if (isMcpCheck(check)) {
197
+ if (isMcpCheck(check) && check.target) {
197
198
  return "Using tool 'mcp' requires approval, but no interactive UI is available.";
198
199
  }
199
200
  return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
@@ -220,10 +221,10 @@ function buildUserDeniedBody(
220
221
  switch (ctx.kind) {
221
222
  case "tool": {
222
223
  const { check } = ctx;
223
- if (isMcpCheck(check)) {
224
+ if (isMcpCheck(check) && check.target) {
224
225
  return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
225
226
  }
226
- if (check.toolName === "bash" && check.command) {
227
+ if (classifyToolKind(check.toolName) === "bash" && check.command) {
227
228
  return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
228
229
  }
229
230
  return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
@@ -243,10 +244,6 @@ function buildUserDeniedBody(
243
244
  }
244
245
  }
245
246
 
246
- function isMcpCheck(check: PermissionCheckResult): boolean {
247
- return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
248
- }
249
-
250
247
  /** Render an external-path disclosure list for the bash deny body's path clause. */
251
248
  function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
252
249
  return paths
@@ -40,12 +40,16 @@ export function shouldExposeTool(
40
40
  * - `session` — encapsulates all mutable session state and lifecycle operations
41
41
  * - `resolver` — owns permission-query surface: `getToolPermission`, skill check
42
42
  * - `toolRegistry` — Pi tool API subset (getActive + setActive)
43
+ * - `warmParser` — warms the tree-sitter parser so the synchronous advisory
44
+ * bash path can decompose at gate parity; `before_agent_start` precedes any
45
+ * tool call, so triggering it here closes the pre-warm window (#309)
43
46
  */
44
47
  export class AgentPrepHandler {
45
48
  constructor(
46
49
  private readonly session: PermissionSession,
47
50
  private readonly resolver: PermissionResolver,
48
51
  private readonly toolRegistry: ToolRegistry,
52
+ private readonly warmParser: () => void,
49
53
  ) {}
50
54
 
51
55
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -53,6 +57,10 @@ export class AgentPrepHandler {
53
57
  event: BeforeAgentStartPayload,
54
58
  ctx: ExtensionContext,
55
59
  ): Promise<BeforeAgentStartEventResult> {
60
+ // Fire-and-forget: warming is idempotent and best-effort, so it never
61
+ // delays agent start. A bash advisory query before it completes falls back
62
+ // to whole-string matching.
63
+ this.warmParser();
56
64
  this.session.activate(ctx);
57
65
  this.session.refreshConfig(ctx);
58
66
 
@@ -1,3 +1,4 @@
1
+ import { classifyToolKind } from "#src/access-intent/tool-kind";
1
2
  import type {
2
3
  PermissionDecisionEvent,
3
4
  PermissionDecisionResolution,
@@ -14,10 +15,17 @@ export function deriveDecisionValue(
14
15
  check: Pick<PermissionCheckResult, "command" | "target">,
15
16
  path?: string,
16
17
  ): string {
17
- if (toolName === "bash") return check.command ?? toolName;
18
- if (toolName === "mcp") return check.target ?? toolName;
19
- if (path) return path;
20
- return toolName;
18
+ switch (classifyToolKind(toolName)) {
19
+ case "bash":
20
+ return check.command ?? toolName;
21
+ case "mcp":
22
+ return check.target ?? toolName;
23
+ case "path":
24
+ case "skill":
25
+ case "extension":
26
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty path falls through to toolName (the original `if (path)` truthiness)
27
+ return path || toolName;
28
+ }
21
29
  }
22
30
 
23
31
  /**
@@ -1,11 +1,11 @@
1
1
  import type { AskEscalator } from "#src/authority/authorizer-selection";
2
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
3
  import type { DecisionReporter } from "#src/decision-reporter";
3
4
  import {
4
5
  formatDenyReason,
5
6
  formatUnavailableReason,
6
7
  formatUserDeniedReason,
7
8
  } from "#src/denial-messages";
8
- import type { PermissionPromptDecision } from "#src/permission-dialog";
9
9
  import { applyPermissionGate } from "#src/permission-gate";
10
10
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
11
11
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
@@ -1,5 +1,6 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import { BashProgram } from "#src/access-intent/bash/program";
3
+ import { classifyToolKind } from "#src/access-intent/tool-kind";
3
4
  import type { PathNormalizer } from "#src/path-normalizer";
4
5
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
5
6
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
@@ -77,7 +78,7 @@ export class ToolCallGatePipeline {
77
78
  const command = getNonEmptyString(toRecord(tcc.input).command);
78
79
  const normalizer = this.inputs.getPathNormalizer();
79
80
  const bashProgram =
80
- tcc.toolName === "bash" && command
81
+ classifyToolKind(tcc.toolName) === "bash" && command
81
82
  ? await BashProgram.parse(
82
83
  command,
83
84
  normalizer,
@@ -159,7 +160,7 @@ export class ToolCallGatePipeline {
159
160
  command: string | null,
160
161
  normalizer: PathNormalizer,
161
162
  ): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
162
- if (tcc.toolName === "bash" && bashProgram) {
163
+ if (classifyToolKind(tcc.toolName) === "bash" && bashProgram) {
163
164
  return {
164
165
  toolCheck: resolveBashCommandCheck(
165
166
  command ?? "",
@@ -1,4 +1,5 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
+ import { classifyToolKind } from "#src/access-intent/tool-kind";
2
3
  import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
3
4
  import { suggestSessionPattern } from "#src/pattern-suggest";
4
5
  import { formatAskPrompt } from "#src/permission-prompts";
@@ -23,10 +24,14 @@ function deriveSuggestionValue(
23
24
  check: PermissionCheckResult,
24
25
  accessPath?: AccessPath,
25
26
  ): string {
26
- if (tcc.toolName === "bash") return check.command ?? "";
27
- if (tcc.toolName === "mcp") return check.target ?? "mcp";
28
- if (accessPath) return accessPath.value();
29
- return "*";
27
+ switch (classifyToolKind(tcc.toolName)) {
28
+ case "bash":
29
+ return check.command ?? "";
30
+ case "mcp":
31
+ return check.target ?? "mcp";
32
+ default:
33
+ return accessPath ? accessPath.value() : "*";
34
+ }
30
35
  }
31
36
 
32
37
  /**
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
 
@@ -146,7 +150,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
146
150
  sessionRules,
147
151
  configStore,
148
152
  authorizerSelection,
149
- hostPlatform,
153
+ hostFlavor,
150
154
  );
151
155
 
152
156
  // refresh() must run after `session` is assigned: a debug-write IO failure
@@ -204,7 +208,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
204
208
  logger,
205
209
  audit,
206
210
  );
207
- const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
211
+ const agentPrep = new AgentPrepHandler(
212
+ session,
213
+ resolver,
214
+ toolRegistry,
215
+ () => {
216
+ void warmBashParser();
217
+ },
218
+ );
208
219
 
209
220
  const reporter = new GateDecisionReporter(logger, pi.events);
210
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
+ }