@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 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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.1.0",
3
+ "version": "33.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 `evaluateFirst()` call.
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
- * Candidate lookup values in priority order (most-specific first).
112
- * Most surfaces produce a single-element array; MCP produces a
113
- * multi-candidate list derived from the invocation input.
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
- targets.add(`${resolvedServer}_${resolvedTool}`);
96
- targets.add(`${resolvedServer}:${resolvedTool}`);
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 so that
111
- * `evaluateFirst()` stops at the first non-default match.
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
- * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
360
- * aliases); every other surface keeps {@link evaluateFirst}. Shared by the
361
- * `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
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 } = PATH_SURFACES.has(surface)
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.
@@ -216,45 +216,15 @@ export function evaluateMostRestrictive(
216
216
  }
217
217
 
218
218
  /**
219
- * Evaluate a surface against an ordered list of candidate values, stopping at
220
- * the first candidate that matches a non-default rule (last-match-wins within
221
- * each candidate, first-non-default-wins across candidates).
222
- *
223
- * Used by MCP (multi-candidate target list) and, uniformly, by all other
224
- * surfaces (single-element candidate list).
225
- *
226
- * Returns the matched rule and the candidate value that produced it.
227
- * When every candidate matches only the synthesized default, falls back to
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,