@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 +33 -0
- package/README.md +7 -1
- package/docs/configuration.md +53 -24
- package/docs/migration/1001-pi-mcp-tools-on-mcp-surface.md +91 -0
- package/package.json +5 -5
- package/src/access-intent/access-intent.ts +4 -2
- package/src/access-intent/input-normalizer.ts +51 -24
- package/src/access-intent/mcp-targets.ts +71 -0
- package/src/access-intent/tool-input-path.ts +1 -0
- package/src/access-intent/tool-kind.ts +53 -8
- package/src/config/policy-loader.ts +11 -1
- package/src/exposure/skill-prompt-sanitizer.ts +41 -93
- package/src/exposure/tool-surface-prompt.ts +34 -430
- package/src/handlers/before-agent-start.ts +64 -50
- package/src/handlers/gates/helpers.ts +1 -0
- package/src/handlers/gates/tool.ts +43 -9
- package/src/policy/normalize.ts +47 -0
- package/src/policy/permission-manager.ts +89 -25
- package/src/presentation/pattern-suggest.ts +12 -5
- package/src/presentation/permission-prompts.ts +2 -1
- package/src/presentation/tool-ask-payload.ts +11 -6
- package/src/service/permissions-service.ts +5 -2
- package/src/tool-input/tool-preview-formatter.ts +8 -2
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.
|
package/docs/configuration.md
CHANGED
|
@@ -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` |
|
|
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
|
|
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
|
|
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),
|
|
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
|
-
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
|
1337
|
-
|
|
1338
|
-
-
|
|
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
|
|
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": "
|
|
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.
|
|
67
|
-
"@earendil-works/pi-tui": ">=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.
|
|
72
|
-
"@earendil-works/pi-tui": "0.
|
|
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
|
|
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
|
-
/** `
|
|
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 {
|
|
5
|
-
|
|
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).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
52
|
-
* child already fixed), and every other surface gets a `tool` intent built
|
|
53
|
-
* from its single portable value. `
|
|
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
|
|
138
|
-
*
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|