@gotgenes/pi-permission-system 37.0.0 → 38.0.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [38.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.0...pi-permission-system-v38.0.1) (2026-10-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** tolerate a non-Pi before_agent_start payload ([#860](https://github.com/gotgenes/pi-packages/issues/860)) ([33f6a24](https://github.com/gotgenes/pi-packages/commit/33f6a24c820f23d43a56c9377245e804c2fd8ba2)), closes [#860](https://github.com/gotgenes/pi-packages/issues/860)
14
+
15
+ ## [38.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v37.0.0...pi-permission-system-v38.0.0) (2026-10-02)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **pi-permission-system:** read MCP server names from the trusted project's .pi/mcp.json too ([780bc33](https://github.com/gotgenes/pi-packages/commit/780bc330274a86463e53edba3833f33db5427035)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
21
+ * **pi-permission-system:** **breaking:** gate Pi's built-in MCP tools on the mcp surface ([97eebdb](https://github.com/gotgenes/pi-packages/commit/97eebdb212f9fa0ceab3ac71c1fd095e4f94b9dc)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
22
+ * **pi-permission-system:** keep top-level mcp__ tool keys working and ask the operator to port them ([b0b9102](https://github.com/gotgenes/pi-packages/commit/b0b9102e4fd048e64203a39ae837d9da8d0a16b9)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
23
+ * **pi-permission-system:** withhold a Pi MCP tool exactly when its mcp rules deny it ([68d734a](https://github.com/gotgenes/pi-packages/commit/68d734afd72e295cd2b9ee7a75cd1d39b9ae8557)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
24
+ * **pi-permission-system:** resolve a forwarded or queried mcp target as itself, not as a status probe ([caa96f9](https://github.com/gotgenes/pi-packages/commit/caa96f90b699aa10b76ba6c208a64a295b43c829)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
25
+
26
+ ### Documentation
27
+
28
+ * **pi-permission-system:** document Pi MCP tools on the mcp surface and the top-level key port ([6bde6a1](https://github.com/gotgenes/pi-packages/commit/6bde6a187320eafc3489e7ea882bcd23e02f1de0)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
29
+ * **pi-permission-system:** note which top-level keys carry over to Pi MCP tools ([07e3287](https://github.com/gotgenes/pi-packages/commit/07e328779f332fa6c8f8a0296ae24086ae3167c1)), closes [#1001](https://github.com/gotgenes/pi-packages/issues/1001)
30
+
8
31
  ## [37.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.1...pi-permission-system-v37.0.0) (2026-10-02)
9
32
 
10
33
 
package/README.md CHANGED
@@ -16,7 +16,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
16
16
  - **Hides disallowed tools** before the agent starts — no wasted turns probing for blocked tools
17
17
  - **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs
18
18
  - **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`)
19
- - **Gates MCP and skill access** at server, tool, and skill-name granularity
19
+ - **Gates MCP and skill access** at server, tool, and skill-name granularity — Pi's built-in MCP tools and the `mcp` proxy alike
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
22
  - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and a bash command the parser could not resolve, in whole or in part — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`); where a partial parse failure's own region re-parses cleanly on its own, the commands and paths it holds are recovered and gated rather than merely prompted for
@@ -366,13 +366,13 @@ Omitting `"*"` defaults to `"ask"` (least privilege).
366
366
  Any registered tool name can be a surface key.
367
367
  A string value is a catch-all for that surface.
368
368
 
369
- | Surface example | Description |
370
- | --------------------------------------------- | ----------------------------------- |
371
- | `read`, `write`, `edit`, `grep`, `find`, `ls` | Canonical Pi built-in file tools |
372
- | `bash` | Shell command execution |
373
- | `mcp` | Registered MCP proxy tool |
374
- | `task` | Delegation tool |
375
- | `third_party_tool` | Any other registered extension tool |
369
+ | Surface example | Description |
370
+ | --------------------------------------------- | ----------------------------------------------------------- |
371
+ | `read`, `write`, `edit`, `grep`, `find`, `ls` | Canonical Pi built-in file tools |
372
+ | `bash` | Shell command execution |
373
+ | `mcp` | MCP calls: Pi's built-in MCP tools and the `mcp` proxy tool |
374
+ | `task` | Delegation tool |
375
+ | `third_party_tool` | Any other registered extension tool |
376
376
 
377
377
  ```jsonc
378
378
  {
@@ -543,7 +543,11 @@ To deliberately opt into permissive bash, set `"bash": { "*": "allow" }` explici
543
543
 
544
544
  ### `mcp` Surface
545
545
 
546
- MCP permissions match against derived targets from tool input:
546
+ MCP permissions match against targets derived from each MCP call.
547
+ Two kinds of tool make MCP calls, and both resolve on this one surface:
548
+
549
+ - **Pi's built-in MCP** registers each server tool as its own tool, `mcp__<server>__<tool>` (for example `mcp__github__search_code`); see [Pi's built-in MCP tools](#pis-built-in-mcp-tools).
550
+ - **The `mcp` proxy tool** (for example pi-mcp-adapter's) takes the call as input, `{"tool": …, "server": …}`.
547
551
 
548
552
  | Target type | Examples |
549
553
  | ----------------- | --------------------------------------------------------------------- |
@@ -573,7 +577,7 @@ In that example `mcp_status` and `mcp_list` allow discovery, `myServer:*` prompt
573
577
  #### How a call becomes targets
574
578
 
575
579
  One MCP call is looked up under several names, and a rule may name any of them.
576
- When the call carries no explicit `server`, the server is derived from the tool name against the servers in your MCP config, in this order — the first convention that matches settles the name:
580
+ When a proxied call carries no explicit `server`, the server is derived from the tool name against the servers in your MCP config (`~/.pi/agent/mcp.json`, plus `<project>/.pi/mcp.json` in a trusted project), in this order — the first convention that matches settles the name:
577
581
 
578
582
  1. **Qualified** — `server:tool` splits directly.
579
583
  2. **Prefix** — the **longest** configured server that is the leading segment of `<server>_<tool>`.
@@ -594,6 +598,29 @@ Deriving a server from a name is a heuristic, and it can attach the wrong rule:
594
598
  Longest-match only helps when both servers are configured.
595
599
  Where the distinction matters, pass an explicit `server` argument or use a qualified `server:tool` name.
596
600
 
601
+ #### Pi's built-in MCP tools
602
+
603
+ Pi names each tool `mcp__<server>__<tool>`, replacing every character outside `[A-Za-z0-9_]` with `_`, so a server named `danger-srv` in `mcp.json` appears as `danger_srv` in the tool name.
604
+ A call to such a tool is looked up under the same targets a proxied call gets, plus the tool's full Pi name, and the server is named in **both** spellings: the one in your `mcp.json` and the one in the tool name.
605
+ Pi refuses two servers whose names differ only in `-` and `_`, so the two spellings always name the same server.
606
+
607
+ | Call (`danger-srv` in `mcp.json`) | Targets |
608
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
609
+ | `mcp__danger_srv__wipe` | `danger-srv_wipe`, `danger-srv:wipe`, `danger-srv`, `danger_srv_wipe`, `danger_srv:wipe`, `danger_srv`, `wipe`, `mcp__danger_srv__wipe`, `mcp_call` |
610
+ | `mcp__github__search` (`github`) | `github_search`, `github:search`, `github`, `search`, `mcp__github__search`, `mcp_call` |
611
+
612
+ So `"danger-srv": "deny"`, `"danger_srv": "deny"`, `"danger-srv:*": "deny"`, and `"mcp__danger_srv__*": "deny"` each deny every tool of that server.
613
+ The server is the longest configured name whose Pi spelling starts the tool name; a server no `mcp.json` names, such as one an extension registers in code, is split at the first `__` and gets only Pi's spelling.
614
+ A tool name over 64 characters, or two of a server's tools that sanitize to the same name, gets an 8-character hash suffix from Pi; the suffix is part of the tool target, so a server-level rule is the reliable way to name such a tool.
615
+
616
+ The tool's arguments are its own input, unlike the proxy's: the ask prompt and the review log show them, and the [`path` and `external_directory`](#path-surface) gates read a top-level `path` argument.
617
+ Approving such a tool "for this session" records its full Pi name on the `mcp` surface, so the approval covers exactly that tool.
618
+ A Pi MCP tool is withheld from the model when its targets resolve to `deny`.
619
+
620
+ > **Migrating:** a top-level key naming a Pi MCP tool (`"mcp__danger_srv__wipe": "deny"`) still applies, as an `mcp` rule, and raises a notice at session start asking you to move it under `mcp`.
621
+ > A top-level wildcard of another shape (`"*__wipe"`) no longer reaches these tools.
622
+ > See [the migration guide](migration/1001-pi-mcp-tools-on-mcp-surface.md).
623
+
597
624
  #### Which rule shape to write
598
625
 
599
626
  | Rule shape | Matches | Use it for |
@@ -1339,9 +1366,9 @@ Additional behaviors:
1339
1366
  A skill listed in text pi did not render, such as your own `SYSTEM.md`, is not edited out; using it is still gated.
1340
1367
  - The prompt options are recomputed on every turn but are stable across turns for a stable policy/agent, so the provider's prompt cache is preserved rather than rewritten each turn.
1341
1368
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1342
- - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1369
+ - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name; Pi's built-in MCP tools (`mcp__<server>__<tool>`) resolve on the `mcp` surface instead (see [Pi's built-in MCP tools](#pis-built-in-mcp-tools))
1343
1370
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
1344
- - Permission review logs include `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked and every value bounded by `reviewLogFieldMaxWidth` (see [Log file sensitivity](#log-file-sensitivity))
1371
+ - Permission review logs include `toolInputPreview` values for tool calls other than bash and the `mcp` proxy, with sensitive-keyed values masked and every value bounded by `reviewLogFieldMaxWidth` (see [Log file sensitivity](#log-file-sensitivity))
1345
1372
  - A tool whose path came from an extractor registered in an **ancestor** session rather than this one records `extractorSource: "inherited"` beside the decision; the field is absent for every path this session resolved itself.
1346
1373
  This happens in a subagent child when the extractor's provider was kept out of the child but the tool's own package was not — the child borrows the declaration so its `path` and `external_directory` gates still see the path (see [Subagent Integration](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/subagent-integration.md#loading-asymmetry))
1347
1374
 
@@ -0,0 +1,91 @@
1
+ # Migration guide: Pi's built-in MCP tools are gated on the `mcp` surface
2
+
3
+ Starting with the release that closes [#1001], `mcp` permission rules apply to the tools Pi's built-in MCP registers, `mcp__<server>__<tool>`.
4
+ Before it, those tools were gated like any other extension tool, under their own name, and no `mcp` rule reached them.
5
+
6
+ This is a **breaking change**: a config that already has `mcp` rules changes how Pi's MCP tools resolve on upgrade, with no edit.
7
+ A config with no `mcp` rules and no top-level `mcp__…` keys is unaffected — both paths fall back to `"*"`.
8
+
9
+ ## What changes
10
+
11
+ Each row is a policy for a server configured as `danger-srv` in `mcp.json`, whose `wipe` tool Pi registers as `mcp__danger_srv__wipe`.
12
+
13
+ | Policy | Before | After |
14
+ | ------------------------------------------------------------- | ------- | ------------------------------------------------ |
15
+ | `"*": "allow"`, `"mcp": {"*": "allow", "danger-srv": "deny"}` | allowed | **denied**, by `danger-srv` |
16
+ | `"*": "ask"`, `"mcp": "allow"` | asked | **allowed**, by `mcp`'s catch-all |
17
+ | `"*": "allow"`, `"mcp__danger_srv__wipe": "deny"` | denied | denied, plus a notice asking you to port the key |
18
+ | `"*": "allow"`, `"mcp__danger_srv__*": "deny"` | denied | denied, plus a notice asking you to port the key |
19
+ | `"*": "ask"` | asked | asked |
20
+
21
+ The *After* column is each policy resolved through the released permission manager; the first and third *Before* entries are the reproduction in [#1001], and the others follow from the tool falling back to `"*"` when no top-level key names it.
22
+
23
+ A denied tool is also withheld from the model, so the first row now hides `mcp__danger_srv__wipe` where it used to be offered.
24
+
25
+ A server rule matches in either spelling — `danger-srv` as in `mcp.json`, or `danger_srv` as in the tool name — because Pi refuses two servers whose names differ only in `-` and `_`.
26
+ The full list of names a call is looked up under is in [Pi's built-in MCP tools](../configuration.md#pis-built-in-mcp-tools).
27
+
28
+ ## Porting a top-level `mcp__…` key
29
+
30
+ A top-level key naming a Pi MCP tool was the only rule that reached it, so it keeps working: it is also applied as an `mcp` rule with the key as its pattern, after every other `mcp` rule.
31
+ The key keeps its original meaning too, so `"mcp__*"` still covers a tool from another extension whose name merely starts with `mcp__`; a key that can name no Pi MCP tool, such as `"mcp__foo"`, is left alone and raises no notice.
32
+ At session start you see a notice naming each such key:
33
+
34
+ ```text
35
+ Top-level permission keys naming Pi MCP tools are applied as "mcp" rules: "mcp__danger_srv__wipe". Move them under "mcp" — see https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/migration/1001-pi-mcp-tools-on-mcp-surface.md
36
+ ```
37
+
38
+ To port it, move the key under `mcp` unchanged — the full Pi name is one of the tool's `mcp` targets:
39
+
40
+ ```jsonc
41
+ // Before
42
+ {
43
+ "permission": {
44
+ "*": "allow",
45
+ "mcp__danger_srv__wipe": "deny"
46
+ }
47
+ }
48
+
49
+ // After
50
+ {
51
+ "permission": {
52
+ "*": "allow",
53
+ "mcp": {
54
+ "*": "allow",
55
+ "mcp__danger_srv__wipe": "deny"
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ Inside `mcp`, rule position decides: put the key **after** any `"*"` catch-all, or the catch-all wins.
62
+
63
+ When the rule was meant for the whole server, a server rule says so directly and survives a tool being renamed:
64
+
65
+ ```jsonc
66
+ {
67
+ "permission": {
68
+ "mcp": {
69
+ "*": "allow",
70
+ "danger-srv": "deny"
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ A wildcard key such as `"mcp__danger_srv__*"` ports the same way, or becomes the server rule above.
77
+
78
+ ## Other top-level wildcards
79
+
80
+ Only keys starting with `mcp__` carry over.
81
+ A top-level wildcard of another shape that used to match a Pi MCP tool's name, such as `"mcp_*"` or `"*__wipe"`, no longer reaches it, because the tool now resolves on the `mcp` surface rather than under its own name.
82
+ It raises no notice; rewrite it as an `mcp` rule (`"mcp": {"*__wipe": "deny"}`) if you relied on it.
83
+
84
+ ## Rules written for Pi 0.99.0 and 0.99.1
85
+
86
+ Pi 0.99.0 and 0.99.1 kept `-` in the tool name (`mcp__danger-srv__wipe`); Pi 0.99.2 replaced it with `_`.
87
+ A top-level key written with the old spelling has matched nothing since 0.99.2.
88
+ It raises the notice like any other `mcp__…` key, but relocating it does not make it match: no tool carries that name anymore.
89
+ Replace it with a server rule as above, which matches `danger-srv` in either spelling.
90
+
91
+ [#1001]: https://github.com/gotgenes/pi-packages/issues/1001
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "37.0.0",
3
+ "version": "38.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,11 +15,13 @@ export interface ToolAccessIntent {
15
15
  }
16
16
 
17
17
  /**
18
- * Precomputed equivalent policy values for a path-shaped surface.
18
+ * Precomputed equivalent policy values for a surface, evaluated as-is.
19
19
  *
20
20
  * Not gate-emitted: the resolver produces it internally by unwrapping an
21
21
  * `access-path` intent via `matchValues()`, keeping the low-level manager
22
22
  * string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
23
+ * A forwarded or service `mcp` query also arrives in this form, carrying the
24
+ * MCP target it names; the name predates that second use.
23
25
  *
24
26
  * This string seam is a deliberate, formalized boundary — not transitional
25
27
  * scaffolding to collapse into the manager (ADR-0002,
@@ -27,7 +29,7 @@ export interface ToolAccessIntent {
27
29
  */
28
30
  export interface PathValuesAccessIntent {
29
31
  kind: "path-values";
30
- /** `"path"` or `"external_directory"`. */
32
+ /** A path-shaped surface (`path`, `external_directory`, a path tool) or `mcp`. */
31
33
  surface: string;
32
34
  values: readonly string[];
33
35
  agentName?: string;
@@ -1,8 +1,15 @@
1
1
  import { stripBashCommentLines } from "#src/access-intent/bash/bash-arity";
2
2
  import type { PathNormalizer } from "#src/path/path-normalizer";
3
3
  import { getNonEmptyString, toRecord } from "#src/value-guards";
4
- import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
5
- import { createMcpPermissionTargets } from "./mcp-targets";
4
+ import type {
5
+ AccessIntent,
6
+ PathValuesAccessIntent,
7
+ ResolvedAccessIntent,
8
+ } from "./access-intent";
9
+ import {
10
+ createMcpPermissionTargets,
11
+ createPiMcpToolTargets,
12
+ } from "./mcp-targets";
6
13
  import { PATH_SURFACES, surfaceFamilyOf } from "./path-surfaces";
7
14
  import { classifyToolKind } from "./tool-kind";
8
15
 
@@ -14,16 +21,19 @@ import { classifyToolKind } from "./tool-kind";
14
21
  * For a path-shaped surface (`path`, `external_directory`, or a path-bearing
15
22
  * tool) carrying a non-empty value, it builds an `AccessPath` and emits an
16
23
  * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
17
- * (symlink-resolved) set — at parity with the gates (#486, #502). Every other
18
- * surface, and any value-less surface-level query, keeps the `tool` intent so
19
- * the manager's `normalizeInput` `["*"]` fallback is preserved.
24
+ * (symlink-resolved) set — at parity with the gates (#486, #502). An `mcp`
25
+ * query carrying a value evaluates that value as-is: an MCP target is already
26
+ * a candidate name, and rebuilding proxy input from it would derive only the
27
+ * status probe `mcp_status`. Every other surface, and any value-less
28
+ * surface-level query, keeps the `tool` intent so the manager's
29
+ * `normalizeInput` `["*"]` fallback is preserved.
20
30
  */
21
31
  export function buildAccessIntentForSurface(
22
32
  surface: string,
23
33
  value: string | undefined,
24
34
  normalizer: PathNormalizer,
25
35
  agentName: string | undefined,
26
- ): AccessIntent {
36
+ ): AccessIntent | PathValuesAccessIntent {
27
37
  const pathValue = getNonEmptyString(value);
28
38
  if (pathValue !== null && PATH_SURFACES.has(surface)) {
29
39
  return {
@@ -33,6 +43,9 @@ export function buildAccessIntentForSurface(
33
43
  agentName,
34
44
  };
35
45
  }
46
+ if (pathValue !== null && surface === "mcp") {
47
+ return { kind: "path-values", surface, values: [pathValue], agentName };
48
+ }
36
49
  return {
37
50
  kind: "tool",
38
51
  surface,
@@ -48,9 +61,11 @@ export function buildAccessIntentForSurface(
48
61
  *
49
62
  * Unlike {@link buildAccessIntentForSurface}, this never touches a
50
63
  * `PathNormalizer` and never rebuilds an `AccessPath` — a path-shaped surface
51
- * gets a `path-values` intent carrying `matchValues` as-is (the values the
52
- * child already fixed), and every other surface gets a `tool` intent built
53
- * from its single portable value. `agentName` is always the requester's
64
+ * or `mcp` gets a `path-values` intent carrying `matchValues` as-is (the values
65
+ * the child already fixed), and every other surface gets a `tool` intent built
66
+ * from its single portable value. An `mcp` request carries the child's target;
67
+ * evaluating it alone can only be stricter than the child, since what the
68
+ * serving node adds is session grants. `agentName` is always the requester's
54
69
  * `principal.agentName` (ADR 0008 §3, agent-scoped serving).
55
70
  */
56
71
  export function buildResolvedIntentFromMatchValues(
@@ -58,7 +73,7 @@ export function buildResolvedIntentFromMatchValues(
58
73
  matchValues: readonly string[],
59
74
  agentName: string,
60
75
  ): ResolvedAccessIntent {
61
- if (PATH_SURFACES.has(surface)) {
76
+ if (PATH_SURFACES.has(surface) || surface === "mcp") {
62
77
  return {
63
78
  kind: "path-values",
64
79
  surface,
@@ -134,8 +149,8 @@ export interface NormalizedInput {
134
149
  *
135
150
  * @param toolName - Normalized (trimmed) tool name from the tool-call event.
136
151
  * @param input - Raw input payload from the tool-call event.
137
- * @param configuredMcpServerNames - Ordered list of MCP server names from the
138
- * global MCP config, used to derive server-qualified MCP targets.
152
+ * @param configuredMcpServerNames - Ordered list of MCP server names from
153
+ * Pi's `mcp.json` files, used to derive server-qualified MCP targets.
139
154
  */
140
155
  export function normalizeInput(
141
156
  toolName: string,
@@ -172,18 +187,16 @@ export function normalizeInput(
172
187
  }
173
188
 
174
189
  // --- MCP ---
175
- case "mcp": {
176
- const mcpTargets = [
177
- ...createMcpPermissionTargets(input, configuredMcpServerNames),
178
- "mcp",
179
- ];
180
- const fallbackTarget = mcpTargets[0] ?? "mcp";
181
- return {
182
- surface: "mcp",
183
- values: mcpTargets,
184
- resultExtras: { target: fallbackTarget },
185
- };
186
- }
190
+ case "mcp":
191
+ return normalizeMcpTargets(
192
+ createMcpPermissionTargets(input, configuredMcpServerNames),
193
+ );
194
+
195
+ // A Pi MCP tool's identity is its name; its input is the MCP arguments.
196
+ case "mcp-tool":
197
+ return normalizeMcpTargets(
198
+ createPiMcpToolTargets(toolName, configuredMcpServerNames),
199
+ );
187
200
 
188
201
  // --- All other surfaces (path-bearing tools and extension tools) ---
189
202
  // Path-bearing tools with a present path never reach here — the gate emits
@@ -198,3 +211,17 @@ export function normalizeInput(
198
211
  };
199
212
  }
200
213
  }
214
+
215
+ /**
216
+ * The `mcp` surface's normalized form: the derived candidates, then the
217
+ * surface-wide `mcp` name, reported under the most specific candidate until a
218
+ * rule match names the one it matched.
219
+ */
220
+ function normalizeMcpTargets(targets: readonly string[]): NormalizedInput {
221
+ const values = [...targets, "mcp"];
222
+ return {
223
+ surface: "mcp",
224
+ values,
225
+ resultExtras: { target: values[0] },
226
+ };
227
+ }
@@ -1,4 +1,5 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/value-guards";
2
+ import { PI_MCP_TOOL_PREFIX } from "./tool-kind";
2
3
 
3
4
  /**
4
5
  * An ordered accumulator that owns the uniqueness invariant.
@@ -222,3 +223,73 @@ export function createMcpPermissionTargets(
222
223
  targets.add("mcp_status");
223
224
  return targets.toArray();
224
225
  }
226
+
227
+ /**
228
+ * Derive the ordered MCP permission-lookup candidates for a tool Pi's built-in
229
+ * MCP registered under its own name, `mcp__<server>__<tool>`.
230
+ *
231
+ * The candidates mirror a proxied call's (`<server>_<tool>`, `<server>:<tool>`,
232
+ * `<server>`, `<tool>`, `mcp_call`) plus the full Pi name, so an `mcp` rule
233
+ * written for either client names the same call. Pi rewrites every character
234
+ * outside `[A-Za-z0-9_]` to `_` in the name, so the server is emitted in both
235
+ * spellings: the configured one from `mcp.json` (`danger-srv`) and the one in
236
+ * the name (`danger_srv`). Pi rejects two servers that differ only in `-` and
237
+ * `_`, which is what makes treating the two spellings as one server safe.
238
+ *
239
+ * The server is the longest configured name whose sanitized form prefixes the
240
+ * tool name; with none, the name splits at its first `__`.
241
+ */
242
+ export function createPiMcpToolTargets(
243
+ toolName: string,
244
+ configuredServerNames: readonly string[],
245
+ ): string[] {
246
+ const rest = toolName.trim().slice(PI_MCP_TOOL_PREFIX.length);
247
+ const { spellings, tool } = resolvePiMcpServer(rest, configuredServerNames);
248
+
249
+ const targets = new McpTargetList();
250
+ for (const server of spellings) {
251
+ targets.add(`${server}_${tool}`);
252
+ targets.add(`${server}:${tool}`);
253
+ targets.add(server);
254
+ }
255
+ targets.add(tool);
256
+ targets.add(toolName.trim());
257
+ targets.add("mcp_call");
258
+ return targets.toArray();
259
+ }
260
+
261
+ /** The server spellings and tool a Pi MCP tool name (prefix removed) holds. */
262
+ function resolvePiMcpServer(
263
+ rest: string,
264
+ configuredServerNames: readonly string[],
265
+ ): { spellings: string[]; tool: string } {
266
+ let sanitizedServer: string | null = null;
267
+ let configured: string[] = [];
268
+
269
+ for (const serverName of configuredServerNames) {
270
+ const trimmed = serverName.trim();
271
+ const sanitized = sanitizePiMcpName(trimmed);
272
+ if (!trimmed || !rest.startsWith(`${sanitized}__`)) continue;
273
+ if (sanitizedServer === null || sanitized.length > sanitizedServer.length) {
274
+ sanitizedServer = sanitized;
275
+ configured = [trimmed];
276
+ } else if (sanitized === sanitizedServer) {
277
+ configured.push(trimmed);
278
+ }
279
+ }
280
+
281
+ if (sanitizedServer === null) {
282
+ const separator = rest.indexOf("__");
283
+ sanitizedServer = rest.slice(0, separator);
284
+ }
285
+
286
+ return {
287
+ spellings: [...new Set([...configured, sanitizedServer])],
288
+ tool: rest.slice(sanitizedServer.length + 2),
289
+ };
290
+ }
291
+
292
+ /** Pi's MCP name sanitization: every character outside `[A-Za-z0-9_]` becomes `_`. */
293
+ function sanitizePiMcpName(name: string): string {
294
+ return name.replace(/[^A-Za-z0-9_]/g, "_");
295
+ }
@@ -62,6 +62,7 @@ export function getToolInputPath(
62
62
  case "mcp":
63
63
  return byConvention(getNonEmptyString(toRecord(record.arguments).path));
64
64
  case "skill":
65
+ case "mcp-tool":
65
66
  case "extension": {
66
67
  const custom = extractors?.resolve(toolName);
67
68
  if (custom) {
@@ -22,25 +22,57 @@ import { PATH_BEARING_TOOLS } from "./path-surfaces";
22
22
  * (`docs/decisions/0002-path-values-string-boundary.md`).
23
23
  *
24
24
  * - `bash` — its own token-based path gates; extraction product is the command.
25
- * - `mcp` — extraction product is the qualified target.
25
+ * - `mcp` — the `mcp` proxy tool (e.g. pi-mcp-adapter), whose input names the
26
+ * MCP call (`{ tool, server, args }`); extraction product is the qualified
27
+ * target.
28
+ * - `mcp-tool` — one MCP server tool registered under its own name,
29
+ * `mcp__<server>__<tool>` (Pi's built-in MCP); its input is the MCP
30
+ * arguments themselves, so it shares the extension tools' input shape while
31
+ * resolving on the `mcp` surface.
26
32
  * - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
27
33
  * - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
28
34
  * extraction product is `input.path`.
29
35
  * - `extension` — every other tool, plus the `external_directory`/`path` special
30
36
  * surfaces that reach `deriveSource` as normalized names.
31
37
  */
32
- export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
38
+ export type ToolKind =
39
+ | "bash"
40
+ | "mcp"
41
+ | "mcp-tool"
42
+ | "skill"
43
+ | "path"
44
+ | "extension";
33
45
 
34
46
  /** Classify a tool name into its {@link ToolKind}. */
35
47
  export function classifyToolKind(toolName: string): ToolKind {
36
48
  const name = toolName.trim();
37
49
  if (name === "bash") return "bash";
38
50
  if (name === "mcp") return "mcp";
51
+ if (isPiMcpToolName(name)) return "mcp-tool";
39
52
  if (name === "skill") return "skill";
40
53
  if (PATH_BEARING_TOOLS.has(name)) return "path";
41
54
  return "extension";
42
55
  }
43
56
 
57
+ /** The prefix Pi's built-in MCP gives every server tool it registers. */
58
+ export const PI_MCP_TOOL_PREFIX = "mcp__";
59
+
60
+ /**
61
+ * True when a tool name has the shape Pi's built-in MCP registers a server
62
+ * tool under: `mcp__<server>__<tool>`, with non-empty server and tool parts.
63
+ *
64
+ * Pi builds the name with every character outside `[A-Za-z0-9_]` replaced by
65
+ * `_`, and codemode scripts call the tool by the same identifier, so the name
66
+ * itself is the stable contract (`extensions/mcp/tools.ts`,
67
+ * `createMcpToolName`).
68
+ */
69
+ export function isPiMcpToolName(toolName: string): boolean {
70
+ if (!toolName.startsWith(PI_MCP_TOOL_PREFIX)) return false;
71
+ const rest = toolName.slice(PI_MCP_TOOL_PREFIX.length);
72
+ const separator = rest.indexOf("__");
73
+ return separator > 0 && separator + 2 < rest.length;
74
+ }
75
+
44
76
  /** A shell invocation's effective command and optional working directory. */
45
77
  export interface ShellInvocation {
46
78
  /** The shell command string to decompose and gate. */
@@ -101,12 +133,25 @@ interface McpKindFields {
101
133
  }
102
134
 
103
135
  /**
104
- * True when a resolved check concerns an MCP call — either the invoked tool is
105
- * `mcp`, or the winning rule matched on the `mcp` surface (`source`). The
106
- * `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`:
107
- * `deriveSource` can set `source` to `mcp` on a result whose `toolName` is a
108
- * server-qualified string.
136
+ * True when a resolved check concerns an MCP call — the invoked tool is the
137
+ * `mcp` proxy or a Pi MCP tool, or the winning rule matched on the `mcp`
138
+ * surface (`source`). The `source` disjunct is why this cannot reduce to
139
+ * `classifyToolKind(toolName)`: `deriveSource` can set `source` to `mcp` on a
140
+ * result whose `toolName` is a server-qualified string.
109
141
  */
110
142
  export function isMcpCheck(check: McpKindFields): boolean {
111
- return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
143
+ if (check.source === "mcp") return true;
144
+ const kind = classifyToolKind(check.toolName);
145
+ return kind === "mcp" || kind === "mcp-tool";
146
+ }
147
+
148
+ /**
149
+ * True when a check concerns an MCP call whose tool input is a request *about*
150
+ * the call (the proxy's `{ tool, server, args }`) rather than the MCP
151
+ * arguments themselves. Such input is not previewed — the target already
152
+ * names the call — while a Pi MCP tool's arguments are shown like any other
153
+ * tool's input.
154
+ */
155
+ export function isProxyMcpCheck(check: McpKindFields): boolean {
156
+ return isMcpCheck(check) && classifyToolKind(check.toolName) !== "mcp-tool";
112
157
  }
@@ -133,6 +133,12 @@ export interface PolicyLoaderOptions {
133
133
  projectGlobalConfigPath?: string;
134
134
  projectAgentsDir?: string;
135
135
  globalMcpConfigPath?: string;
136
+ /**
137
+ * The project's `.pi/mcp.json`, read beside the global file the way Pi's
138
+ * built-in MCP reads it. Set only for a trusted project: the manager derives
139
+ * it from a cwd, and an untrusted project's cwd is withheld.
140
+ */
141
+ projectMcpConfigPath?: string;
136
142
  mcpServerNames?: readonly string[];
137
143
  }
138
144
 
@@ -150,6 +156,7 @@ export class FilePolicyLoader implements PolicyLoader {
150
156
  private readonly projectGlobalConfigPath: string | null;
151
157
  private readonly projectAgentsDir: string | null;
152
158
  private readonly globalMcpConfigPath: string;
159
+ private readonly projectMcpConfigPath: string | null;
153
160
  private readonly configuredMcpServerNamesOverride: readonly string[] | null;
154
161
 
155
162
  private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
@@ -175,6 +182,7 @@ export class FilePolicyLoader implements PolicyLoader {
175
182
  this.projectAgentsDir = options.projectAgentsDir ?? null;
176
183
  this.globalMcpConfigPath =
177
184
  options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
185
+ this.projectMcpConfigPath = options.projectMcpConfigPath ?? null;
178
186
  this.configuredMcpServerNamesOverride = options.mcpServerNames
179
187
  ? [
180
188
  ...new Set(
@@ -316,7 +324,9 @@ export class FilePolicyLoader implements PolicyLoader {
316
324
  return this.configuredMcpServerNamesOverride;
317
325
  }
318
326
 
319
- const paths = [this.globalMcpConfigPath];
327
+ const paths = this.projectMcpConfigPath
328
+ ? [this.globalMcpConfigPath, this.projectMcpConfigPath]
329
+ : [this.globalMcpConfigPath];
320
330
  const stamp = paths
321
331
  .map((path) => `${path}:${getFileStamp(path)}`)
322
332
  .join("|");
@@ -22,8 +22,8 @@ import type { TurnPreparation } from "./session-turn-prep";
22
22
 
23
23
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
24
24
  interface BeforeAgentStartPayload {
25
- /** The prompt rendered from `systemPromptOptions` as it stands right now. */
26
- readonly systemPrompt: string;
25
+ /** Pi renders a string; a host may supply ordered prompt fragments instead. */
26
+ readonly systemPrompt: string | readonly string[];
27
27
  /**
28
28
  * The mutable parts Pi renders the prompt from; later handlers, and Pi
29
29
  * itself, see what this handler writes here. `customPrompt` says whether Pi
@@ -32,7 +32,7 @@ interface BeforeAgentStartPayload {
32
32
  * renders from, and `sections` is where it is stated. `skills` is the
33
33
  * catalogue Pi renders, which policy narrows.
34
34
  */
35
- systemPromptOptions: Pick<
35
+ systemPromptOptions?: Pick<
36
36
  NormalizedBuildSystemPromptOptions,
37
37
  "customPrompt" | "toolSnippets" | "promptGuidelines" | "sections" | "skills"
38
38
  >;
@@ -56,10 +56,12 @@ export function shouldExposeTool(
56
56
  /**
57
57
  * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
58
58
  *
59
- * Every change to the prompt is stated through `event.systemPromptOptions`,
59
+ * When provided, prompt changes are stated through `event.systemPromptOptions`,
60
60
  * never as a returned `systemPrompt`: a returned prompt is frozen for the run,
61
61
  * so the sections an extension later in the chain adds (Pi's own
62
62
  * `<mcp_servers>` among them) would never reach the provider (#999).
63
+ * Without those options, only tool filtering and skill path resolution run;
64
+ * there is no mutable section or skill catalogue to narrow.
63
65
  *
64
66
  * Narrowing the active set is enough for a prompt Pi wrote: Pi renders its
65
67
  * `<tools>` and `<rules>` from the reconciled active set. A subagent child's
@@ -97,7 +99,12 @@ export class AgentPrepHandler {
97
99
  ): Promise<BeforeAgentStartEventResult> {
98
100
  this.turnPrep.prepare(ctx);
99
101
 
100
- const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
102
+ // Normalize once at the boundary, before either prompt consumer reads it.
103
+ const systemPrompt =
104
+ typeof event.systemPrompt === "string"
105
+ ? event.systemPrompt
106
+ : event.systemPrompt.join("\n");
107
+ const agentName = this.session.resolveAgentName(ctx, systemPrompt);
101
108
  const registered = readRegisteredTools(this.toolRegistry.getAll());
102
109
  const surface = this.session.resolveExposedTools(
103
110
  this.observeToolSurface(registered),
@@ -118,7 +125,7 @@ export class AgentPrepHandler {
118
125
  }
119
126
 
120
127
  const options = event.systemPromptOptions;
121
- if (this.isSubagentUnderCustomPrompt(event, ctx)) {
128
+ if (options && this.isSubagentUnderCustomPrompt(event, ctx)) {
122
129
  const sections = renderToolSurfaceSections({
123
130
  allowedTools,
124
131
  toolSnippets: options.toolSnippets,
@@ -134,12 +141,14 @@ export class AgentPrepHandler {
134
141
  // read before the skill list is narrowed.
135
142
  this.session.setActiveSkillEntries(
136
143
  visibleSkillPromptEntries(
137
- event.systemPrompt,
144
+ systemPrompt,
138
145
  this.resolver,
139
146
  agentName,
140
147
  this.session.getPathNormalizer(),
141
148
  ),
142
149
  );
150
+ if (!options) return {};
151
+
143
152
  // Denials are judged on the list Pi renders `<skills>` from, not on the
144
153
  // rendered prompt: that prompt predates this turn's tool changes, and on
145
154
  // the turn `read`/`bash` return from a full denial it lists no catalogue.
@@ -181,5 +190,5 @@ export class AgentPrepHandler {
181
190
  * test, so an empty string reads here the way it reads there: as none.
182
191
  */
183
192
  function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
184
- return Boolean(event.systemPromptOptions.customPrompt);
193
+ return Boolean(event.systemPromptOptions?.customPrompt);
185
194
  }
@@ -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