@gotgenes/pi-permission-system 30.0.0 → 30.1.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
+ ## [30.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.0.0...pi-permission-system-v30.1.0) (2026-09-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** publish isToolFullyDenied for cross-extension tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([4345b19](https://github.com/gotgenes/pi-packages/commit/4345b199e5a33427e47bb80f9c16cb78fb1e7fbf)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** stop hiding a tool whose surface has a reachable non-deny rule ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([ce46b6c](https://github.com/gotgenes/pi-packages/commit/ce46b6c7b9f4c901d6a035f3be6e334f77a76e95)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** describe tool exposure as surface reachability ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([71d9696](https://github.com/gotgenes/pi-packages/commit/71d96969e5c33ecc0f38b0a43b0bdaf267622000))
22
+ * **pi-permission-system:** stop recommending getToolPermission for tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([fdf0a38](https://github.com/gotgenes/pi-packages/commit/fdf0a383d5419eaded4b6277ead8264e23aa281d))
23
+
8
24
  ## [30.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.3.0...pi-permission-system-v30.0.0) (2026-09-02)
9
25
 
10
26
 
package/dist/public.d.ts CHANGED
@@ -531,13 +531,17 @@ interface PermissionQuery {
531
531
  */
532
532
  checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
533
533
  /**
534
- * Query the tool-level permission state for pre-filtering tools before
535
- * creating a child session.
534
+ * Query a surface's catch-all permission state its blanket policy.
536
535
  *
537
536
  * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
538
537
  * Does not consider command-level rules (e.g. per-bash-command patterns) —
539
538
  * use `checkPermission` for runtime invocation gates.
540
539
  *
540
+ * This is **not** the question to ask when pre-filtering a tool list: a
541
+ * partially permissive surface such as `bash: {"*": "deny", "git *": "ask"}`
542
+ * answers `"deny"` here while `git status` would still be asked about. Use
543
+ * {@link PermissionsService.isToolFullyDenied} for that.
544
+ *
541
545
  * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
542
546
  * @param agentName - Optional agent name for per-agent policy resolution.
543
547
  */
@@ -557,6 +561,23 @@ interface PermissionQuery {
557
561
  * rules internally.
558
562
  */
559
563
  interface PermissionsService extends PermissionQuery {
564
+ /**
565
+ * Whether every value under a tool's surface resolves to `deny`.
566
+ *
567
+ * This is the question to ask before withholding a tool from a child
568
+ * session's tool list, and it is not `getToolPermission`: that reports the
569
+ * surface's own catch-all, so a partially permissive surface such as
570
+ * `bash: {"*": "deny", "git *": "ask"}` reads as `"deny"` while `git status`
571
+ * would still be asked about.
572
+ *
573
+ * Rule ordering is honored (last-match-wins), so an exception written *after*
574
+ * a `deny` catch-all keeps the tool reachable and one written *before* it
575
+ * does not.
576
+ *
577
+ * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
578
+ * @param agentName - Optional agent name for per-agent policy resolution.
579
+ */
580
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
560
581
  /**
561
582
  * Register a custom preview formatter for a specific tool name.
562
583
  *
@@ -295,6 +295,10 @@ A string value is a catch-all for that surface.
295
295
  Unknown or absent tools are not required in the config.
296
296
  If a tool is not registered at runtime, this extension blocks it before permission checks run.
297
297
 
298
+ A tool is withheld from the model entirely only when **every** pattern configured under its surface resolves to `deny`.
299
+ So `"bash": "deny"` hides the tool, while `"bash": { "*": "deny", "git *": "ask" }` keeps it visible — the agent can attempt a `git` command and be prompted, and everything else is denied at the gate.
300
+ Ordering follows the same last-match-wins rule as every other lookup: an exception written *after* the `deny` catch-all is reachable, while one written *before* it is shadowed and the tool is hidden.
301
+
298
302
  #### Path Patterns for File Tools
299
303
 
300
304
  For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
@@ -993,6 +997,9 @@ Avoid arrays, multi-line scalars, and YAML anchors.
993
997
  }
994
998
  ```
995
999
 
1000
+ The Bash tool stays visible to the agent here: the three `git` patterns are written after the `deny` catch-all, so they are reachable.
1001
+ Every other command is denied at the gate.
1002
+
996
1003
  ### Read-Only Bash Command Allowlist
997
1004
 
998
1005
  The [Read-Only Mode](#read-only-mode) recipe above gates *tools*; this one gates the *bash* surface.
@@ -1142,6 +1149,7 @@ Additional behaviors:
1142
1149
 
1143
1150
  - Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
1144
1151
  - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
1152
+ - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1145
1153
  - The `Available tools:` system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed
1146
1154
  - The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
1147
1155
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
@@ -69,9 +69,12 @@ interface PermissionsService {
69
69
  agentName?: string,
70
70
  ): PermissionCheckResult;
71
71
 
72
- /** Query tool-level permission state for pre-filtering before session creation. */
72
+ /** Query a surface's catch-all permission state its blanket policy. */
73
73
  getToolPermission(toolName: string, agentName?: string): PermissionState;
74
74
 
75
+ /** Whether every value under a tool's surface resolves to deny; use this to pre-filter a tool list. */
76
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
77
+
75
78
  /**
76
79
  * Register a custom preview formatter for a specific tool name.
77
80
  * Returns a disposer that unregisters the formatter.
@@ -130,14 +133,27 @@ Decomposition needs the tree-sitter parser, which is warmed at `before_agent_sta
130
133
  #### `getToolPermission`
131
134
 
132
135
  Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
133
- Use this to pre-filter a tool list before creating a child session — it avoids calling `checkPermission` per tool and interpreting the full result.
136
+ It reports the surface's own catch-all, so it answers what a surface's blanket policy is.
134
137
 
135
138
  ```typescript
136
- const denied = tools.filter(
137
- (t) => permissions.getToolPermission(t, agentName) === "deny",
138
- );
139
+ const blanketPolicy = permissions.getToolPermission("bash", agentName);
139
140
  ```
140
141
 
142
+ This is not the question to ask when pre-filtering a tool list — use `isToolFullyDenied` for that.
143
+ A surface written as `bash: {"*": "deny", "git *": "ask"}` reports `"deny"` here while `git status` would still be asked about.
144
+
145
+ #### `isToolFullyDenied`
146
+
147
+ Returns `true` when every value under the tool's surface resolves to `deny`, and `false` when anything at all could get through.
148
+ Use this to pre-filter a tool list before creating a child session — it avoids calling `checkPermission` per tool and interpreting the full result, and unlike `getToolPermission` it does not withhold a tool that is only partially restricted.
149
+
150
+ ```typescript
151
+ const usable = tools.filter((t) => !permissions.isToolFullyDenied(t, agentName));
152
+ ```
153
+
154
+ Rule ordering is honored (last-match-wins), so an exception written after a `deny` catch-all keeps the tool reachable while one written before it does not.
155
+ It considers config-layer rules only; a runtime session approval does not change the answer.
156
+
141
157
  #### `registerToolInputFormatter`
142
158
 
143
159
  Register a custom preview formatter for a specific tool name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "30.0.0",
3
+ "version": "30.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,6 @@ import type { PermissionSession } from "#src/permission-session";
8
8
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
9
9
  import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
10
10
  import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
11
- import type { PermissionState } from "#src/types";
12
11
 
13
12
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
14
13
  interface BeforeAgentStartPayload {
@@ -17,16 +16,17 @@ interface BeforeAgentStartPayload {
17
16
 
18
17
  /**
19
18
  * Pure helper: returns true when the tool should be exposed to the agent.
20
- * Checks the tool-level permission (not command-level) so that a blanket
21
- * `bash: deny` hides the tool entirely before any invocation is attempted.
19
+ *
20
+ * A tool is withheld only when *every* value under its surface resolves to
21
+ * `deny`, so a blanket `bash: deny` hides the tool entirely while a partially
22
+ * permissive `bash: {"*": "deny", "git *": "ask"}` keeps it reachable (#815).
22
23
  */
23
24
  export function shouldExposeTool(
24
25
  toolName: string,
25
26
  agentName: string | null,
26
- getToolPermission: (toolName: string, agentName?: string) => PermissionState,
27
+ isToolFullyDenied: (toolName: string, agentName?: string) => boolean,
27
28
  ): boolean {
28
- const toolPermission = getToolPermission(toolName, agentName ?? undefined);
29
- return toolPermission !== "deny";
29
+ return !isToolFullyDenied(toolName, agentName ?? undefined);
30
30
  }
31
31
 
32
32
  /**
@@ -41,7 +41,7 @@ export function shouldExposeTool(
41
41
  * - `turnPrep` — brings the node up to date for the turn before anything reads
42
42
  * session state
43
43
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
- * - `resolver` — owns permission-query surface: `getToolPermission`, skill check
44
+ * - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
45
45
  * - `toolRegistry` — Pi tool API subset (getActive + setActive)
46
46
  */
47
47
  export class AgentPrepHandler {
@@ -70,7 +70,7 @@ export class AgentPrepHandler {
70
70
  }
71
71
  if (
72
72
  shouldExposeTool(toolName, agentName, (t, a) =>
73
- this.resolver.getToolPermission(t, a),
73
+ this.resolver.isToolFullyDenied(t, a),
74
74
  )
75
75
  ) {
76
76
  allowedTools.push(toolName);
@@ -22,6 +22,7 @@ import {
22
22
  evaluateAnyValue,
23
23
  evaluateFirst,
24
24
  floorAllowsToAsk,
25
+ isSurfaceFullyDenied,
25
26
  rewriteAsksToYolo,
26
27
  } from "./rule";
27
28
  import { mergeScopesWithOrigins } from "./scope-merge";
@@ -83,6 +84,7 @@ export interface ScopedPermissionManager {
83
84
  sessionRules?: Ruleset,
84
85
  ): PermissionCheckResult;
85
86
  getToolPermission(toolName: string, agentName?: string): PermissionState;
87
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
86
88
  getConfigIssues(agentName?: string): string[];
87
89
  }
88
90
 
@@ -268,6 +270,24 @@ export class PermissionManager implements ScopedPermissionManager {
268
270
  return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
269
271
  }
270
272
 
273
+ /**
274
+ * Whether every value under a tool's surface resolves to `deny`.
275
+ *
276
+ * This is the question tool exposure asks, and it is not
277
+ * {@link PermissionManager.getToolPermission} — that reports the surface's
278
+ * catch-all, so `bash: {"*": "deny", "git *": "ask"}` reads as `deny` even
279
+ * though `git status` would be asked about (#815).
280
+ *
281
+ * Reads the same composed rules the catch-all query does, so it inherits the
282
+ * fail-closed floor and not the yolo rewrite. Neither matters: one touches
283
+ * only `allow` and the other only `ask`, so neither can create or remove the
284
+ * `deny` this answer turns on.
285
+ */
286
+ isToolFullyDenied(toolName: string, agentName?: string): boolean {
287
+ const { composedRules } = this.resolvePermissions(agentName);
288
+ return isSurfaceFullyDenied(toolName.trim(), composedRules, this.flavor);
289
+ }
290
+
271
291
  /**
272
292
  * Unified resolution entry point — dispatches on intent kind.
273
293
  *
@@ -126,10 +126,19 @@ export class PermissionResolver
126
126
  );
127
127
  }
128
128
 
129
+ // Reached only through `LocalPermissionsService`'s structural resolver view,
130
+ // which fallow cannot trace; `tsc` enforces it at that constructor. The
131
+ // handler's exposure check moved to `isToolFullyDenied` in #815, leaving this
132
+ // the published cross-extension catch-all query and nothing else.
133
+ // fallow-ignore-next-line unused-class-member
129
134
  getToolPermission(toolName: string, agentName?: string): PermissionState {
130
135
  return this.permissionManager.getToolPermission(toolName, agentName);
131
136
  }
132
137
 
138
+ isToolFullyDenied(toolName: string, agentName?: string): boolean {
139
+ return this.permissionManager.isToolFullyDenied(toolName, agentName);
140
+ }
141
+
133
142
  getConfigIssues(agentName?: string): string[] {
134
143
  return this.permissionManager.getConfigIssues(agentName);
135
144
  }
@@ -25,6 +25,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
25
25
  interface ResolverForService {
26
26
  resolve(intent: AccessIntent): PermissionCheckResult;
27
27
  getToolPermission(toolName: string, agentName?: string): PermissionState;
28
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
28
29
  }
29
30
 
30
31
  /** Narrow session view: hands out the cwd-bound path normalizer. */
@@ -81,6 +82,13 @@ export class LocalPermissionsService implements PermissionsService {
81
82
  return this.resolver.getToolPermission(toolName, agentName);
82
83
  }
83
84
 
85
+ isToolFullyDenied(
86
+ toolName: string,
87
+ agentName?: string,
88
+ ): ReturnType<PermissionsService["isToolFullyDenied"]> {
89
+ return this.resolver.isToolFullyDenied(toolName, agentName);
90
+ }
91
+
84
92
  registerToolInputFormatter(
85
93
  toolName: string,
86
94
  formatter: ToolInputFormatter,
package/src/rule.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PathFlavor } from "#src/path/path-flavor";
2
2
 
3
3
  import { PATH_SURFACES } from "./access-intent/path-surfaces";
4
+ import { expandHomePath } from "./expand-home";
4
5
  import type { PermissionState } from "./types";
5
6
  import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
6
7
 
@@ -139,6 +140,54 @@ function ruleMatches(
139
140
  );
140
141
  }
141
142
 
143
+ /**
144
+ * Whether every value on `surface` resolves to `deny`.
145
+ *
146
+ * This is a different question from "what does the surface's catch-all say?",
147
+ * and it carries a different burden of proof: the catch-all answers *what this
148
+ * surface is*, while this answers *whether anything at all could get through* —
149
+ * the question tool exposure has to ask before withholding a tool from the
150
+ * agent entirely.
151
+ *
152
+ * Reachability is decided by probing each pattern configured on the surface as
153
+ * a representative value through {@link evaluate}, so last-match-wins shadowing
154
+ * is honored: an exception written *after* a `deny` catch-all is reachable,
155
+ * while one written *before* it is not.
156
+ *
157
+ * The probe is an approximation of "does any string resolve non-deny", not a
158
+ * decision procedure for it. Being wrong in either direction only changes
159
+ * whether the agent sees the tool — the invocation gate re-evaluates the real
160
+ * value against the same ruleset either way.
161
+ */
162
+ export function isSurfaceFullyDenied(
163
+ surface: string,
164
+ rules: Ruleset,
165
+ flavor: PathFlavor,
166
+ ): boolean {
167
+ for (const value of probeValuesForSurface(surface, rules)) {
168
+ if (evaluate(surface, value, rules, flavor).action !== "deny") return false;
169
+ }
170
+ return true;
171
+ }
172
+
173
+ /**
174
+ * The representative values {@link isSurfaceFullyDenied} probes: the catch-all,
175
+ * plus every pattern configured on a rule whose surface reaches `surface`.
176
+ *
177
+ * Each pattern is home-expanded because {@link compileWildcardPattern} expands
178
+ * the *pattern* side only; an unexpanded `~/notes/*` probe would fail to match
179
+ * its own rule and report the surface denied.
180
+ */
181
+ function probeValuesForSurface(surface: string, rules: Ruleset): Set<string> {
182
+ const values = new Set<string>(["*"]);
183
+ for (const rule of rules) {
184
+ if (wildcardMatch(rule.surface, surface)) {
185
+ values.add(expandHomePath(rule.pattern));
186
+ }
187
+ }
188
+ return values;
189
+ }
190
+
142
191
  /**
143
192
  * Evaluate a surface against multiple values, returning the most restrictive
144
193
  * non-allow result (deny > ask > allow).
package/src/service.ts CHANGED
@@ -99,13 +99,17 @@ export interface PermissionQuery {
99
99
  ): PermissionCheckResult;
100
100
 
101
101
  /**
102
- * Query the tool-level permission state for pre-filtering tools before
103
- * creating a child session.
102
+ * Query a surface's catch-all permission state its blanket policy.
104
103
  *
105
104
  * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
106
105
  * Does not consider command-level rules (e.g. per-bash-command patterns) —
107
106
  * use `checkPermission` for runtime invocation gates.
108
107
  *
108
+ * This is **not** the question to ask when pre-filtering a tool list: a
109
+ * partially permissive surface such as `bash: {"*": "deny", "git *": "ask"}`
110
+ * answers `"deny"` here while `git status` would still be asked about. Use
111
+ * {@link PermissionsService.isToolFullyDenied} for that.
112
+ *
109
113
  * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
110
114
  * @param agentName - Optional agent name for per-agent policy resolution.
111
115
  */
@@ -126,6 +130,24 @@ export interface PermissionQuery {
126
130
  * rules internally.
127
131
  */
128
132
  export interface PermissionsService extends PermissionQuery {
133
+ /**
134
+ * Whether every value under a tool's surface resolves to `deny`.
135
+ *
136
+ * This is the question to ask before withholding a tool from a child
137
+ * session's tool list, and it is not `getToolPermission`: that reports the
138
+ * surface's own catch-all, so a partially permissive surface such as
139
+ * `bash: {"*": "deny", "git *": "ask"}` reads as `"deny"` while `git status`
140
+ * would still be asked about.
141
+ *
142
+ * Rule ordering is honored (last-match-wins), so an exception written *after*
143
+ * a `deny` catch-all keeps the tool reachable and one written *before* it
144
+ * does not.
145
+ *
146
+ * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
147
+ * @param agentName - Optional agent name for per-agent policy resolution.
148
+ */
149
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
150
+
129
151
  /**
130
152
  * Register a custom preview formatter for a specific tool name.
131
153
  *