@gotgenes/pi-permission-system 20.2.0 → 20.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/docs/cross-extension-api.md +4 -0
  3. package/docs/subagent-integration.md +4 -2
  4. package/package.json +1 -1
  5. package/src/access-intent/access-path.ts +7 -5
  6. package/src/access-intent/bash/bash-path-resolver.ts +1 -2
  7. package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
  8. package/src/access-intent/bash/parser.ts +39 -0
  9. package/src/access-intent/bash/sync-commands.ts +31 -0
  10. package/src/access-intent/bash/token-classification.ts +18 -32
  11. package/src/access-intent/path-normalization.ts +24 -43
  12. package/src/access-intent/tool-kind.ts +57 -0
  13. package/src/authority/approval-escalator.ts +18 -8
  14. package/src/authority/authorizer-selection.ts +1 -1
  15. package/src/authority/authorizer.ts +2 -2
  16. package/src/authority/denying-authorizer.ts +1 -1
  17. package/src/authority/forwarded-request-server.ts +52 -4
  18. package/src/authority/forwarder-context.ts +1 -1
  19. package/src/authority/forwarding-io.ts +30 -3
  20. package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
  21. package/src/authority/local-user-authorizer.ts +27 -2
  22. package/src/{permission-dialog.ts → authority/permission-dialog.ts} +26 -0
  23. package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +22 -2
  24. package/src/authority/permission-prompter.ts +9 -1
  25. package/src/authority/subagent-context.ts +11 -14
  26. package/src/authority/subagent-detection.ts +5 -4
  27. package/src/bash-advisory-check.ts +38 -0
  28. package/src/denial-messages.ts +6 -9
  29. package/src/handlers/before-agent-start.ts +8 -0
  30. package/src/handlers/gates/helpers.ts +12 -4
  31. package/src/handlers/gates/runner.ts +4 -1
  32. package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
  33. package/src/handlers/gates/tool.ts +9 -4
  34. package/src/index.ts +27 -12
  35. package/src/input-normalizer.ts +53 -48
  36. package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
  37. package/src/path/path-containment.ts +24 -0
  38. package/src/path/path-flavor.ts +114 -0
  39. package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
  40. package/src/path-normalizer.ts +35 -59
  41. package/src/pattern-suggest.ts +29 -0
  42. package/src/permission-gate.ts +1 -1
  43. package/src/permission-manager.ts +36 -56
  44. package/src/permission-prompts.ts +4 -3
  45. package/src/permission-session.ts +6 -5
  46. package/src/permissions-service.ts +8 -0
  47. package/src/rule.ts +19 -21
  48. package/src/session-approval.ts +11 -0
  49. package/src/tool-input-path.ts +17 -19
  50. package/src/tool-preview-formatter.ts +2 -5
  51. package/src/path-containment.ts +0 -56
  52. /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
  53. /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
@@ -3,15 +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";
14
+ import { SessionApproval } from "#src/session-approval";
15
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
13
16
  import type { DebugReviewLogger } from "#src/session-logger";
14
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
15
17
  import type { PermissionCheckResult } from "#src/types";
16
18
  import type { AskEscalator } from "./authorizer-selection";
17
19
  import {
@@ -61,6 +63,11 @@ export interface ForwardedRequestServerDeps {
61
63
  policy: ServingPolicy;
62
64
  /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
63
65
  escalator: AskEscalator;
66
+ /**
67
+ * The serving session's `SessionRules`. Records a whole-session grant when a
68
+ * human approves a forwarded request for the entire serving session.
69
+ */
70
+ recorder: SessionApprovalRecorder;
64
71
  /** In-process subagent registry, read only by the one-hop canary. */
65
72
  registry?: SubagentSessionRegistry;
66
73
  }
@@ -115,6 +122,11 @@ function buildForwardedAskDetails(
115
122
  requesterAgentName: request.requesterAgentName || null,
116
123
  requesterSessionId: request.requesterSessionId || null,
117
124
  },
125
+ // Carries the child's suggestion so LocalUserAuthorizer can offer the
126
+ // whole-session grant scope; absent for a legacy/version-skew request.
127
+ ...(request.sessionApproval
128
+ ? { sessionApproval: request.sessionApproval }
129
+ : {}),
118
130
  };
119
131
  }
120
132
 
@@ -132,6 +144,7 @@ export class ForwardedRequestServer implements InboxProcessor {
132
144
  private readonly logger: DebugReviewLogger;
133
145
  private readonly policy: ServingPolicy;
134
146
  private readonly escalator: AskEscalator;
147
+ private readonly recorder: SessionApprovalRecorder;
135
148
  private readonly registry: SubagentSessionRegistry | undefined;
136
149
 
137
150
  constructor(deps: ForwardedRequestServerDeps) {
@@ -139,6 +152,7 @@ export class ForwardedRequestServer implements InboxProcessor {
139
152
  this.logger = deps.logger;
140
153
  this.policy = deps.policy;
141
154
  this.escalator = deps.escalator;
155
+ this.recorder = deps.recorder;
142
156
  this.registry = deps.registry;
143
157
  }
144
158
 
@@ -237,10 +251,44 @@ export class ForwardedRequestServer implements InboxProcessor {
237
251
  location,
238
252
  requestPath,
239
253
  currentSessionId,
240
- decision,
254
+ this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
241
255
  );
242
256
  }
243
257
 
258
+ /**
259
+ * Apply the human's grant-scope choice on a forwarded approval.
260
+ *
261
+ * A whole-session grant (`approved_for_serving_session`) records the child's
262
+ * suggested pattern into this serving node's `SessionRules` — the single
263
+ * source of truth for the scope — and is then translated to a plain
264
+ * `approved` so the child records nothing (its next identical action
265
+ * re-forwards and resolves as recorded authority). Every other decision
266
+ * passes through unchanged (`approved_for_session` → the child records).
267
+ */
268
+ private applyGrantScope(
269
+ request: ForwardedPermissionRequest,
270
+ decision: PermissionPromptDecision,
271
+ logDetails: Record<string, unknown>,
272
+ ): PermissionPromptDecision {
273
+ if (decision.state !== "approved_for_serving_session") {
274
+ return decision;
275
+ }
276
+ if (request.sessionApproval) {
277
+ this.recorder.recordSessionApproval(
278
+ SessionApproval.multiple(
279
+ request.sessionApproval.surface,
280
+ request.sessionApproval.patterns,
281
+ ),
282
+ );
283
+ this.logger.review("forwarded_permission.session_recorded", {
284
+ ...logDetails,
285
+ surface: request.sessionApproval.surface,
286
+ patterns: request.sessionApproval.patterns,
287
+ });
288
+ }
289
+ return { approved: true, state: "approved" };
290
+ }
291
+
244
292
  /**
245
293
  * Persist the served decision: write the response file the child polls for,
246
294
  * log the outcome, and delete the drained request. The symmetric "respond"
@@ -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,14 +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,
17
+ type ForwardedSessionApproval,
18
18
  type PermissionForwardingLocation,
19
- } from "#src/permission-forwarding";
19
+ } from "#src/authority/permission-forwarding";
20
+ import type { PermissionUiPromptSource } from "#src/permission-events";
20
21
  import type { DebugReviewLogger } from "#src/session-logger";
21
22
 
22
23
  /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
@@ -41,6 +42,31 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
41
42
  return undefined;
42
43
  }
43
44
 
45
+ /**
46
+ * Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
47
+ *
48
+ * Tolerant read: the child's session-approval suggestion is optional (absent
49
+ * on an older child) and only accepted when well-formed — a non-empty surface
50
+ * and an all-string patterns array.
51
+ */
52
+ function asForwardedSessionApproval(
53
+ value: unknown,
54
+ ): ForwardedSessionApproval | undefined {
55
+ if (typeof value !== "object" || value === null) {
56
+ return undefined;
57
+ }
58
+ const candidate = value as Partial<ForwardedSessionApproval>;
59
+ if (
60
+ typeof candidate.surface !== "string" ||
61
+ candidate.surface.length === 0 ||
62
+ !Array.isArray(candidate.patterns) ||
63
+ !candidate.patterns.every((pattern) => typeof pattern === "string")
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return { surface: candidate.surface, patterns: [...candidate.patterns] };
68
+ }
69
+
44
70
  export function formatUnknownErrorMessage(error: unknown): string {
45
71
  if (error instanceof Error && error.message) {
46
72
  return error.message;
@@ -323,6 +349,7 @@ export function readForwardedPermissionRequest(
323
349
  source: asUiPromptSource(parsed.source),
324
350
  surface: asNullableDisplayString(parsed.surface),
325
351
  value: asNullableDisplayString(parsed.value),
352
+ sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
326
353
  };
327
354
  } catch (error) {
328
355
  logPermissionForwardingWarning(
@@ -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,8 +1,10 @@
1
1
  import type {
2
2
  PermissionDecisionUi,
3
3
  PermissionPromptDecision,
4
+ RequestPermissionOptions,
4
5
  requestPermissionDecisionFromUi,
5
- } from "#src/permission-dialog";
6
+ } from "#src/authority/permission-dialog";
7
+ import { buildForwardedScopeLabels } from "#src/pattern-suggest";
6
8
  import {
7
9
  emitUiPromptEvent,
8
10
  type PermissionEventBus,
@@ -45,7 +47,30 @@ export class LocalUserAuthorizer implements Authorizer {
45
47
  ? "Permission Required (Subagent)"
46
48
  : "Permission Required",
47
49
  details.message,
48
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
50
+ buildRequestOptions(details),
49
51
  );
50
52
  }
51
53
  }
54
+
55
+ /**
56
+ * A forwarded ask carrying a session-approval suggestion offers the scope
57
+ * choice (subagent vs whole session); any other ask keeps its single
58
+ * "for this session" option (custom label when the gate supplied one).
59
+ */
60
+ function buildRequestOptions(
61
+ details: PromptPermissionDetails,
62
+ ): RequestPermissionOptions | undefined {
63
+ const pattern = details.sessionApproval?.patterns[0];
64
+ if (details.forwarding && details.sessionApproval && pattern) {
65
+ return {
66
+ sessionScope: buildForwardedScopeLabels(
67
+ details.forwarding.requesterAgentName,
68
+ details.sessionApproval.surface,
69
+ pattern,
70
+ ),
71
+ };
72
+ }
73
+ return details.sessionLabel
74
+ ? { sessionLabel: details.sessionLabel }
75
+ : undefined;
76
+ }
@@ -1,6 +1,7 @@
1
1
  export type PermissionDecisionState =
2
2
  | "approved"
3
3
  | "approved_for_session"
4
+ | "approved_for_serving_session"
4
5
  | "denied"
5
6
  | "denied_with_reason";
6
7
 
@@ -67,6 +68,7 @@ export function isPermissionDecisionState(
67
68
  return (
68
69
  value === "approved" ||
69
70
  value === "approved_for_session" ||
71
+ value === "approved_for_serving_session" ||
70
72
  value === "denied" ||
71
73
  value === "denied_with_reason"
72
74
  );
@@ -75,6 +77,15 @@ export function isPermissionDecisionState(
75
77
  export interface RequestPermissionOptions {
76
78
  /** Override the "for this session" option label (e.g. to show the suggested pattern). */
77
79
  sessionLabel?: string;
80
+ /**
81
+ * Forwarded asks only: when set, choosing the "for this session" option opens
82
+ * a second select asking whether the grant applies to the requesting subagent
83
+ * only (the least-privilege default) or the whole serving session.
84
+ */
85
+ sessionScope?: {
86
+ subagentLabel: string;
87
+ servingSessionLabel: string;
88
+ };
78
89
  }
79
90
 
80
91
  export async function requestPermissionDecisionFromUi(
@@ -103,6 +114,21 @@ export async function requestPermissionDecisionFromUi(
103
114
  }
104
115
 
105
116
  if (selected === sessionOption) {
117
+ if (options?.sessionScope) {
118
+ const scope = await ui.select(`${title}\nApply this session grant to:`, [
119
+ options.sessionScope.subagentLabel,
120
+ options.sessionScope.servingSessionLabel,
121
+ ]);
122
+ return {
123
+ approved: true,
124
+ // A cancelled scope select (undefined) falls back to the
125
+ // least-privilege subagent scope.
126
+ state:
127
+ scope === options.sessionScope.servingSessionLabel
128
+ ? "approved_for_serving_session"
129
+ : "approved_for_session",
130
+ };
131
+ }
106
132
  return {
107
133
  approved: true,
108
134
  state: "approved_for_session",
@@ -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;
@@ -53,6 +52,20 @@ export interface ForwardedPromptDisplay {
53
52
  value: string | null;
54
53
  }
55
54
 
55
+ /**
56
+ * The child's session-approval suggestion, relayed to the serving node so a
57
+ * human who grants "the whole session" records the same pattern the child
58
+ * would have recorded locally.
59
+ *
60
+ * A plain data shape (not the `SessionApproval` value object) so it serializes
61
+ * onto the forwarded request; the serving node rebuilds a `SessionApproval`
62
+ * from it via `SessionApproval.multiple`.
63
+ */
64
+ export interface ForwardedSessionApproval {
65
+ surface: string;
66
+ patterns: readonly string[];
67
+ }
68
+
56
69
  export type ForwardedPermissionRequest = {
57
70
  id: string;
58
71
  createdAt: number;
@@ -69,6 +82,13 @@ export type ForwardedPermissionRequest = {
69
82
  source?: PermissionUiPromptSource;
70
83
  surface?: string | null;
71
84
  value?: string | null;
85
+ /**
86
+ * The child's session-approval suggestion. Present when the child computed a
87
+ * "for this session" pattern for the ask; lets the serving node record a
88
+ * whole-session grant. Optional for version-skew tolerance (an older child
89
+ * omits it, and the serving dialog then offers no scope choice).
90
+ */
91
+ sessionApproval?: ForwardedSessionApproval;
72
92
  };
73
93
 
74
94
  export type ForwardedPermissionResponse = {
@@ -1,4 +1,5 @@
1
- import type { PermissionPromptDecision } from "#src/permission-dialog";
1
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
+ import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
2
3
  import type { ReviewLogger } from "#src/session-logger";
3
4
  import type { Authorizer } from "./authorizer";
4
5
 
@@ -38,6 +39,13 @@ export interface PromptPermissionDetails {
38
39
  value?: string | null;
39
40
  /** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
40
41
  forwarding?: ForwardedAskProvenance;
42
+ /**
43
+ * The session-approval suggestion for this ask. On the child's escalation it
44
+ * rides into the forwarded request; on the serving node it lets the dialog
45
+ * offer a whole-session grant scope. Absent when the gate computed no
46
+ * suggestion.
47
+ */
48
+ sessionApproval?: ForwardedSessionApproval;
41
49
  }
42
50
 
43
51
  /**
@@ -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";
@@ -147,6 +147,9 @@ export class GateRunner {
147
147
  const decision = await this.prompter.escalate({
148
148
  requestId: toolCallId,
149
149
  ...descriptor.promptDetails,
150
+ ...(descriptor.sessionApproval
151
+ ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
152
+ : {}),
150
153
  });
151
154
  autoApproved = decision.autoApproved === true;
152
155
  confirmationUnavailable = decision.confirmationUnavailable === true;
@@ -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
  /**