@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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v37.0.0...pi-permission-system-v38.0.0) (2026-10-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+ * **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)
15
+ * **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)
16
+ * **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)
17
+ * **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)
18
+
19
+ ### Documentation
20
+
21
+ * **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)
22
+ * **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)
23
+
24
+ ## [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)
25
+
26
+
27
+ ### Features
28
+
29
+ * **pi-permission-system:** **breaking:** require Pi 1.0.0 or later ([9c76ae3](https://github.com/gotgenes/pi-packages/commit/9c76ae30dd58b0a94524f753f7c72ff356231294)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
30
+
31
+ ### Bug Fixes
32
+
33
+ * **pi-permission-system:** keep prompt sections other extensions add, such as <mcp_servers> ([1abdfe4](https://github.com/gotgenes/pi-packages/commit/1abdfe4901a27cb8ff3d85a965fc07b3be874624)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
34
+ * **pi-permission-system:** keep later prompt sections when a skill is denied ([dafd83b](https://github.com/gotgenes/pi-packages/commit/dafd83b075b01ef39335822f8b5122c56ee00b97)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
35
+
36
+ ### Documentation
37
+
38
+ * **pi-permission-system:** note the Pi 1.0.0 requirement under Upgrading ([5bc932c](https://github.com/gotgenes/pi-packages/commit/5bc932cf9936728c0e3c551fdbea6a7009108958)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
39
+ * **pi-permission-system:** record prompt changes through systemPromptOptions ([b11051f](https://github.com/gotgenes/pi-packages/commit/b11051f1365a635b67e70dea126c8a49ff9ef31b)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
40
+
8
41
  ## [36.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.0...pi-permission-system-v36.2.1) (2026-10-01)
9
42
 
10
43
 
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
@@ -155,6 +155,12 @@ Third-party packages are maintained by their authors; review one before granting
155
155
 
156
156
  ## Upgrading
157
157
 
158
+ ### The major after 36.x — requires Pi 1.0.0
159
+
160
+ The extension now requires `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` 1.0.0 or later.
161
+ Upgrade Pi to 1.0.0 before upgrading this package; on an older Pi, stay on the 36.x line.
162
+ No configuration change is needed.
163
+
158
164
  ### 22.0.0 — project config requires project trust
159
165
 
160
166
  Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
@@ -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 |
@@ -1314,11 +1341,11 @@ permission:
1314
1341
 
1315
1342
  The extension integrates via Pi's lifecycle hooks:
1316
1343
 
1317
- | Hook | Behavior |
1318
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1319
- | `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match (except under a custom system prompt outside a subagent), and hides denied skills |
1320
- | `tool_call` | Enforces permissions for every tool invocation |
1321
- | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1344
+ | Hook | Behavior |
1345
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1346
+ | `before_agent_start` | Filters the active tool set (restrict-only), states a subagent child's own tool list and rules, and hides denied skills, all through pi's prompt options rather than a rewritten prompt |
1347
+ | `tool_call` | Enforces permissions for every tool invocation |
1348
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1322
1349
 
1323
1350
  Additional behaviors:
1324
1351
 
@@ -1326,20 +1353,22 @@ Additional behaviors:
1326
1353
  - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
1327
1354
  - Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
1328
1355
  A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1329
- - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
1330
1356
  - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1331
- - The tool list and guidelines are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after the working directory pi states last.
1332
- They take the shape pi writes them in: `Available tools:` and `Guidelines:` sections after a `Current working directory:` footer through pi 0.85, and `<tools>` and `<rules>` sections after a `<cwd>` section from pi 0.86.
1333
- Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session on pi's default prompt, whether or not anything is denied.
1334
- A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1357
+ - Every change this extension makes to the system prompt goes through pi's `systemPromptOptions`; it never returns a rewritten prompt.
1358
+ A returned prompt is sent as the whole prompt, which would drop the sections other extensions add after this one runs, such as pi's `<mcp_servers>` list of `codemode` and `deferred` MCP servers (see [ADR 0015](decisions/0015-prompt-changes-through-system-prompt-options.md)).
1359
+ - On pi's default prompt, the tool list and guidelines stay where pi writes them, as its `<tools>` and `<rules>` sections, and pi renders them from the filtered active set, so a denied tool and its guidelines are absent and a restored tool is listed on the turn it returns.
1360
+ - A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
1335
1361
  Tool filtering and enforcement still apply, and the model still receives only the allowed tools in the request's tool list.
1336
- A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it still gets its own tool list and rules at the end, while any custom text it inherited stays untouched.
1337
- - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones
1338
- - The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1362
+ A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it gets its own `<tools>` and `<rules>` sections, placed after the working directory, while any custom text it inherited stays untouched.
1363
+ The child's sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones.
1364
+ - A subagent child running with `@gotgenes/pi-subagents` needs a release that drops the parent's `<tools>` and `<rules>` from the prompt the child inherits; with an older one, the child shows the parent's tool list as well as its own.
1365
+ - A denied skill is removed from the skills catalogue pi renders.
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.
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.
1339
1368
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1340
- - 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))
1341
1370
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
1342
- - 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))
1343
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.
1344
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))
1345
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": "36.2.1",
3
+ "version": "38.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,13 +63,13 @@
63
63
  ]
64
64
  },
65
65
  "peerDependencies": {
66
- "@earendil-works/pi-coding-agent": ">=0.79.0",
67
- "@earendil-works/pi-tui": ">=0.79.0"
66
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
67
+ "@earendil-works/pi-tui": ">=1.0.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.5.11",
71
- "@earendil-works/pi-coding-agent": "0.79.1",
72
- "@earendil-works/pi-tui": "0.79.1",
71
+ "@earendil-works/pi-coding-agent": "1.0.0",
72
+ "@earendil-works/pi-tui": "1.0.0",
73
73
  "@types/node": "^22.15.3",
74
74
  "rollup": "^4.63.1",
75
75
  "rollup-plugin-dts": "^6.5.1",
@@ -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) {