@gotgenes/pi-permission-system 32.1.0 → 33.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,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
|
+
## [33.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.1.0...pi-permission-system-v33.0.0) (2026-09-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **breaking:** apply MCP server rules to prefix-named tools such as github_search_code ([1994b32](https://github.com/gotgenes/pi-packages/commit/1994b3227e16898c8ca4ff0d7a17b258dc306bcb)), closes [#929](https://github.com/gotgenes/pi-packages/issues/929)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **breaking:** honor last-match-wins for MCP rules instead of stopping at the first candidate ([c573696](https://github.com/gotgenes/pi-packages/commit/c57369648a4ffd64de9b07999e2687ceaa96e867))
|
|
18
|
+
* stop deriving unmatchable re-prefixed MCP candidates for already-qualified tool names ([72b3b0b](https://github.com/gotgenes/pi-packages/commit/72b3b0b6859511f9ce41d55aa803e57e5886077c)), closes [#929](https://github.com/gotgenes/pi-packages/issues/929)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* describe how MCP tool names derive server and tool permission targets ([49484a6](https://github.com/gotgenes/pi-packages/commit/49484a635d69292e8e0ad17dbe792019acd97269))
|
|
23
|
+
|
|
8
24
|
## [32.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.6...pi-permission-system-v32.1.0) (2026-09-16)
|
|
9
25
|
|
|
10
26
|
|
package/docs/configuration.md
CHANGED
|
@@ -523,6 +523,46 @@ MCP permissions match against derived targets from tool input:
|
|
|
523
523
|
|
|
524
524
|
> **Note:** Baseline discovery targets auto-allow when any explicit `mcp: allow` rule exists.
|
|
525
525
|
|
|
526
|
+
In that example `mcp_status` and `mcp_list` allow discovery, `myServer:*` prompts for each of that server's tools, and `dangerousServer` denies every call to it — including `dangerousServer_wipe` and a `{"tool": "wipe", "server": "dangerousServer"}` call — because each rule is written **after** the `"*"` catch-all.
|
|
527
|
+
|
|
528
|
+
#### How a call becomes targets
|
|
529
|
+
|
|
530
|
+
One MCP call is looked up under several names, and a rule may name any of them.
|
|
531
|
+
When the call carries no explicit `server`, the server is derived from the tool name against the servers in your MCP config, in this order — the first convention that matches settles the name:
|
|
532
|
+
|
|
533
|
+
1. **Qualified** — `server:tool` splits directly.
|
|
534
|
+
2. **Prefix** — the **longest** configured server that is the leading segment of `<server>_<tool>`.
|
|
535
|
+
This is the common case: the `mcp()` proxy carries names like `chrome_devtools_take_screenshot`, and aggregators expose `<server>_<tool>`.
|
|
536
|
+
`foo_bar_baz` belongs to `foo_bar`, never also to `foo`, and a prefix match settles the name — so `foo_bar_baz_github` derives `foo_bar` and not `github`.
|
|
537
|
+
3. **Suffix** — a legacy name like `search_code_github`, which also derives the qualified forms.
|
|
538
|
+
|
|
539
|
+
An explicit `server` argument skips derivation entirely.
|
|
540
|
+
|
|
541
|
+
| Call | Targets |
|
|
542
|
+
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
543
|
+
| `{"tool": "github_search_code"}`, `github` configured | `github_search_code`, `github`, `mcp_call` |
|
|
544
|
+
| `{"tool": "search_code_github"}`, `github` configured | `github_search_code_github`, `github:search_code_github`, `github`, `search_code_github`, `mcp_call` |
|
|
545
|
+
| `{"tool": "github:search_code"}` | `github_search_code`, `github:search_code`, `github`, `search_code`, `mcp_call` |
|
|
546
|
+
| `{"tool": "search_code", "server": "github"}` | `github_search_code`, `github:search_code`, `github`, `search_code`, `mcp_call` |
|
|
547
|
+
|
|
548
|
+
Deriving a server from a name is a heuristic, and it can attach the wrong rule: with `git` configured, a `git_lab_issues` tool from a different server derives `git`.
|
|
549
|
+
Longest-match only helps when both servers are configured.
|
|
550
|
+
Where the distinction matters, pass an explicit `server` argument or use a qualified `server:tool` name.
|
|
551
|
+
|
|
552
|
+
#### Which rule shape to write
|
|
553
|
+
|
|
554
|
+
| Rule shape | Matches | Use it for |
|
|
555
|
+
| -------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
556
|
+
| `"myServer"` | the bare-server target | **Server-level policy — the recommended form.** Fires for any call belonging to that server, whichever naming convention produced it. |
|
|
557
|
+
| `"myServer_*"` | the tool-name target | Tool-level policy for prefix-named tools. |
|
|
558
|
+
| `"myServer:*"` | the qualified target | Tool-level policy for qualified names and explicit-`server` calls. |
|
|
559
|
+
| `"*_myServer"` | the suffix targets | Only when you have suffix-named tools. |
|
|
560
|
+
|
|
561
|
+
**Rule position decides, not target order.**
|
|
562
|
+
The last rule matching *any* of a call's targets wins, exactly as on every other surface — so put broad catch-alls first and specific overrides after.
|
|
563
|
+
A `{"*": "allow", "github": "deny"}` config denies github calls; reversing the two lines makes the catch-all win instead.
|
|
564
|
+
The target order above decides only which name the decision is reported under in the prompt and the review log, where the most specific matching name is shown.
|
|
565
|
+
|
|
526
566
|
String shorthand grants broad MCP access — useful for per-agent overrides:
|
|
527
567
|
|
|
528
568
|
```yaml
|
package/package.json
CHANGED
|
@@ -102,15 +102,17 @@ function buildInputForSurface(
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Surface-normalized representation of a tool invocation used by
|
|
105
|
-
* `checkPermission()` to feed a single `
|
|
105
|
+
* `checkPermission()` to feed a single `evaluateAnyValue()` call.
|
|
106
106
|
*/
|
|
107
107
|
export interface NormalizedInput {
|
|
108
108
|
/** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
|
|
109
109
|
surface: string;
|
|
110
110
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
111
|
+
* Alternative lookup names for this one access, most-specific first.
|
|
112
|
+
* MCP is the only surface producing more than one; every other surface
|
|
113
|
+
* produces a single-element array. Order does not decide which rule wins
|
|
114
|
+
* (rule position does) — it decides which name the decision is reported
|
|
115
|
+
* under when the winning rule matches several.
|
|
114
116
|
*/
|
|
115
117
|
values: string[];
|
|
116
118
|
/**
|
|
@@ -51,6 +51,35 @@ export function parseQualifiedMcpToolName(
|
|
|
51
51
|
return { server, tool };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Find the configured server that owns `toolName` by the prefix convention.
|
|
56
|
+
*
|
|
57
|
+
* Returns the **longest** configured name that is the tool's leading
|
|
58
|
+
* `<server>_` segment, so `foo_bar_baz` belongs to `foo_bar` and never also to
|
|
59
|
+
* `foo`. Selecting here rather than trusting the caller's ordering keeps the
|
|
60
|
+
* rule true for any caller: the production loader sorts longest-first, but the
|
|
61
|
+
* `mcpServerNames` option does not.
|
|
62
|
+
*/
|
|
63
|
+
function findLongestPrefixServer(
|
|
64
|
+
toolName: string,
|
|
65
|
+
configuredServerNames: readonly string[],
|
|
66
|
+
): string | null {
|
|
67
|
+
let longest: string | null = null;
|
|
68
|
+
|
|
69
|
+
for (const serverName of configuredServerNames) {
|
|
70
|
+
const trimmedServerName = serverName.trim();
|
|
71
|
+
if (!trimmedServerName || !toolName.startsWith(`${trimmedServerName}_`)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (longest === null || trimmedServerName.length > longest.length) {
|
|
76
|
+
longest = trimmedServerName;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return longest;
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
function addDerivedMcpServerTargets(
|
|
55
84
|
toolName: string,
|
|
56
85
|
configuredServerNames: readonly string[],
|
|
@@ -61,6 +90,23 @@ function addDerivedMcpServerTargets(
|
|
|
61
90
|
return;
|
|
62
91
|
}
|
|
63
92
|
|
|
93
|
+
// Prefix convention (`github_search_code`): the name already carries its
|
|
94
|
+
// server, so the bare server is the only candidate worth deriving. A prefix
|
|
95
|
+
// hit also settles the name's convention, which is why the suffix pass below
|
|
96
|
+
// does not run — a tool ending in another configured server's name is a
|
|
97
|
+
// coincidence, not a second owner.
|
|
98
|
+
const prefixServer = findLongestPrefixServer(
|
|
99
|
+
trimmedToolName,
|
|
100
|
+
configuredServerNames,
|
|
101
|
+
);
|
|
102
|
+
if (prefixServer) {
|
|
103
|
+
targets.add(trimmedToolName);
|
|
104
|
+
targets.add(prefixServer);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Suffix convention (`search_code_github`): the server is not part of any
|
|
109
|
+
// candidate the caller will add, so the qualified forms are derived too.
|
|
64
110
|
for (const serverName of configuredServerNames) {
|
|
65
111
|
const trimmedServerName = serverName.trim();
|
|
66
112
|
if (!trimmedServerName) {
|
|
@@ -71,10 +117,6 @@ function addDerivedMcpServerTargets(
|
|
|
71
117
|
continue;
|
|
72
118
|
}
|
|
73
119
|
|
|
74
|
-
if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
120
|
targets.add(`${trimmedServerName}_${trimmedToolName}`);
|
|
79
121
|
targets.add(`${trimmedServerName}:${trimmedToolName}`);
|
|
80
122
|
targets.add(trimmedServerName);
|
|
@@ -92,8 +134,17 @@ function pushMcpToolPermissionTargets(
|
|
|
92
134
|
const resolvedTool = qualified?.tool ?? rawReference;
|
|
93
135
|
|
|
94
136
|
if (resolvedServer) {
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
// A name already carrying its server needs no re-prefixing: the qualified
|
|
138
|
+
// forms would be `github_github_search_code`, which no rule can usefully
|
|
139
|
+
// name, and which led the list as the reported target. The tool name is
|
|
140
|
+
// itself the qualified form, so it leads instead — matching what prefix
|
|
141
|
+
// derivation produces when no explicit server accompanies the call.
|
|
142
|
+
if (resolvedTool.startsWith(`${resolvedServer}_`)) {
|
|
143
|
+
targets.add(resolvedTool);
|
|
144
|
+
} else {
|
|
145
|
+
targets.add(`${resolvedServer}_${resolvedTool}`);
|
|
146
|
+
targets.add(`${resolvedServer}:${resolvedTool}`);
|
|
147
|
+
}
|
|
97
148
|
targets.add(resolvedServer);
|
|
98
149
|
} else {
|
|
99
150
|
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
|
|
@@ -107,8 +158,11 @@ function pushMcpToolPermissionTargets(
|
|
|
107
158
|
* Derive the ordered list of MCP permission-lookup candidates from a raw MCP
|
|
108
159
|
* tool invocation input.
|
|
109
160
|
*
|
|
110
|
-
* Candidates are ordered from most-specific to least-specific
|
|
111
|
-
* `
|
|
161
|
+
* Candidates are ordered from most-specific to least-specific. The order does
|
|
162
|
+
* not decide which rule wins — `evaluateAnyValue()` gives that to the last
|
|
163
|
+
* matching rule — but it decides which candidate a winning rule is reported
|
|
164
|
+
* against, so the most specific name the rule matches is the one the prompt
|
|
165
|
+
* and the review log show.
|
|
112
166
|
*/
|
|
113
167
|
export function createMcpPermissionTargets(
|
|
114
168
|
input: unknown,
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
|
|
3
3
|
import { normalizeInput } from "#src/access-intent/input-normalizer";
|
|
4
|
-
import {
|
|
5
|
-
PATH_SURFACES,
|
|
6
|
-
surfaceFamilyOf,
|
|
7
|
-
} from "#src/access-intent/path-surfaces";
|
|
4
|
+
import { surfaceFamilyOf } from "#src/access-intent/path-surfaces";
|
|
8
5
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
9
6
|
import {
|
|
10
7
|
getGlobalConfigPath,
|
|
@@ -29,7 +26,6 @@ import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
|
29
26
|
import {
|
|
30
27
|
evaluate,
|
|
31
28
|
evaluateAnyValue,
|
|
32
|
-
evaluateFirst,
|
|
33
29
|
floorAllowsToAsk,
|
|
34
30
|
isSurfaceFullyDenied,
|
|
35
31
|
rewriteAsksToYolo,
|
|
@@ -356,9 +352,10 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
356
352
|
/**
|
|
357
353
|
* Evaluate a normalized surface/values triple and shape the result.
|
|
358
354
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* `"tool"` and `"path-values"`
|
|
355
|
+
* Every surface resolves through {@link evaluateAnyValue}, so a rule's position
|
|
356
|
+
* in the config decides and the candidate list only determines which name the
|
|
357
|
+
* decision is reported under. Shared by the `"tool"` and `"path-values"`
|
|
358
|
+
* branches of {@link PermissionManager.check}.
|
|
362
359
|
*/
|
|
363
360
|
function buildCheckResult(
|
|
364
361
|
surface: string,
|
|
@@ -369,9 +366,7 @@ function buildCheckResult(
|
|
|
369
366
|
fullRules: Ruleset,
|
|
370
367
|
flavor: PathFlavor,
|
|
371
368
|
): PermissionCheckResult {
|
|
372
|
-
const { rule, value } =
|
|
373
|
-
? evaluateAnyValue(surface, values, fullRules, flavor)
|
|
374
|
-
: evaluateFirst(surface, values, fullRules, flavor);
|
|
369
|
+
const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
|
|
375
370
|
|
|
376
371
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
377
372
|
// matched candidate value so PermissionCheckResult.target is accurate.
|
package/src/policy/rule.ts
CHANGED
|
@@ -216,45 +216,15 @@ export function evaluateMostRestrictive(
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Evaluate a
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* evaluating the first candidate so the caller always receives a concrete
|
|
229
|
-
* result.
|
|
230
|
-
*/
|
|
231
|
-
export function evaluateFirst(
|
|
232
|
-
surface: string,
|
|
233
|
-
values: string[],
|
|
234
|
-
rules: Ruleset,
|
|
235
|
-
flavor: PathFlavor,
|
|
236
|
-
): { rule: Rule; value: string } {
|
|
237
|
-
for (const value of values) {
|
|
238
|
-
const rule = evaluate(surface, value, rules, flavor);
|
|
239
|
-
if (rule.layer !== "default") {
|
|
240
|
-
return { rule, value };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
// All candidates matched only the synthesized default — use the first.
|
|
244
|
-
const fallbackValue = values[0] ?? "*";
|
|
245
|
-
return {
|
|
246
|
-
rule: evaluate(surface, fallbackValue, rules, flavor),
|
|
247
|
-
value: fallbackValue,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Evaluate equivalent lookup values as aliases of the same path.
|
|
253
|
-
*
|
|
254
|
-
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
255
|
-
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
256
|
-
* legacy relative rules coexist without a catch-all match on the first alias
|
|
257
|
-
* masking a later, more specific rule on another alias.
|
|
219
|
+
* Evaluate a set of lookup values as alternative names for one access.
|
|
220
|
+
*
|
|
221
|
+
* Preserves rule ordering across the whole set: the last rule matching **any**
|
|
222
|
+
* value wins, and the reported value is the first one that rule matches. This
|
|
223
|
+
* is last-match-wins (see `evaluate()`) lifted from a single value to a
|
|
224
|
+
* candidate list, so a catch-all matched by one value cannot mask a later, more
|
|
225
|
+
* specific rule matched by another — whether the values are spellings of one
|
|
226
|
+
* path (`/proj/src/a.ts` and `src/a.ts`) or names for one MCP call
|
|
227
|
+
* (`github_search_code`, `github`, `mcp_call`).
|
|
258
228
|
*/
|
|
259
229
|
export function evaluateAnyValue(
|
|
260
230
|
surface: string,
|