@gotgenes/pi-permission-system 32.1.0 → 33.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/docs/configuration.md +40 -0
- package/docs/subagent-integration.md +5 -1
- package/package.json +1 -1
- package/src/access-intent/input-normalizer.ts +6 -4
- package/src/access-intent/mcp-targets.ts +62 -8
- package/src/policy/permission-manager.ts +6 -11
- package/src/policy/rule.ts +9 -39
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [33.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.0...pi-permission-system-v33.0.1) (2026-09-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** document the serving-liveness and bound-channel mechanism in the integration spec ([#942](https://github.com/gotgenes/pi-packages/issues/942)) ([2786acc](https://github.com/gotgenes/pi-packages/commit/2786accaf40136f192e6f8d187058ae60c7555b5)), closes [#942](https://github.com/gotgenes/pi-packages/issues/942)
|
|
14
|
+
|
|
15
|
+
## [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)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **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)
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **breaking:** honor last-match-wins for MCP rules instead of stopping at the first candidate ([c573696](https://github.com/gotgenes/pi-packages/commit/c57369648a4ffd64de9b07999e2687ceaa96e867))
|
|
25
|
+
* 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)
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* describe how MCP tool names derive server and tool permission targets ([49484a6](https://github.com/gotgenes/pi-packages/commit/49484a635d69292e8e0ad17dbe792019acd97269))
|
|
30
|
+
|
|
8
31
|
## [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
32
|
|
|
10
33
|
|
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
|
|
@@ -160,7 +160,9 @@ The polling session publishes the session id it polls, and a child checks that i
|
|
|
160
160
|
|
|
161
161
|
The announcement goes out on two channels, because a child cannot always reach the same one.
|
|
162
162
|
A child running inside its parent's process reads a process-global registry.
|
|
163
|
+
A stale mark left in that registry by a session that died without `session_shutdown` suppresses the fast-fail and falls back to the timeout — the safe direction for that channel.
|
|
163
164
|
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
|
|
165
|
+
That `serving/` directory is created on demand and never removed.
|
|
164
166
|
|
|
165
167
|
For an out-of-process target, four things count as "not draining":
|
|
166
168
|
|
|
@@ -171,7 +173,7 @@ For an out-of-process target, four things count as "not draining":
|
|
|
171
173
|
| A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
|
|
172
174
|
| A record for a different session id | The child is forwarding somewhere nobody is listening |
|
|
173
175
|
|
|
174
|
-
If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
176
|
+
If the target is not draining its inbox, the child gives up after a two-second grace window (`PERMISSION_FORWARDING_SERVING_GRACE_MS`) rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
175
177
|
|
|
176
178
|
```text
|
|
177
179
|
[pi-permission-system] Running bash command 'pwd' requires approval, but no
|
|
@@ -180,6 +182,7 @@ permission requests.
|
|
|
180
182
|
```
|
|
181
183
|
|
|
182
184
|
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
185
|
+
Absence of a record is deliberately **not** read as unknown: a cleanly exited parent leaves nothing behind, so treating absence as "maybe serving" would restore the full-timeout stall that the heartbeat exists to end.
|
|
183
186
|
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
184
187
|
That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
|
|
185
188
|
|
|
@@ -188,6 +191,7 @@ None of them is reported as a user denial, because no user was ever asked.
|
|
|
188
191
|
|
|
189
192
|
The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
|
|
190
193
|
When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
|
|
194
|
+
The child's `forwarded_permission.no_serving_session` entry also records `servingChannel` and `servingState` beside the ids observed, since "exited", "killed", and "polling a different session id" are different diagnoses the shared denial string does not distinguish.
|
|
191
195
|
|
|
192
196
|
When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
|
|
193
197
|
The serving node records its decider on the response — a rule of its own (with the surface, pattern, and origin that matched), the link that ruled, or the human who answered its dialog — and the child records it nested under a `forwarded` frame:
|
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,
|