@gotgenes/pi-permission-system 20.3.0 → 20.4.1

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 (57) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/docs/configuration.md +3 -0
  4. package/docs/cross-extension-api.md +4 -0
  5. package/docs/subagent-integration.md +2 -2
  6. package/package.json +1 -1
  7. package/src/access-intent/access-path.ts +7 -5
  8. package/src/access-intent/bash/bash-path-resolver.ts +1 -2
  9. package/src/access-intent/bash/command-enumeration.ts +98 -24
  10. package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
  11. package/src/access-intent/bash/parser.ts +39 -0
  12. package/src/access-intent/bash/program.ts +3 -3
  13. package/src/access-intent/bash/sync-commands.ts +31 -0
  14. package/src/access-intent/bash/token-classification.ts +18 -32
  15. package/src/access-intent/path-normalization.ts +24 -43
  16. package/src/access-intent/tool-kind.ts +57 -0
  17. package/src/authority/approval-escalator.ts +3 -3
  18. package/src/authority/authorizer-selection.ts +1 -1
  19. package/src/authority/authorizer.ts +2 -2
  20. package/src/authority/denying-authorizer.ts +1 -1
  21. package/src/authority/forwarded-request-server.ts +3 -3
  22. package/src/authority/forwarder-context.ts +1 -1
  23. package/src/authority/forwarding-io.ts +3 -3
  24. package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
  25. package/src/authority/local-user-authorizer.ts +2 -2
  26. package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +1 -2
  27. package/src/authority/permission-prompter.ts +2 -2
  28. package/src/authority/subagent-context.ts +11 -14
  29. package/src/authority/subagent-detection.ts +5 -4
  30. package/src/bash-advisory-check.ts +38 -0
  31. package/src/denial-messages.ts +6 -9
  32. package/src/handlers/before-agent-start.ts +8 -0
  33. package/src/handlers/gates/bash-command.ts +22 -8
  34. package/src/handlers/gates/helpers.ts +12 -4
  35. package/src/handlers/gates/runner.ts +1 -1
  36. package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
  37. package/src/handlers/gates/tool.ts +9 -4
  38. package/src/index.ts +23 -12
  39. package/src/input-normalizer.ts +53 -48
  40. package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
  41. package/src/path/path-containment.ts +24 -0
  42. package/src/path/path-flavor.ts +114 -0
  43. package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
  44. package/src/path-normalizer.ts +35 -59
  45. package/src/permission-gate.ts +1 -1
  46. package/src/permission-manager.ts +36 -56
  47. package/src/permission-prompts.ts +4 -3
  48. package/src/permission-session.ts +6 -5
  49. package/src/permissions-service.ts +8 -0
  50. package/src/rule.ts +19 -21
  51. package/src/session-approval.ts +1 -1
  52. package/src/tool-input-path.ts +17 -19
  53. package/src/tool-preview-formatter.ts +2 -5
  54. package/src/path-containment.ts +0 -56
  55. /package/src/{permission-dialog.ts → authority/permission-dialog.ts} +0 -0
  56. /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
  57. /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
@@ -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
 
@@ -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,4 +1,7 @@
1
- import type { BashCommand } from "#src/access-intent/bash/command-enumeration";
1
+ import type {
2
+ BashCommand,
3
+ WrapperKind,
4
+ } from "#src/access-intent/bash/command-enumeration";
2
5
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
6
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
7
  import type { PermissionCheckResult } from "#src/types";
@@ -19,11 +22,13 @@ import type { PermissionCheckResult } from "#src/types";
19
22
  * `commandContext` (set only for a nested command), so the prompt,
20
23
  * session-approval suggestion, and decision event scope to that command.
21
24
  *
22
- * An opaque-payload wrapper unit (`bash -c`/`eval`, flagged `opaque` by the
23
- * enumerator) has its inner program hidden behind a quoted argument, so an
24
- * `allow` is floored up to a synthetic `ask` (the `<opaque-bash-wrapper>`
25
- * pattern) to keep it from riding a permissive rule; an explicit `deny`/`ask`
26
- * on the wrapper is left untouched (`deny > ask > allow`).
25
+ * A wrapper unit (flagged with a `wrapperKind` by the enumerator) hides or
26
+ * indirects the command that should be gated, so an `allow` is floored up to a
27
+ * synthetic `ask` the `<opaque-bash-wrapper>` pattern for an inline-shell
28
+ * payload (`bash -c`/`eval`, #481) or `<indirection-bash-wrapper>` for a
29
+ * prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…, #490) to keep it
30
+ * from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
31
+ * untouched (`deny > ask > allow`).
27
32
  *
28
33
  * When `commands` is empty there are two cases. A trivially-empty command (an
29
34
  * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
@@ -36,6 +41,15 @@ import type { PermissionCheckResult } from "#src/types";
36
41
  * Pure and synchronous: the (async, tree-sitter) parse happens once in the
37
42
  * handler, which passes the decomposed `commands` here.
38
43
  */
44
+ /**
45
+ * The synthetic `matchedPattern` recorded when a wrapper unit's `allow` is
46
+ * floored to `ask`, keyed by the wrapper kind that caused the floor.
47
+ */
48
+ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
49
+ "opaque-payload": "<opaque-bash-wrapper>",
50
+ indirection: "<indirection-bash-wrapper>",
51
+ };
52
+
39
53
  export function resolveBashCommandCheck(
40
54
  command: string,
41
55
  commands: BashCommand[],
@@ -69,11 +83,11 @@ export function resolveBashCommandCheck(
69
83
  agentName,
70
84
  });
71
85
  const result =
72
- cmd.opaque && base.state === "allow"
86
+ cmd.wrapperKind && base.state === "allow"
73
87
  ? {
74
88
  ...base,
75
89
  state: "ask" as const,
76
- matchedPattern: "<opaque-bash-wrapper>",
90
+ matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
77
91
  }
78
92
  : base;
79
93
  return cmd.context ? { ...result, commandContext: cmd.context } : result;
@@ -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
  /**