@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
@@ -62,6 +62,35 @@ export function suggestMcpPattern(target: string): string {
62
62
  return "*";
63
63
  }
64
64
 
65
+ /** Scope labels for the forwarded-approval two-step scope select. */
66
+ export interface ForwardedScopeLabels {
67
+ /** Least-privilege default: record on the requesting subagent only. */
68
+ subagentLabel: string;
69
+ /** Record on the serving node — covers the parent and all subagents. */
70
+ servingSessionLabel: string;
71
+ }
72
+
73
+ /**
74
+ * Build the two scope labels shown when a human grants a forwarded request
75
+ * "for this session."
76
+ *
77
+ * The subagent option names the requester (least privilege); the whole-session
78
+ * option restates the surface + pattern being granted session-wide.
79
+ */
80
+ export function buildForwardedScopeLabels(
81
+ agentName: string | null,
82
+ surface: string,
83
+ pattern: string,
84
+ ): ForwardedScopeLabels {
85
+ const subagentLabel = agentName
86
+ ? `This subagent ('${agentName}') only`
87
+ : "This subagent only";
88
+ return {
89
+ subagentLabel,
90
+ servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
91
+ };
92
+ }
93
+
65
94
  /** Surface-aware human-readable labels for the session-approval option. */
66
95
  function buildLabel(pattern: string, surface: string): string {
67
96
  switch (surface) {
@@ -1,4 +1,4 @@
1
- import type { PermissionPromptDecision } from "./permission-dialog";
1
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
2
 
3
3
  /** Result of applying the permission gate. */
4
4
  export type PermissionGateResult =
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "./access-intent/access-intent";
3
+ import { classifyToolKind } from "./access-intent/tool-kind";
3
4
  import {
4
5
  getGlobalConfigPath,
5
6
  getProjectAgentsDir,
@@ -7,6 +8,7 @@ import {
7
8
  } from "./config-paths";
8
9
  import { normalizeInput } from "./input-normalizer";
9
10
  import { normalizeFlatConfig } from "./normalize";
11
+ import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
10
12
  import { PATH_SURFACES } from "./path-surfaces";
11
13
  import {
12
14
  FilePolicyLoader,
@@ -37,15 +39,6 @@ import type {
37
39
  import { isPermissionState } from "./types";
38
40
  import { wildcardMatch } from "./wildcard-matcher";
39
41
 
40
- const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
41
- "bash",
42
- "read",
43
- "write",
44
- "edit",
45
- "grep",
46
- "find",
47
- "ls",
48
- ]);
49
42
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
50
43
 
51
44
  /** Universal fallback when permission["*"] is absent from all scopes. */
@@ -109,11 +102,12 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
109
102
  */
110
103
  agentDir?: string;
111
104
  /**
112
- * Host platform, injected from the composition root, that decides whether
113
- * path-surface rule matching folds case (and separators) on Windows.
114
- * Defaults to a POSIX flavor; production always supplies the real platform.
105
+ * Resolved path-language flavor, injected from the composition root, that
106
+ * decides whether path-surface rule matching folds case (and separators) on
107
+ * Windows. Defaults to the POSIX flavor; production always supplies the real
108
+ * platform's flavor.
115
109
  */
116
- platform?: NodeJS.Platform;
110
+ flavor?: PathFlavor;
117
111
  /**
118
112
  * yolo-mode reader, injected from the composition root. When it reports
119
113
  * true, {@link PermissionManager.check} rewrites every matched `ask` to a
@@ -126,7 +120,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
126
120
 
127
121
  export class PermissionManager implements ScopedPermissionManager {
128
122
  private readonly agentDir: string | undefined;
129
- private readonly platform: NodeJS.Platform;
123
+ private readonly flavor: PathFlavor;
130
124
  private readonly isYoloEnabled: () => boolean;
131
125
  private loader: PolicyLoader;
132
126
  private readonly resolvedPermissionsCache = new Map<
@@ -136,7 +130,7 @@ export class PermissionManager implements ScopedPermissionManager {
136
130
 
137
131
  constructor(options: PermissionManagerOptions = {}) {
138
132
  this.agentDir = options.agentDir;
139
- this.platform = options.platform ?? "linux";
133
+ this.flavor = options.flavor ?? posixPathFlavor;
140
134
  this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
141
135
  this.loader =
142
136
  options.policyLoader ??
@@ -270,7 +264,7 @@ export class PermissionManager implements ScopedPermissionManager {
270
264
  .map((r) => r.pattern);
271
265
  if (patterns.length === 0) return NO_PROMOTION;
272
266
 
273
- const matchOptions = pathMatchOptions("path", this.platform);
267
+ const matchOptions = pathMatchOptions("path", this.flavor);
274
268
  return (token) =>
275
269
  patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
276
270
  }
@@ -281,29 +275,10 @@ export class PermissionManager implements ScopedPermissionManager {
281
275
  */
282
276
  getToolPermission(toolName: string, agentName?: string): PermissionState {
283
277
  const { composedRules } = this.resolvePermissions(agentName);
284
- const normalizedToolName = toolName.trim();
285
-
286
- // Special surfaces (external_directory): evaluate directly by surface name.
287
- if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
288
- return evaluate(normalizedToolName, "*", composedRules, this.platform)
289
- .action;
290
- }
291
-
292
- // Bash, MCP, skill: evaluate with "*" value — the per-surface catch-all
293
- // (or universal default) handles this correctly.
294
- if (normalizedToolName === "bash") {
295
- return evaluate("bash", "*", composedRules, this.platform).action;
296
- }
297
- if (normalizedToolName === "mcp") {
298
- return evaluate("mcp", "*", composedRules, this.platform).action;
299
- }
300
- if (normalizedToolName === "skill") {
301
- return evaluate("skill", "*", composedRules, this.platform).action;
302
- }
303
-
304
- // Tool-name surfaces (read, write, etc. and extension tools).
305
- return evaluate(normalizedToolName, "*", composedRules, this.platform)
306
- .action;
278
+ // Every surface (special, bash, mcp, skill, path-bearing, and extension
279
+ // tools) resolves its tool-level state identically: evaluate the surface
280
+ // name against the "*" catch-all value. There is no per-kind branch.
281
+ return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
307
282
  }
308
283
 
309
284
  /**
@@ -345,7 +320,7 @@ export class PermissionManager implements ScopedPermissionManager {
345
320
  intent.surface,
346
321
  intent.surface,
347
322
  fullRules,
348
- this.platform,
323
+ this.flavor,
349
324
  );
350
325
  }
351
326
 
@@ -363,7 +338,7 @@ export class PermissionManager implements ScopedPermissionManager {
363
338
  toolName,
364
339
  intent.surface,
365
340
  fullRules,
366
- this.platform,
341
+ this.flavor,
367
342
  );
368
343
  }
369
344
  }
@@ -382,16 +357,18 @@ function buildCheckResult(
382
357
  normalizedToolName: string,
383
358
  toolName: string,
384
359
  fullRules: Ruleset,
385
- platform: NodeJS.Platform,
360
+ flavor: PathFlavor,
386
361
  ): PermissionCheckResult {
387
362
  const { rule, value } = PATH_SURFACES.has(surface)
388
- ? evaluateAnyValue(surface, values, fullRules, platform)
389
- : evaluateFirst(surface, values, fullRules, platform);
363
+ ? evaluateAnyValue(surface, values, fullRules, flavor)
364
+ : evaluateFirst(surface, values, fullRules, flavor);
390
365
 
391
366
  // For MCP, replace the normalizer's fallback target with the actual
392
367
  // matched candidate value so PermissionCheckResult.target is accurate.
393
368
  const extras =
394
- surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
369
+ classifyToolKind(surface) === "mcp"
370
+ ? { ...resultExtras, target: value }
371
+ : resultExtras;
395
372
 
396
373
  return {
397
374
  toolName,
@@ -444,19 +421,22 @@ function deriveSource(
444
421
  toolName: string,
445
422
  ): PermissionCheckResult["source"] {
446
423
  if (rule.layer === "session") return "session";
447
-
448
- if (toolName === "mcp") {
449
- if (rule.layer === "default") return "default";
450
- return "mcp";
451
- }
452
-
453
424
  if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
454
- if (toolName === "skill") return "skill";
455
- if (toolName === "bash") return "bash";
456
425
 
457
- // Built-in tools always report "tool"; extension tools distinguish default.
458
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
459
- return rule.layer === "default" ? "default" : "tool";
426
+ switch (classifyToolKind(toolName)) {
427
+ case "mcp":
428
+ return rule.layer === "default" ? "default" : "mcp";
429
+ case "skill":
430
+ return "skill";
431
+ case "bash":
432
+ return "bash";
433
+ case "path":
434
+ // Built-in path-bearing tools (read/write/edit/grep/find/ls).
435
+ return "tool";
436
+ case "extension":
437
+ // Extension tools distinguish a synthesized-default match from a rule.
438
+ return rule.layer === "default" ? "default" : "tool";
439
+ }
460
440
  }
461
441
 
462
442
  // Re-export types that external modules import from this file.
@@ -1,3 +1,4 @@
1
+ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
1
2
  import { matchQualifier } from "./denial-messages";
2
3
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
3
4
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
@@ -22,7 +23,7 @@ export function formatUnknownToolReason(
22
23
  preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
23
24
 
24
25
  const mcpHint =
25
- toolName === "mcp"
26
+ classifyToolKind(toolName) === "mcp"
26
27
  ? ""
27
28
  : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
28
29
 
@@ -37,7 +38,7 @@ export function formatAskPrompt(
37
38
  ): string {
38
39
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
39
40
 
40
- if (result.toolName === "bash") {
41
+ if (classifyToolKind(result.toolName) === "bash") {
41
42
  const subCommand = result.command ?? "";
42
43
  const qualifier = matchQualifier(
43
44
  result.matchedPattern,
@@ -52,7 +53,7 @@ export function formatAskPrompt(
52
53
  return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
53
54
  }
54
55
 
55
- if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
56
+ if (isMcpCheck(result) && result.target) {
56
57
  const patternInfo = result.matchedPattern
57
58
  ? ` (matched '${result.matchedPattern}')`
58
59
  : "";
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ForwardingController } from "#src/authority/forwarding-manager";
2
3
  import {
3
4
  getActiveAgentName,
4
5
  getActiveAgentNameFromSystemPrompt,
@@ -7,8 +8,8 @@ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-select
7
8
  import type { SessionConfigStore } from "./config-store";
8
9
  import type { PermissionSystemExtensionConfig } from "./extension-config";
9
10
  import type { ExtensionPaths } from "./extension-paths";
10
- import type { ForwardingController } from "./forwarding-manager";
11
11
  import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
12
+ import type { PathFlavor } from "./path/path-flavor";
12
13
  import { PathNormalizer } from "./path-normalizer";
13
14
  import type { ScopedPermissionManager } from "./permission-manager";
14
15
 
@@ -47,12 +48,12 @@ export class PermissionSession implements ToolCallGateInputs {
47
48
  private readonly sessionRules: SessionRules,
48
49
  private readonly configStore: SessionConfigStore,
49
50
  private readonly authorizerSelection: AuthorizerSelectionLifecycle,
50
- private readonly platform: NodeJS.Platform,
51
+ private readonly flavor: PathFlavor,
51
52
  ) {
52
53
  // Placeholder until the first activate(ctx) binds the real cwd; every gate
53
54
  // evaluate runs after activate (handleToolCall activates first), so this
54
55
  // empty-cwd value is never read.
55
- this.pathNormalizer = new PathNormalizer(platform, "");
56
+ this.pathNormalizer = new PathNormalizer(flavor, "");
56
57
  }
57
58
 
58
59
  // ── Context lifecycle ──────────────────────────────────────────────────
@@ -68,7 +69,7 @@ export class PermissionSession implements ToolCallGateInputs {
68
69
  */
69
70
  activate(ctx: ExtensionContext): void {
70
71
  this.context = ctx;
71
- this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
72
+ this.pathNormalizer = new PathNormalizer(this.flavor, ctx.cwd);
72
73
  this.forwarding.start(ctx);
73
74
  this.authorizerSelection.activate(ctx);
74
75
  }
@@ -207,7 +208,7 @@ export class PermissionSession implements ToolCallGateInputs {
207
208
  // ── Path normalization ────────────────────────────────────────────────
208
209
 
209
210
  /**
210
- * The session's {@link PathNormalizer}, carrying the host platform and the
211
+ * The session's {@link PathNormalizer}, carrying the host path flavor and the
211
212
  * session cwd. Rebuilt on every `resetForNewSession` so a session switch
212
213
  * rebinds the cwd.
213
214
  */
@@ -1,4 +1,5 @@
1
1
  import type { AccessIntent } from "./access-intent/access-intent";
2
+ import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
2
3
  import { buildAccessIntentForSurface } from "./input-normalizer";
3
4
  import type { PathNormalizer } from "./path-normalizer";
4
5
  import type { PermissionsService } from "./service";
@@ -50,6 +51,13 @@ export class LocalPermissionsService implements PermissionsService {
50
51
  value?: string,
51
52
  agentName?: string,
52
53
  ): ReturnType<PermissionsService["checkPermission"]> {
54
+ // Bash decomposes at gate parity: a chained/nested command is split into
55
+ // its command-pattern units and resolved most-restrictive, matching what
56
+ // the enforcement gate enforces (#309). A cold parser falls back to the
57
+ // whole-string match inside resolveBashAdvisoryCheck.
58
+ if (surface === "bash") {
59
+ return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
60
+ }
53
61
  const intent = buildAccessIntentForSurface(
54
62
  surface,
55
63
  value,
package/src/rule.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+
1
3
  import { PATH_SURFACES } from "./path-surfaces";
2
4
  import type { PermissionState } from "./types";
3
- import { wildcardMatch } from "./wildcard-matcher";
5
+ import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
4
6
 
5
7
  /**
6
8
  * Provenance of a rule — which source contributed it.
@@ -74,12 +76,10 @@ export function evaluate(
74
76
  surface: string,
75
77
  pattern: string,
76
78
  rules: Ruleset,
77
- platform: NodeJS.Platform,
79
+ flavor: PathFlavor,
78
80
  defaultAction?: PermissionState,
79
81
  ): Rule {
80
- const rule = rules.findLast((r) =>
81
- ruleMatches(r, surface, pattern, platform),
82
- );
82
+ const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor));
83
83
  if (rule !== undefined) return rule;
84
84
  return {
85
85
  surface,
@@ -96,20 +96,18 @@ export function evaluate(
96
96
  */
97
97
  export function pathMatchOptions(
98
98
  surface: string,
99
- platform: NodeJS.Platform,
100
- ): { caseInsensitive: true; windowsSeparators: true } | undefined {
101
- return platform === "win32" && PATH_SURFACES.has(surface)
102
- ? { caseInsensitive: true, windowsSeparators: true }
103
- : undefined;
99
+ flavor: PathFlavor,
100
+ ): WildcardMatchOptions | undefined {
101
+ return PATH_SURFACES.has(surface) ? flavor.matchOptions : undefined;
104
102
  }
105
103
 
106
104
  function ruleMatches(
107
105
  rule: Rule,
108
106
  surface: string,
109
107
  value: string,
110
- platform: NodeJS.Platform,
108
+ flavor: PathFlavor,
111
109
  ): boolean {
112
- const matchOptions = pathMatchOptions(surface, platform);
110
+ const matchOptions = pathMatchOptions(surface, flavor);
113
111
  return (
114
112
  wildcardMatch(rule.surface, surface) &&
115
113
  wildcardMatch(rule.pattern, value, matchOptions)
@@ -144,11 +142,11 @@ export function evaluateMostRestrictive(
144
142
  surface: string,
145
143
  values: string[],
146
144
  rules: Ruleset,
147
- platform: NodeJS.Platform,
145
+ flavor: PathFlavor,
148
146
  ): { rule: Rule; value: string } | null {
149
147
  let worst: { rule: Rule; value: string } | null = null;
150
148
  for (const value of values) {
151
- const rule = evaluate(surface, value, rules, platform);
149
+ const rule = evaluate(surface, value, rules, flavor);
152
150
  if (rule.action === "deny") return { rule, value };
153
151
  if (rule.action === "ask" && worst?.rule.action !== "ask") {
154
152
  worst = { rule, value };
@@ -161,10 +159,10 @@ export function evaluateFirst(
161
159
  surface: string,
162
160
  values: string[],
163
161
  rules: Ruleset,
164
- platform: NodeJS.Platform,
162
+ flavor: PathFlavor,
165
163
  ): { rule: Rule; value: string } {
166
164
  for (const value of values) {
167
- const rule = evaluate(surface, value, rules, platform);
165
+ const rule = evaluate(surface, value, rules, flavor);
168
166
  if (rule.layer !== "default") {
169
167
  return { rule, value };
170
168
  }
@@ -172,7 +170,7 @@ export function evaluateFirst(
172
170
  // All candidates matched only the synthesized default — use the first.
173
171
  const fallbackValue = values[0] ?? "*";
174
172
  return {
175
- rule: evaluate(surface, fallbackValue, rules, platform),
173
+ rule: evaluate(surface, fallbackValue, rules, flavor),
176
174
  value: fallbackValue,
177
175
  };
178
176
  }
@@ -189,22 +187,22 @@ export function evaluateAnyValue(
189
187
  surface: string,
190
188
  values: string[],
191
189
  rules: Ruleset,
192
- platform: NodeJS.Platform,
190
+ flavor: PathFlavor,
193
191
  ): { rule: Rule; value: string } {
194
192
  const fallbackValue = values[0] ?? "*";
195
193
  const rule = rules.findLast((r) =>
196
- values.some((value) => ruleMatches(r, surface, value, platform)),
194
+ values.some((value) => ruleMatches(r, surface, value, flavor)),
197
195
  );
198
196
  if (rule !== undefined) {
199
197
  return {
200
198
  rule,
201
199
  value:
202
- values.find((value) => ruleMatches(rule, surface, value, platform)) ??
200
+ values.find((value) => ruleMatches(rule, surface, value, flavor)) ??
203
201
  fallbackValue,
204
202
  };
205
203
  }
206
204
  return {
207
- rule: evaluate(surface, fallbackValue, rules, platform),
205
+ rule: evaluate(surface, fallbackValue, rules, flavor),
208
206
  value: fallbackValue,
209
207
  };
210
208
  }
@@ -1,3 +1,5 @@
1
+ import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
2
+
1
3
  /**
2
4
  * Value object for a session-scoped approval: one surface, one-or-more patterns.
3
5
  *
@@ -40,4 +42,13 @@ export class SessionApproval {
40
42
  if (pattern === undefined) return undefined;
41
43
  return { surface: this.surface, pattern };
42
44
  }
45
+
46
+ /**
47
+ * Plain data shape for relaying this approval on a forwarded request, so the
48
+ * serving node can record the same pattern(s) as a whole-session grant.
49
+ * Returns a defensive copy of the patterns.
50
+ */
51
+ toForwardedData(): ForwardedSessionApproval {
52
+ return { surface: this.surface, patterns: [...this.patterns] };
53
+ }
43
54
  }
@@ -1,4 +1,4 @@
1
- import { PATH_BEARING_TOOLS } from "./path-surfaces";
1
+ import { classifyToolKind } from "./access-intent/tool-kind";
2
2
  import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
3
3
  import { getNonEmptyString, toRecord } from "./value-guards";
4
4
 
@@ -6,7 +6,7 @@ export function getPathBearingToolPath(
6
6
  toolName: string,
7
7
  input: unknown,
8
8
  ): string | null {
9
- if (!PATH_BEARING_TOOLS.has(toolName)) {
9
+ if (classifyToolKind(toolName) !== "path") {
10
10
  return null;
11
11
  }
12
12
 
@@ -31,24 +31,22 @@ export function getToolInputPath(
31
31
  input: unknown,
32
32
  extractors?: ToolAccessExtractorLookup,
33
33
  ): string | null {
34
- if (toolName === "bash") {
35
- return null;
36
- }
37
-
38
34
  const record = toRecord(input);
39
35
 
40
- if (PATH_BEARING_TOOLS.has(toolName)) {
41
- return getNonEmptyString(record.path);
42
- }
43
-
44
- if (toolName === "mcp") {
45
- return getNonEmptyString(toRecord(record.arguments).path);
46
- }
47
-
48
- const custom = extractors?.get(toolName);
49
- if (custom) {
50
- return getNonEmptyString(custom(record));
36
+ switch (classifyToolKind(toolName)) {
37
+ case "bash":
38
+ return null;
39
+ case "path":
40
+ return getNonEmptyString(record.path);
41
+ case "mcp":
42
+ return getNonEmptyString(toRecord(record.arguments).path);
43
+ case "skill":
44
+ case "extension": {
45
+ const custom = extractors?.get(toolName);
46
+ if (custom) {
47
+ return getNonEmptyString(custom(record));
48
+ }
49
+ return getNonEmptyString(record.path);
50
+ }
51
51
  }
52
-
53
- return getNonEmptyString(record.path);
54
52
  }
@@ -1,3 +1,4 @@
1
+ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
1
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
2
3
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
3
4
  import {
@@ -158,11 +159,7 @@ export class ToolPreviewFormatter {
158
159
  input: unknown,
159
160
  pathBearingTools: ReadonlySet<string>,
160
161
  ): string | undefined {
161
- if (
162
- result.toolName === "bash" ||
163
- result.toolName === "mcp" ||
164
- result.source === "mcp"
165
- ) {
162
+ if (classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)) {
166
163
  return undefined;
167
164
  }
168
165
 
@@ -1,56 +0,0 @@
1
- import { posix as posixPath, win32 as winPath } from "node:path";
2
-
3
- import { isSafeSystemPath } from "./safe-system-paths";
4
-
5
- /**
6
- * Returns true when `pathValue` is `directory` itself or nested inside it.
7
- *
8
- * Containment is decided with Node's platform-native `path.relative` rather
9
- * than a hand-rolled prefix check: on `win32` the comparison folds case (and
10
- * tolerates either separator), matching the case-insensitive filesystem.
11
- * `platform` is injected from the composition root so Windows behavior is
12
- * testable on a POSIX CI.
13
- */
14
- export function isPathWithinDirectory(
15
- pathValue: string,
16
- directory: string,
17
- platform: NodeJS.Platform,
18
- ): boolean {
19
- if (!pathValue || !directory) {
20
- return false;
21
- }
22
-
23
- if (pathValue === directory) {
24
- return true;
25
- }
26
-
27
- const impl = platform === "win32" ? winPath : posixPath;
28
- const rel = impl.relative(directory, pathValue);
29
- return (
30
- rel !== "" &&
31
- rel !== ".." &&
32
- !rel.startsWith(`..${impl.sep}`) &&
33
- !impl.isAbsolute(rel)
34
- );
35
- }
36
-
37
- /**
38
- * Pure geometry: is `canonicalPath` outside `canonicalCwd`?
39
- *
40
- * Both operands must already be canonical (symlink-resolved, win32-lowercased)
41
- * — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
42
- * This predicate touches no filesystem and does no derivation.
43
- */
44
- export function isPathOutsideWorkingDirectory(
45
- canonicalPath: string,
46
- canonicalCwd: string,
47
- platform: NodeJS.Platform,
48
- ): boolean {
49
- if (!canonicalCwd || !canonicalPath) {
50
- return false;
51
- }
52
- if (isSafeSystemPath(canonicalPath)) {
53
- return false;
54
- }
55
- return !isPathWithinDirectory(canonicalPath, canonicalCwd, platform);
56
- }