@gotgenes/pi-permission-system 37.0.0 → 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 +16 -0
- package/README.md +1 -1
- package/docs/configuration.md +38 -11
- package/docs/migration/1001-pi-mcp-tools-on-mcp-surface.md +91 -0
- package/package.json +1 -1
- 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/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,22 @@ 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
|
+
|
|
8
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)
|
|
9
25
|
|
|
10
26
|
|
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
|
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 |
|
|
@@ -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
|
|
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
|
@@ -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
|
+
}
|
|
@@ -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` —
|
|
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 =
|
|
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 —
|
|
105
|
-
* `mcp
|
|
106
|
-
* `source` disjunct is why this cannot reduce to
|
|
107
|
-
* `deriveSource` can set `source` to `mcp` on 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
|
-
|
|
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 =
|
|
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("|");
|
|
@@ -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
|
-
|
|
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
|
-
:
|
|
88
|
-
gateSurface,
|
|
89
|
-
deriveSuggestionValue(gateSurface, check),
|
|
90
|
-
);
|
|
124
|
+
: suggestValueSessionPattern(tcc.toolName, gateSurface, check);
|
|
91
125
|
|
|
92
126
|
const payload = buildToolAskPayload({
|
|
93
127
|
check,
|
package/src/policy/normalize.ts
CHANGED
|
@@ -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 } =
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
99
|
+
proxyMcp: boolean,
|
|
95
100
|
): PromptEvidence[] {
|
|
96
101
|
const preview = facts.formatter?.formatToolInputForPrompt(
|
|
97
|
-
|
|
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 {
|
|
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 {
|
|
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 (
|
|
163
|
+
if (
|
|
164
|
+
classifyToolKind(result.toolName) === "bash" ||
|
|
165
|
+
isProxyMcpCheck(result)
|
|
166
|
+
) {
|
|
161
167
|
return undefined;
|
|
162
168
|
}
|
|
163
169
|
|