@gotgenes/pi-permission-system 36.2.1 → 38.0.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.
@@ -1,16 +1,20 @@
1
1
  import type {
2
2
  BeforeAgentStartEventResult,
3
3
  ExtensionContext,
4
+ NormalizedBuildSystemPromptOptions,
4
5
  } from "@earendil-works/pi-coding-agent";
5
6
  import type { SubagentDetector } from "#src/authority/subagent-detection";
6
- import { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
7
+ import {
8
+ visibleSkillPromptEntries,
9
+ withoutDeniedSkills,
10
+ } from "#src/exposure/skill-prompt-sanitizer";
7
11
  import {
8
12
  type RegisteredTools,
9
13
  readRegisteredTools,
10
14
  type ToolRegistry,
11
15
  } from "#src/exposure/tool-registry";
12
16
  import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
13
- import { renderToolSurface } from "#src/exposure/tool-surface-prompt";
17
+ import { renderToolSurfaceSections } from "#src/exposure/tool-surface-prompt";
14
18
  import type { DebugLogger } from "#src/logging/session-logger";
15
19
  import type { PermissionResolver } from "#src/policy/permission-resolver";
16
20
  import type { PermissionSession } from "#src/session/permission-session";
@@ -18,22 +22,20 @@ import type { TurnPreparation } from "./session-turn-prep";
18
22
 
19
23
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
20
24
  interface BeforeAgentStartPayload {
21
- systemPrompt: string;
25
+ /** The prompt rendered from `systemPromptOptions` as it stands right now. */
26
+ readonly systemPrompt: string;
22
27
  /**
23
- * The parts Pi assembled the prompt from. `toolSnippets` is what lets this
24
- * handler render the session's own tool list instead of editing the one Pi
25
- * wrote — including in a child, whose inherited identity carries none.
26
- * `customPrompt` says whether Pi wrote a preamble at all: under one, it
27
- * writes no tool surface, so there is nothing of Pi's to remove, and a root
28
- * node adds none either.
29
- * `promptGuidelines` carries rules other extensions added, which removing
30
- * Pi's own rules section would otherwise drop.
28
+ * The mutable parts Pi renders the prompt from; later handlers, and Pi
29
+ * itself, see what this handler writes here. `customPrompt` says whether Pi
30
+ * wrote a preamble at all: under one, it writes no tool surface.
31
+ * `toolSnippets` and `promptGuidelines` are what a child's own tool surface
32
+ * renders from, and `sections` is where it is stated. `skills` is the
33
+ * catalogue Pi renders, which policy narrows.
31
34
  */
32
- systemPromptOptions?: {
33
- customPrompt?: string;
34
- toolSnippets?: Record<string, string>;
35
- promptGuidelines?: readonly string[];
36
- };
35
+ systemPromptOptions: Pick<
36
+ NormalizedBuildSystemPromptOptions,
37
+ "customPrompt" | "toolSnippets" | "promptGuidelines" | "sections" | "skills"
38
+ >;
37
39
  }
38
40
 
39
41
  /**
@@ -54,17 +56,16 @@ export function shouldExposeTool(
54
56
  /**
55
57
  * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
56
58
  *
57
- * Recomputes the active tool set and the returned system-prompt override on
58
- * every fire (no memoization): the override must be returned each turn so that
59
- * skill filtering is reapplied and the wire prompt stays stable across turns,
60
- * rather than letting Pi reset to its skill-unfiltered base prompt on a cache
61
- * hit.
59
+ * Every change to the prompt is stated through `event.systemPromptOptions`,
60
+ * never as a returned `systemPrompt`: a returned prompt is frozen for the run,
61
+ * so the sections an extension later in the chain adds (Pi's own
62
+ * `<mcp_servers>` among them) would never reach the provider (#999).
62
63
  *
63
- * The tool surface is relocated rather than edited in place, so a subagent
64
- * child's inherited identity stays byte-identical to its parent's (#890).
65
- * A root node whose prompt Pi built from a custom one states no tool surface,
66
- * matching Pi, which writes none there; a subagent child's prompt is always a
67
- * custom one, and it still states its own tools.
64
+ * Narrowing the active set is enough for a prompt Pi wrote: Pi renders its
65
+ * `<tools>` and `<rules>` from the reconciled active set. A subagent child's
66
+ * prompt is always a custom one, under which Pi writes neither, so a child
67
+ * states its own as `sections`, which Pi places after its `<cwd>` section. A
68
+ * root under a custom prompt states none, matching Pi.
68
69
  *
69
70
  * Constructor deps:
70
71
  * - `turnPrep` — brings the node up to date for the turn before anything reads
@@ -116,40 +117,53 @@ export class AgentPrepHandler {
116
117
  });
117
118
  }
118
119
 
119
- const toolSurfacePrompt = this.statesOwnToolSurface(event, ctx)
120
- ? renderToolSurface(event.systemPrompt, {
121
- allowedTools,
122
- toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
123
- guidelinesByTool: registered.guidelinesByTool,
124
- promptGuidelines: event.systemPromptOptions?.promptGuidelines ?? [],
125
- piAuthoredPreamble: !hasCustomPrompt(event),
126
- })
127
- : event.systemPrompt;
128
- const skillPromptResult = resolveSkillPromptEntries(
129
- toolSurfacePrompt,
120
+ const options = event.systemPromptOptions;
121
+ if (this.isSubagentUnderCustomPrompt(event, ctx)) {
122
+ const sections = renderToolSurfaceSections({
123
+ allowedTools,
124
+ toolSnippets: options.toolSnippets,
125
+ guidelinesByTool: registered.guidelinesByTool,
126
+ promptGuidelines: options.promptGuidelines,
127
+ });
128
+ // An empty section is one Pi leaves out, so this also clears a stale peer list.
129
+ options.sections.tools = sections.tools ?? "";
130
+ options.sections.rules = sections.rules;
131
+ }
132
+
133
+ // Path-match entries come from every catalogue the rendered prompt lists,
134
+ // read before the skill list is narrowed.
135
+ this.session.setActiveSkillEntries(
136
+ visibleSkillPromptEntries(
137
+ event.systemPrompt,
138
+ this.resolver,
139
+ agentName,
140
+ this.session.getPathNormalizer(),
141
+ ),
142
+ );
143
+ // Denials are judged on the list Pi renders `<skills>` from, not on the
144
+ // rendered prompt: that prompt predates this turn's tool changes, and on
145
+ // the turn `read`/`bash` return from a full denial it lists no catalogue.
146
+ options.skills = withoutDeniedSkills(
147
+ options.skills,
130
148
  this.resolver,
131
149
  agentName,
132
- this.session.getPathNormalizer(),
133
150
  );
134
- this.session.setActiveSkillEntries(skillPromptResult.entries);
135
- return skillPromptResult.prompt !== event.systemPrompt
136
- ? { systemPrompt: skillPromptResult.prompt }
137
- : {};
151
+ return {};
138
152
  }
139
153
 
140
154
  /**
141
- * Whether this node renders its own tool surface into the prompt.
155
+ * Whether this node states its own tool surface through `sections`.
142
156
  *
143
- * Every node does except a root whose prompt Pi built from a custom one:
144
- * there Pi writes no tool list or rules, and the operator's prompt is left
145
- * as Pi built it. A subagent child's prompt is always a custom one, and its
146
- * inherited identity carries no tool list, so it still states its own.
157
+ * Only a subagent child does: its prompt is always a custom one, under which
158
+ * Pi writes no tool list or rules, and its inherited identity carries none
159
+ * either. A root needs none — Pi renders its own from the narrowed active
160
+ * set, or, under an operator's custom prompt, deliberately writes none.
147
161
  */
148
- private statesOwnToolSurface(
162
+ private isSubagentUnderCustomPrompt(
149
163
  event: BeforeAgentStartPayload,
150
164
  ctx: ExtensionContext,
151
165
  ): boolean {
152
- return !hasCustomPrompt(event) || this.detector.isSubagent(ctx);
166
+ return hasCustomPrompt(event) && this.detector.isSubagent(ctx);
153
167
  }
154
168
 
155
169
  private observeToolSurface(
@@ -167,5 +181,5 @@ export class AgentPrepHandler {
167
181
  * test, so an empty string reads here the way it reads there: as none.
168
182
  */
169
183
  function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
170
- return Boolean(event.systemPromptOptions?.customPrompt);
184
+ return Boolean(event.systemPromptOptions.customPrompt);
171
185
  }
@@ -116,6 +116,7 @@ export function deriveDecisionValue(
116
116
  case "bash":
117
117
  return check.command ?? toolName;
118
118
  case "mcp":
119
+ case "mcp-tool":
119
120
  return check.target ?? toolName;
120
121
  case "path":
121
122
  case "skill":
@@ -6,6 +6,8 @@ import {
6
6
  type ShellInvocation,
7
7
  } from "#src/access-intent/tool-kind";
8
8
  import {
9
+ type SessionApprovalSuggestion,
10
+ suggestExactSessionPattern,
9
11
  suggestPathSessionPattern,
10
12
  suggestSessionPattern,
11
13
  } from "#src/presentation/pattern-suggest";
@@ -56,6 +58,45 @@ function deriveSuggestionValue(
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * The surface a tool call is gated on.
63
+ *
64
+ * A shell invocation (native `bash` or an aliased shell tool) is gated on the
65
+ * `bash` surface, and a Pi MCP tool on the `mcp` surface — their session rule,
66
+ * decision value, and suggestion are shaped by that surface — while the
67
+ * invoked tool name is preserved in the prompt and review log so a user sees
68
+ * which tool actually ran (#574).
69
+ */
70
+ function gateSurfaceOf(
71
+ toolName: string,
72
+ shell: ShellInvocation | null | undefined,
73
+ ): string {
74
+ if (shell) return "bash";
75
+ if (classifyToolKind(toolName) === "mcp-tool") return "mcp";
76
+ return toolName;
77
+ }
78
+
79
+ /**
80
+ * The session approval for a gate whose value is not a path.
81
+ *
82
+ * A Pi MCP tool is approved by its own full name, which is one of its `mcp`
83
+ * candidates: exactly the tool asked about, never the wider server prefix the
84
+ * proxy target heuristic would suggest.
85
+ */
86
+ function suggestValueSessionPattern(
87
+ toolName: string,
88
+ gateSurface: string,
89
+ check: PermissionCheckResult,
90
+ ): SessionApprovalSuggestion {
91
+ if (classifyToolKind(toolName) === "mcp-tool") {
92
+ return suggestExactSessionPattern(gateSurface, toolName.trim());
93
+ }
94
+ return suggestSessionPattern(
95
+ gateSurface,
96
+ deriveSuggestionValue(gateSurface, check),
97
+ );
98
+ }
99
+
59
100
  /**
60
101
  * Build a pure descriptor for the normal tool permission gate.
61
102
  *
@@ -69,11 +110,7 @@ export function describeToolGate(
69
110
  pathAccess?: ToolPathAccess,
70
111
  shell?: ShellInvocation | null,
71
112
  ): GateDescriptor {
72
- // A shell invocation (native `bash` or an aliased shell tool) is gated on the
73
- // `bash` surface — its session rule, decision value, and suggestion are
74
- // bash-shaped — while the invoked tool name is preserved in the prompt and
75
- // review log so a user sees which tool actually ran (#574).
76
- const gateSurface = shell ? "bash" : tcc.toolName;
113
+ const gateSurface = gateSurfaceOf(tcc.toolName, shell);
77
114
 
78
115
  const permissionLogContext = formatter.getPermissionLogContext(
79
116
  check,
@@ -84,10 +121,7 @@ export function describeToolGate(
84
121
  // Compute session approval suggestion for the "for this session" option.
85
122
  const suggestion = pathAccess
86
123
  ? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
87
- : suggestSessionPattern(
88
- gateSurface,
89
- deriveSuggestionValue(gateSurface, check),
90
- );
124
+ : suggestValueSessionPattern(tcc.toolName, gateSurface, check);
91
125
 
92
126
  const payload = buildToolAskPayload({
93
127
  check,
@@ -1,4 +1,8 @@
1
1
  import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
2
+ import {
3
+ isPiMcpToolName,
4
+ PI_MCP_TOOL_PREFIX,
5
+ } from "#src/access-intent/tool-kind";
2
6
  import type { FlatPermissionConfig, PatternValue } from "#src/types";
3
7
  import { isDenyWithReason, isPermissionState } from "#src/types";
4
8
  import type { Rule, Ruleset } from "./rule";
@@ -115,3 +119,46 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
115
119
  }
116
120
  return rules;
117
121
  }
122
+
123
+ /** The rules with relocated copies appended, and the keys that were copied. */
124
+ export interface McpToolKeyRelocation {
125
+ rules: Ruleset;
126
+ /** Each relocated surface key, in rule order. */
127
+ relocatedKeys: string[];
128
+ }
129
+
130
+ /**
131
+ * Copy the catch-all rule of every top-level key that can name a Pi MCP tool
132
+ * onto the `mcp` surface, with the key as its pattern.
133
+ *
134
+ * A Pi MCP tool (`mcp__<server>__<tool>`) once resolved on its own name, so a
135
+ * top-level key naming it (or a surface wildcard such as `mcp__srv__*`) was the
136
+ * only rule that reached it. The tool now resolves on `mcp`, where its full
137
+ * name is one of its candidates, so the same key is the same rule there.
138
+ *
139
+ * The copies are appended after every other rule, in their original order:
140
+ * the key was the only rule that applied to the tool before, so it keeps the
141
+ * final say over the `mcp` rules that newly reach it. The original rule stays
142
+ * where it was, because a key such as `mcp__*` also matches tools that still
143
+ * resolve on their own name (`mcp__foo`), and moving it would drop them. A key
144
+ * that can name no Pi MCP tool (`mcp__foo`) is not copied, and a non-`*`
145
+ * pattern under any key never matched (a tool surface is evaluated with `*`).
146
+ */
147
+ export function relocateMcpToolKeyRules(rules: Ruleset): McpToolKeyRelocation {
148
+ const relocated = rules
149
+ .filter((rule) => rule.pattern === "*" && canNamePiMcpTool(rule.surface))
150
+ .map((rule): Rule => ({ ...rule, surface: "mcp", pattern: rule.surface }));
151
+ return {
152
+ rules: [...rules, ...relocated],
153
+ relocatedKeys: relocated.map((rule) => rule.pattern),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Whether a top-level surface key can match a Pi MCP tool name: the name
159
+ * itself, or an `mcp__`-prefixed wildcard.
160
+ */
161
+ function canNamePiMcpTool(surfaceKey: string): boolean {
162
+ if (isPiMcpToolName(surfaceKey)) return true;
163
+ return surfaceKey.startsWith(PI_MCP_TOOL_PREFIX) && /[*?]/.test(surfaceKey);
164
+ }
@@ -21,7 +21,7 @@ import type {
21
21
  PermissionState,
22
22
  } from "#src/types";
23
23
  import { isPermissionState } from "#src/types";
24
- import { normalizeFlatConfig } from "./normalize";
24
+ import { normalizeFlatConfig, relocateMcpToolKeyRules } from "./normalize";
25
25
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
26
26
  import {
27
27
  evaluate,
@@ -30,7 +30,7 @@ import {
30
30
  isSurfaceFullyDenied,
31
31
  rewriteAsksToYolo,
32
32
  } from "./rule";
33
- import { mergeScopesWithOrigins } from "./scope-merge";
33
+ import { type MergedScopes, mergeScopesWithOrigins } from "./scope-merge";
34
34
  import {
35
35
  composeRuleset,
36
36
  synthesizeBaseline,
@@ -62,6 +62,11 @@ type ResolvedPermissions = {
62
62
  * names also drive the fail-closed notice in {@link getConfigIssues}.
63
63
  */
64
64
  failClosedScopes: RuleOrigin[];
65
+ /**
66
+ * Top-level permission keys naming Pi MCP tools, relocated onto the `mcp`
67
+ * surface; they drive the port notice in {@link getConfigIssues}.
68
+ */
69
+ legacyMcpToolKeys: string[];
65
70
  };
66
71
 
67
72
  /**
@@ -152,7 +157,8 @@ export class PermissionManager implements ScopedPermissionManager {
152
157
 
153
158
  getConfigIssues(agentName?: string): string[] {
154
159
  // Trigger a load/resolve to ensure issues are collected.
155
- const { failClosedScopes } = this.resolvePermissions(agentName);
160
+ const { failClosedScopes, legacyMcpToolKeys } =
161
+ this.resolvePermissions(agentName);
156
162
  const issues = [...this.loader.getConfigIssues()];
157
163
  if (failClosedScopes.length > 0) {
158
164
  issues.push(
@@ -161,6 +167,9 @@ export class PermissionManager implements ScopedPermissionManager {
161
167
  `until the configuration is corrected.`,
162
168
  );
163
169
  }
170
+ if (legacyMcpToolKeys.length > 0) {
171
+ issues.push(formatMcpToolKeyPortNotice(legacyMcpToolKeys));
172
+ }
164
173
  return issues;
165
174
  }
166
175
 
@@ -201,26 +210,16 @@ export class PermissionManager implements ScopedPermissionManager {
201
210
  const universalFallbackOrigin: RuleOrigin =
202
211
  origins.get("*")?.get("*") ?? "builtin";
203
212
 
204
- // Build config rules from everything except the universal "*" key.
205
- const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
206
- Object.entries(mergedPermission).filter(([k]) => k !== "*"),
207
- );
208
-
209
- // Normalize to config rules, tagged with "config" layer and their origin.
210
- const configRules: Ruleset = normalizeFlatConfig(
211
- permissionWithoutUniversal,
212
- ).map(
213
- (r): Rule => ({
214
- ...r,
215
- layer: "config",
216
- origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
217
- }),
218
- );
213
+ const configRules = buildConfigRules(mergedPermission, origins);
214
+ // A top-level `mcp__…` key now names an `mcp` candidate. The baseline reads
215
+ // the rules as written, so a relocated `allow` on one Pi MCP tool does not
216
+ // newly open the proxy's discovery targets.
217
+ const relocation = relocateMcpToolKeyRules(configRules);
219
218
 
220
219
  const composedRules = composeRuleset(
221
220
  synthesizeDefaults(universalFallback, universalFallbackOrigin),
222
221
  synthesizeBaseline(configRules),
223
- configRules,
222
+ relocation.rules,
224
223
  );
225
224
 
226
225
  // Fail closed when a non-global scope's config is invalid: floor every
@@ -241,6 +240,7 @@ export class PermissionManager implements ScopedPermissionManager {
241
240
  const value: ResolvedPermissions = {
242
241
  composedRules: effectiveRules,
243
242
  failClosedScopes,
243
+ legacyMcpToolKeys: [...new Set(relocation.relocatedKeys)],
244
244
  };
245
245
  this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
246
246
  return value;
@@ -263,10 +263,14 @@ export class PermissionManager implements ScopedPermissionManager {
263
263
  */
264
264
  getToolPermission(toolName: string, agentName?: string): PermissionState {
265
265
  const { composedRules } = this.resolvePermissions(agentName);
266
- // Every surface (special, bash, mcp, skill, path-bearing, and extension
267
- // tools) resolves its tool-level state identically: evaluate the surface
268
- // name against the "*" catch-all value. There is no per-kind branch.
269
- return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
266
+ const name = toolName.trim();
267
+ if (classifyToolKind(name) === "mcp-tool") {
268
+ return this.resolvePiMcpTool(name, composedRules);
269
+ }
270
+ // Every other surface (special, bash, mcp, skill, path-bearing, and
271
+ // extension tools) resolves its tool-level state identically: evaluate the
272
+ // surface name against the "*" catch-all value.
273
+ return evaluate(name, "*", composedRules, this.flavor).action;
270
274
  }
271
275
 
272
276
  /**
@@ -284,7 +288,30 @@ export class PermissionManager implements ScopedPermissionManager {
284
288
  */
285
289
  isToolFullyDenied(toolName: string, agentName?: string): boolean {
286
290
  const { composedRules } = this.resolvePermissions(agentName);
287
- return isSurfaceFullyDenied(toolName.trim(), composedRules, this.flavor);
291
+ const name = toolName.trim();
292
+ if (classifyToolKind(name) === "mcp-tool") {
293
+ return this.resolvePiMcpTool(name, composedRules) === "deny";
294
+ }
295
+ return isSurfaceFullyDenied(name, composedRules, this.flavor);
296
+ }
297
+
298
+ /**
299
+ * The action a Pi MCP tool resolves to on the `mcp` surface.
300
+ *
301
+ * Its candidates come from its name alone, never its input, so this one
302
+ * answer is both its tool-level state and whether it is fully denied.
303
+ */
304
+ private resolvePiMcpTool(
305
+ toolName: string,
306
+ composedRules: Ruleset,
307
+ ): PermissionState {
308
+ const { surface, values } = normalizeInput(
309
+ toolName,
310
+ undefined,
311
+ this.loader.getConfiguredMcpServerNames(),
312
+ );
313
+ return evaluateAnyValue(surface, values, composedRules, this.flavor).rule
314
+ .action;
288
315
  }
289
316
 
290
317
  /**
@@ -389,6 +416,39 @@ function buildCheckResult(
389
416
  };
390
417
  }
391
418
 
419
+ const MCP_TOOL_KEY_MIGRATION_GUIDE =
420
+ "https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/migration/1001-pi-mcp-tools-on-mcp-surface.md";
421
+
422
+ /** The notice asking the operator to move top-level `mcp__…` keys under `mcp`. */
423
+ function formatMcpToolKeyPortNotice(keys: readonly string[]): string {
424
+ const named = keys.map((key) => `"${key}"`).join(", ");
425
+ return (
426
+ `Top-level permission keys naming Pi MCP tools are applied as "mcp" rules: ${named}. ` +
427
+ `Move them under "mcp" — see ${MCP_TOOL_KEY_MIGRATION_GUIDE}`
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Build the config-layer rules from the merged permission object: every key
433
+ * except the universal `"*"` (which feeds `synthesizeDefaults` only), each
434
+ * rule tagged with the `config` layer and the scope that contributed it.
435
+ */
436
+ function buildConfigRules(
437
+ mergedPermission: FlatPermissionConfig,
438
+ origins: MergedScopes["origins"],
439
+ ): Ruleset {
440
+ const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
441
+ Object.entries(mergedPermission).filter(([k]) => k !== "*"),
442
+ );
443
+ return normalizeFlatConfig(permissionWithoutUniversal).map(
444
+ (r): Rule => ({
445
+ ...r,
446
+ layer: "config",
447
+ origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
448
+ }),
449
+ );
450
+ }
451
+
392
452
  /**
393
453
  * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
394
454
  * Setting agentsDir explicitly from agentDir removes the hidden
@@ -403,6 +463,9 @@ function derivePolicyLoaderOptions(
403
463
  agentsDir: join(agentDir, "agents"),
404
464
  projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
405
465
  projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
466
+ // Pi's built-in MCP reads these two files (`extensions/mcp/config.ts`).
467
+ globalMcpConfigPath: join(agentDir, "mcp.json"),
468
+ projectMcpConfigPath: cwd ? join(cwd, ".pi", "mcp.json") : undefined,
406
469
  };
407
470
  }
408
471
 
@@ -414,7 +477,7 @@ function derivePolicyLoaderOptions(
414
477
  *
415
478
  * - session → "session" (always, all surfaces)
416
479
  * - mcp + default → "default"
417
- * - mcp + other → "mcp"
480
+ * - mcp + other → "mcp" (the proxy and Pi MCP tools alike)
418
481
  * - special → "special" (always)
419
482
  * - skill → "skill" (always)
420
483
  * - bash → "bash" (always)
@@ -431,6 +494,7 @@ function deriveSource(
431
494
 
432
495
  switch (classifyToolKind(toolName)) {
433
496
  case "mcp":
497
+ case "mcp-tool":
434
498
  return rule.layer === "default" ? "default" : "mcp";
435
499
  case "skill":
436
500
  return "skill";
@@ -233,9 +233,16 @@ export function suggestPathSessionPattern(
233
233
  surface: string,
234
234
  approvalPattern: string,
235
235
  ): SessionApprovalSuggestion {
236
- return {
237
- surface,
238
- pattern: approvalPattern,
239
- label: buildLabel(approvalPattern, surface),
240
- };
236
+ return suggestExactSessionPattern(surface, approvalPattern);
237
+ }
238
+
239
+ /**
240
+ * Build the suggestion for a pattern the caller already holds verbatim, such
241
+ * as a Pi MCP tool's full name on the `mcp` surface: no wildcard is derived.
242
+ */
243
+ export function suggestExactSessionPattern(
244
+ surface: string,
245
+ pattern: string,
246
+ ): SessionApprovalSuggestion {
247
+ return { surface, pattern, label: buildLabel(pattern, surface) };
241
248
  }
@@ -18,8 +18,9 @@ export function formatUnknownToolReason(
18
18
  const availableList =
19
19
  preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
20
20
 
21
+ const kind = classifyToolKind(toolName);
21
22
  const mcpHint =
22
- classifyToolKind(toolName) === "mcp"
23
+ kind === "mcp" || kind === "mcp-tool"
23
24
  ? ""
24
25
  : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
25
26
 
@@ -1,4 +1,8 @@
1
- import { classifyToolKind, isMcpCheck } from "#src/access-intent/tool-kind";
1
+ import {
2
+ classifyToolKind,
3
+ isMcpCheck,
4
+ isProxyMcpCheck,
5
+ } from "#src/access-intent/tool-kind";
2
6
  import type { ToolPreviewFormatter } from "#src/tool-input/tool-preview-formatter";
3
7
  import type { PermissionCheckResult } from "#src/types";
4
8
  import { getNonEmptyString, toRecord } from "#src/value-guards";
@@ -45,7 +49,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
45
49
  },
46
50
  evidence: bash
47
51
  ? fullCommandEvidence(facts)
48
- : inputPreviewEvidence(facts, mcp),
52
+ : inputPreviewEvidence(facts, isProxyMcpCheck(check)),
49
53
  annotations: [],
50
54
  };
51
55
  }
@@ -86,15 +90,16 @@ function fullCommandEvidence(facts: ToolAskFacts): PromptEvidence[] {
86
90
  /**
87
91
  * The per-tool input preview, when a formatter is registered and produces one.
88
92
  *
89
- * An MCP ask previews under the `mcp` key rather than the qualified target, so
90
- * a registered MCP formatter is consulted for every server.
93
+ * A proxied MCP ask previews under the `mcp` key rather than the qualified
94
+ * target, so a registered MCP formatter is consulted for every server. A Pi
95
+ * MCP tool's input is its own arguments, so it previews under its own name.
91
96
  */
92
97
  function inputPreviewEvidence(
93
98
  facts: ToolAskFacts,
94
- mcp: boolean,
99
+ proxyMcp: boolean,
95
100
  ): PromptEvidence[] {
96
101
  const preview = facts.formatter?.formatToolInputForPrompt(
97
- mcp ? "mcp" : facts.check.toolName,
102
+ proxyMcp ? "mcp" : facts.check.toolName,
98
103
  facts.input,
99
104
  );
100
105
  return preview ? [{ label: "input", text: preview, detail: null }] : [];
@@ -1,4 +1,7 @@
1
- import type { AccessIntent } from "#src/access-intent/access-intent";
1
+ import type {
2
+ AccessIntent,
3
+ PathValuesAccessIntent,
4
+ } from "#src/access-intent/access-intent";
2
5
  import { buildAccessIntentForSurface } from "#src/access-intent/input-normalizer";
3
6
  import type { Authorizer } from "#src/authority/authorizer";
4
7
  import type { AuthorizerRegistrar } from "#src/authority/authorizer-registry";
@@ -23,7 +26,7 @@ import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
23
26
  * `PermissionResolver` satisfies it.
24
27
  */
25
28
  interface ResolverForService {
26
- resolve(intent: AccessIntent): PermissionCheckResult;
29
+ resolve(intent: AccessIntent | PathValuesAccessIntent): PermissionCheckResult;
27
30
  getToolPermission(toolName: string, agentName?: string): PermissionState;
28
31
  isToolFullyDenied(toolName: string, agentName?: string): boolean;
29
32
  }
@@ -1,4 +1,7 @@
1
- import { classifyToolKind, isMcpCheck } from "#src/access-intent/tool-kind";
1
+ import {
2
+ classifyToolKind,
3
+ isProxyMcpCheck,
4
+ } from "#src/access-intent/tool-kind";
2
5
  import type { PermissionCheckResult } from "#src/types";
3
6
  import { getNonEmptyString, toRecord } from "#src/value-guards";
4
7
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
@@ -157,7 +160,10 @@ export class ToolPreviewFormatter {
157
160
  input: unknown,
158
161
  pathBearingTools: ReadonlySet<string>,
159
162
  ): string | undefined {
160
- if (classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)) {
163
+ if (
164
+ classifyToolKind(result.toolName) === "bash" ||
165
+ isProxyMcpCheck(result)
166
+ ) {
161
167
  return undefined;
162
168
  }
163
169