@gotgenes/pi-permission-system 31.0.2 → 31.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
+ ## [31.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.0.2...pi-permission-system-v31.1.0) (2026-09-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** record every change to the effective tool surface in the debug log ([bf0331a](https://github.com/gotgenes/pi-packages/commit/bf0331a26ace5dcee7f6a26064af1251fecaaa41))
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** restore a tool when its deny rule is relaxed mid-session ([190a25e](https://github.com/gotgenes/pi-packages/commit/190a25eccdcd5101badf6266f10142c809296983))
18
+ * **pi-permission-system:** forget a withheld tool that pi has unregistered ([404d819](https://github.com/gotgenes/pi-packages/commit/404d8194f9763ee8a228bd36176c5adbb944464c)), closes [#873](https://github.com/gotgenes/pi-packages/issues/873)
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** document the tool-surface baseline ([0997065](https://github.com/gotgenes/pi-packages/commit/09970655c60bfe14c5a38b986301c480ed19f316))
23
+
8
24
  ## [31.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.0.1...pi-permission-system-v31.0.2) (2026-09-04)
9
25
 
10
26
 
@@ -1165,9 +1165,13 @@ Additional behaviors:
1165
1165
 
1166
1166
  - Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
1167
1167
  - 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`)
1168
+ - Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
1169
+ A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1170
+ - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt an extension receives before the extension runs, so the line is only regenerated once the restored tool is already active
1168
1171
  - 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))
1169
1172
  - 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
1170
- - 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
1173
+ - 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.
1174
+ A policy change is an intentional cache transition, as a mid-session agent switch already is.
1171
1175
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1172
1176
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
1173
1177
  - Permission review logs include `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked and every value bounded by `reviewLogFieldMaxWidth` (see [Log file sensitivity](#log-file-sensitivity))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.0.2",
3
+ "version": "31.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,9 +5,11 @@ import type {
5
5
  import type { TurnPreparation } from "#src/handlers/session-turn-prep";
6
6
  import type { PermissionResolver } from "#src/permission-resolver";
7
7
  import type { PermissionSession } from "#src/permission-session";
8
+ import type { DebugLogger } from "#src/session-logger";
8
9
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
9
10
  import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
10
11
  import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
12
+ import type { ToolSurfaceObservation } from "#src/tool-surface-baseline";
11
13
 
12
14
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
13
15
  interface BeforeAgentStartPayload {
@@ -42,7 +44,11 @@ export function shouldExposeTool(
42
44
  * session state
43
45
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
46
  * - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
45
- * - `toolRegistry` — Pi tool API subset (getActive + setActive)
47
+ * - `toolRegistry` — Pi tool API subset (getAll + getActive + setActive)
48
+ * - `logger` — records each change to the effective tool surface
49
+ *
50
+ * The active set is recomputed from the session's pre-filter tool surface
51
+ * every turn, so relaxing a rule restores the tool it had withheld (#873).
46
52
  */
47
53
  export class AgentPrepHandler {
48
54
  constructor(
@@ -50,6 +56,7 @@ export class AgentPrepHandler {
50
56
  private readonly session: PermissionSession,
51
57
  private readonly resolver: PermissionResolver,
52
58
  private readonly toolRegistry: ToolRegistry,
59
+ private readonly logger: DebugLogger,
53
60
  ) {}
54
61
 
55
62
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -60,24 +67,23 @@ export class AgentPrepHandler {
60
67
  this.turnPrep.prepare(ctx);
61
68
 
62
69
  const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
63
- const activeTools = this.toolRegistry.getActive();
64
- const allowedTools: string[] = [];
65
-
66
- for (const tool of activeTools) {
67
- const toolName = getToolNameFromValue(tool);
68
- if (!toolName) {
69
- continue;
70
- }
71
- if (
70
+ const surface = this.session.resolveExposedTools(
71
+ this.observeToolSurface(),
72
+ (toolName) =>
72
73
  shouldExposeTool(toolName, agentName, (t, a) =>
73
74
  this.resolver.isToolFullyDenied(t, a),
74
- )
75
- ) {
76
- allowedTools.push(toolName);
77
- }
78
- }
75
+ ),
76
+ );
77
+ const allowedTools = [...surface.exposed];
79
78
 
80
79
  this.toolRegistry.setActive(allowedTools);
80
+ if (surface.changed) {
81
+ this.logger.debug("tool_surface.changed", {
82
+ exposed: surface.exposed,
83
+ withheld: surface.withheld,
84
+ restored: surface.restored,
85
+ });
86
+ }
81
87
 
82
88
  const toolPromptResult = sanitizeAvailableToolsSection(
83
89
  event.systemPrompt,
@@ -94,4 +100,22 @@ export class AgentPrepHandler {
94
100
  ? { systemPrompt: skillPromptResult.prompt }
95
101
  : {};
96
102
  }
103
+
104
+ private observeToolSurface(): ToolSurfaceObservation {
105
+ return {
106
+ active: toolNamesOf(this.toolRegistry.getActive()),
107
+ registered: new Set(toolNamesOf(this.toolRegistry.getAll())),
108
+ };
109
+ }
110
+ }
111
+
112
+ function toolNamesOf(tools: readonly unknown[]): string[] {
113
+ const names: string[] = [];
114
+ for (const tool of tools) {
115
+ const toolName = getToolNameFromValue(tool);
116
+ if (toolName) {
117
+ names.push(toolName);
118
+ }
119
+ }
120
+ return names;
97
121
  }
package/src/index.ts CHANGED
@@ -318,6 +318,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
318
318
  session,
319
319
  resolver,
320
320
  toolRegistry,
321
+ logger,
321
322
  );
322
323
 
323
324
  const gateRunner = new GateRunner(
@@ -20,6 +20,11 @@ import {
20
20
  resolveToolPreviewLimits,
21
21
  type ToolPreviewFormatterOptions,
22
22
  } from "./tool-preview-formatter";
23
+ import {
24
+ ToolSurfaceBaseline,
25
+ type ToolSurfaceObservation,
26
+ type ToolSurfaceResolution,
27
+ } from "./tool-surface-baseline";
23
28
 
24
29
  /**
25
30
  * Encapsulates all mutable session state and exposes operations instead of
@@ -40,6 +45,7 @@ export class PermissionSession implements ToolCallGateInputs {
40
45
  private skillEntries: SkillPromptEntry[] = [];
41
46
  private knownAgentName: string | null = null;
42
47
  private pathNormalizer: PathNormalizer;
48
+ private readonly toolSurfaceBaseline = new ToolSurfaceBaseline();
43
49
 
44
50
  constructor(
45
51
  private readonly paths: ExtensionPaths,
@@ -108,6 +114,7 @@ export class PermissionSession implements ToolCallGateInputs {
108
114
  projectTrusted ? ctx.cwd : undefined,
109
115
  );
110
116
  this.skillEntries = [];
117
+ this.toolSurfaceBaseline.reset();
111
118
  this.activate(ctx);
112
119
  }
113
120
 
@@ -118,6 +125,7 @@ export class PermissionSession implements ToolCallGateInputs {
118
125
  shutdown(): void {
119
126
  this.sessionRules.clear();
120
127
  this.skillEntries = [];
128
+ this.toolSurfaceBaseline.reset();
121
129
  this.deactivate();
122
130
  }
123
131
 
@@ -134,6 +142,23 @@ export class PermissionSession implements ToolCallGateInputs {
134
142
  projectTrusted ? this.context?.cwd : undefined,
135
143
  );
136
144
  this.skillEntries = [];
145
+ // The tool-surface baseline deliberately survives a reload: a reload is
146
+ // when a relaxed policy arrives, and reseeding from the already-filtered
147
+ // active set would strand the tool it just un-denied (#873).
148
+ }
149
+
150
+ // ── Tool surface ───────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Answer which tools the agent may see this turn, applying `isExposed` to
154
+ * the session's pre-filter tool surface rather than to the previous turn's
155
+ * filtered result.
156
+ */
157
+ resolveExposedTools(
158
+ observation: ToolSurfaceObservation,
159
+ isExposed: (toolName: string) => boolean,
160
+ ): ToolSurfaceResolution {
161
+ return this.toolSurfaceBaseline.resolveExposed(observation, isExposed);
137
162
  }
138
163
 
139
164
  // ── Skill entries ──────────────────────────────────────────────────────
@@ -18,13 +18,19 @@ export interface ReviewLogger {
18
18
  }
19
19
 
20
20
  /**
21
- * Logging seam for consumers that write both debug and review entries.
22
- * Injected into `ConfigStore`, `ParentAuthorizer`, and `ForwardedRequestServer`.
21
+ * Narrowest logging seam consumers that only write debug-log entries.
22
+ * Injected into `AgentPrepHandler`.
23
23
  */
24
- export interface DebugReviewLogger extends ReviewLogger {
24
+ export interface DebugLogger {
25
25
  debug(event: string, details?: Record<string, unknown>): void;
26
26
  }
27
27
 
28
+ /**
29
+ * Logging seam for consumers that write both debug and review entries.
30
+ * Injected into `ConfigStore`, `ParentAuthorizer`, and `ForwardedRequestServer`.
31
+ */
32
+ export interface DebugReviewLogger extends ReviewLogger, DebugLogger {}
33
+
28
34
  /**
29
35
  * Unified logging + notification surface for handler deps.
30
36
  *
@@ -0,0 +1,101 @@
1
+ /** What a turn observed about pi's tool surface. */
2
+ export interface ToolSurfaceObservation {
3
+ /** Names pi reports active right now (`pi.getActiveTools()`). */
4
+ readonly active: readonly string[];
5
+ /** Names pi currently has registered (`pi.getAllTools()`). */
6
+ readonly registered: ReadonlySet<string>;
7
+ }
8
+
9
+ /** The effective tool surface for one turn, and what changed to produce it. */
10
+ export interface ToolSurfaceResolution {
11
+ /** Names to hand to `setActive`, in baseline order. */
12
+ readonly exposed: readonly string[];
13
+ /** Baseline members the current policy withholds. */
14
+ readonly withheld: readonly string[];
15
+ /** Names withheld on an earlier turn that the current policy exposes again. */
16
+ readonly restored: readonly string[];
17
+ /** Whether the withheld set differs from the previous turn's. */
18
+ readonly changed: boolean;
19
+ }
20
+
21
+ /** Decides whether the current policy lets the agent see a tool. */
22
+ type ToolExposurePolicy = (toolName: string) => boolean;
23
+
24
+ /**
25
+ * The runtime tool surface a session has, independent of what policy withholds.
26
+ *
27
+ * Filtering writes its result back through `setActive`, so reading the active
28
+ * set again next turn returns the *filtered* set. Applying policy to that makes
29
+ * the surface monotonically shrink and leaves a tool stranded once its rule is
30
+ * relaxed (#873). This baseline is the stable input policy is applied to
31
+ * instead: `exposed = baseline ∩ policy`, recomputed every turn.
32
+ *
33
+ * The baseline only ever grows from tools observed **active**, never from the
34
+ * whole registry, so a tool pi deliberately left inactive is never activated
35
+ * (#385). A tool that stops being active without this extension withholding it
36
+ * — another extension deactivating it — leaves the baseline with it.
37
+ */
38
+ export class ToolSurfaceBaseline {
39
+ private baseline: readonly string[] = [];
40
+ private withheld: ReadonlySet<string> = new Set();
41
+
42
+ resolveExposed(
43
+ observation: ToolSurfaceObservation,
44
+ isExposed: ToolExposurePolicy,
45
+ ): ToolSurfaceResolution {
46
+ const baseline = this.rebuild(observation);
47
+ const exposed: string[] = [];
48
+ const withheld: string[] = [];
49
+ for (const toolName of baseline) {
50
+ (isExposed(toolName) ? exposed : withheld).push(toolName);
51
+ }
52
+ const restored = exposed.filter((toolName) => this.withheld.has(toolName));
53
+ const changed = !holdsExactly(this.withheld, withheld);
54
+
55
+ this.baseline = baseline;
56
+ this.withheld = new Set(withheld);
57
+ return { exposed, withheld, restored, changed };
58
+ }
59
+
60
+ /** Forget the surface, so the next turn reseeds from what pi reports. */
61
+ reset(): void {
62
+ this.baseline = [];
63
+ this.withheld = new Set();
64
+ }
65
+
66
+ /**
67
+ * Reconstruct the pre-filter surface: the tools still active, plus the ones
68
+ * only this extension's own filtering removed, plus anything newly active.
69
+ *
70
+ * A withheld tool that has left the registry is forgotten rather than kept as
71
+ * a restoration candidate, so re-registering it inactive cannot activate it.
72
+ * The registry is consulted for withheld tools only — an active tool is real
73
+ * by definition — and an active tool is adopted whatever the registry says,
74
+ * so a registry that reports nothing can cost restoration candidates but
75
+ * never removes a tool pi has active.
76
+ */
77
+ private rebuild(observation: ToolSurfaceObservation): readonly string[] {
78
+ const active = new Set(observation.active);
79
+ const baseline = this.baseline.filter(
80
+ (toolName) =>
81
+ active.has(toolName) ||
82
+ (this.withheld.has(toolName) && observation.registered.has(toolName)),
83
+ );
84
+
85
+ const known = new Set(baseline);
86
+ for (const toolName of observation.active) {
87
+ if (!known.has(toolName)) {
88
+ known.add(toolName);
89
+ baseline.push(toolName);
90
+ }
91
+ }
92
+ return baseline;
93
+ }
94
+ }
95
+
96
+ function holdsExactly(
97
+ set: ReadonlySet<string>,
98
+ members: readonly string[],
99
+ ): boolean {
100
+ return set.size === members.length && members.every((m) => set.has(m));
101
+ }