@gotgenes/pi-permission-system 32.0.3 → 32.0.5
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 +25 -0
- package/README.md +1 -1
- package/docs/configuration.md +13 -9
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +19 -1
- package/src/access-intent/bash/command-enumeration.ts +68 -7
- package/src/access-intent/bash/parser.ts +18 -2
- package/src/access-intent/bash/program.ts +21 -11
- package/src/access-intent/bash/sync-commands.ts +10 -2
- package/src/access-intent/bash/unresolved-salvage.ts +97 -0
- package/src/authority/authorizer-chain-audit.ts +99 -0
- package/src/authority/authorizer-selection.ts +30 -19
- package/src/handlers/gates/bash-command.ts +34 -20
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
## [32.0.5](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.4...pi-permission-system-v32.0.5) (2026-09-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** tell the operator when a configured chain link is missing ([3662541](https://github.com/gotgenes/pi-packages/commit/3662541d4ebc555d54faf2934085d22b36ee618e)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** record the unregistered-link warning ([f70b1e3](https://github.com/gotgenes/pi-packages/commit/f70b1e3ce52a38744da2a07880878efb61dfae00)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
18
|
+
* **pi-permission-system:** state the resolved skip in the boundary test comment ([9cd18f1](https://github.com/gotgenes/pi-packages/commit/9cd18f1ef6bd8f9dbabf6fc6ce6b9dfa58d64764)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
19
|
+
|
|
20
|
+
## [32.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.3...pi-permission-system-v32.0.4) (2026-09-16)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** evaluate bash rules against a command a partial parse dropped ([28cedc4](https://github.com/gotgenes/pi-packages/commit/28cedc45dd4a11ee9189f41ee661a051d2b13936)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
26
|
+
* **pi-permission-system:** gate the paths a command dropped by a partial parse reads ([6f1d032](https://github.com/gotgenes/pi-packages/commit/6f1d0328f8151da8716641f0ee0c25341699cd75)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
27
|
+
* **pi-permission-system:** keep the whole-command deny reachable when a salvage recovers the only units ([ab91a66](https://github.com/gotgenes/pi-packages/commit/ab91a660eafb879a3370104cb6f68353041a7cbb)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** record the salvage of a bash command a partial parse dropped ([17a3684](https://github.com/gotgenes/pi-packages/commit/17a3684f182467c19e1ca56664e1adf900980f11))
|
|
32
|
+
|
|
8
33
|
## [32.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.2...pi-permission-system-v32.0.3) (2026-09-15)
|
|
9
34
|
|
|
10
35
|
|
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 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`)
|
|
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`); where a partial parse failure's own region re-parses cleanly on its own, the commands and paths it holds are recovered and gated rather than merely prompted for
|
|
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
|
@@ -226,9 +226,11 @@ Three invariants govern the chain:
|
|
|
226
226
|
|
|
227
227
|
1. **Config order wins, never registration order.**
|
|
228
228
|
The order in `authorizerChain` — not the order extensions happen to register in — fixes the security-relevant chain order.
|
|
229
|
-
2. **A missing link is skipped fail-safe.**
|
|
230
|
-
A name with no registered link is skipped
|
|
229
|
+
2. **A missing link is skipped fail-safe, and you are told.**
|
|
230
|
+
A name with no registered link is skipped; the `ask` still reaches the terminal.
|
|
231
231
|
Absence of a judge means *more* prompting, never less.
|
|
232
|
+
Because you asked for that judge and did not get it, the skip also raises a warning naming the link — once per session per name, beside the per-ask review record.
|
|
233
|
+
Three things leave the identical absence, so the warning names the likeliest and admits the others: the extension providing the link is not loaded in this session (a subagent child's `excludedExtensionPackages` does this), it failed to load, or it declined to register because it has no configuration of its own.
|
|
232
234
|
3. **Registration alone grants no authority.**
|
|
233
235
|
Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
|
|
234
236
|
|
|
@@ -244,12 +246,12 @@ The subagent itself resolves no links (an extension cannot register one in a chi
|
|
|
244
246
|
|
|
245
247
|
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
|
|
246
248
|
|
|
247
|
-
| Record | Meaning
|
|
248
|
-
| ------------------------------------ |
|
|
249
|
-
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace
|
|
250
|
-
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here
|
|
251
|
-
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal
|
|
252
|
-
| `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted
|
|
249
|
+
| Record | Meaning |
|
|
250
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
251
|
+
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
|
252
|
+
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
|
253
|
+
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal, and the first skip of that name also warns you |
|
|
254
|
+
| `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
|
|
253
255
|
|
|
254
256
|
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`, taking `sessionId` from that event's payload; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
|
255
257
|
Registration returns a disposer, and only one link may hold a given name.
|
|
@@ -429,11 +431,13 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
429
431
|
- If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is **blocked** rather than passed ungated, and a `gate_error` entry is written to the review log naming the failure.
|
|
430
432
|
- 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
433
|
A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
|
|
434
|
+
That whole-command check runs whenever the parse itself matched nothing, including when the recovery below went on to recover a command from the wreckage, so a rule naming the command in context (`"* rm -rf *"`) is still consulted.
|
|
432
435
|
An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
|
|
433
436
|
- A command the parser could only *partly* resolve is floored the same way (the synthetic `<unparsed-bash-subtree>` pattern in the review log).
|
|
434
437
|
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
|
|
438
|
+
The prompt names the **whole** command rather than the unit, because the fragment that did parse is not what you need to see.
|
|
436
439
|
A statement beside the failed one keeps its own rule.
|
|
440
|
+
Where the unresolved region's own text parses cleanly on its own, the commands and paths inside it are recovered and gated too, so a `deny` covering one of them still denies rather than prompting — a region whose own text does not re-parse is left to the floor, since error recovery invents the structure inside one and inventions do not re-parse.
|
|
437
441
|
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.
|
|
438
442
|
- 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).
|
|
439
443
|
An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
|
package/package.json
CHANGED
|
@@ -127,13 +127,31 @@ export class BashPathResolver {
|
|
|
127
127
|
* itself is added to the external paths when it resolves outside the cwd.
|
|
128
128
|
* Containment is always measured against the session cwd baked into the
|
|
129
129
|
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
|
|
130
|
+
*
|
|
131
|
+
* `salvagedRoots` are regions the primary parse could not resolve, re-parsed
|
|
132
|
+
* cleanly on their own (`unresolved-salvage.ts`, #875). Their candidates are
|
|
133
|
+
* collected into the same array before projection runs, so a path both a
|
|
134
|
+
* salvaged region and the primary parse name folds to one entry rather than
|
|
135
|
+
* showing twice in the prompt. Each is walked under the **unknown** base: a
|
|
136
|
+
* fragment carries no record of the `cd` in force where it sat, and
|
|
137
|
+
* resolving `cat rel.txt` against the session cwd after `cd /outside` would
|
|
138
|
+
* name a different file than the one that runs — a rule for that other path
|
|
139
|
+
* could then allow this access. #393's unknown base declines the claim
|
|
140
|
+
* instead, keeping an absolute token literal-only and unconditionally
|
|
141
|
+
* external while a relative one is not projected at all.
|
|
130
142
|
*/
|
|
131
|
-
resolve(
|
|
143
|
+
resolve(
|
|
144
|
+
rootNode: TSNode,
|
|
145
|
+
salvagedRoots: readonly TSNode[] = [],
|
|
146
|
+
): ResolvedBashPaths {
|
|
132
147
|
const initialBase =
|
|
133
148
|
this.workdir === undefined
|
|
134
149
|
? CWD_BASE
|
|
135
150
|
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
|
136
151
|
const candidates = this.collectPathCandidates(rootNode, initialBase);
|
|
152
|
+
for (const salvaged of salvagedRoots) {
|
|
153
|
+
this.walkForCandidates(salvaged, UNKNOWN_BASE, candidates);
|
|
154
|
+
}
|
|
137
155
|
return {
|
|
138
156
|
externalAccesses: this.withWorkdirExternal(
|
|
139
157
|
this.projectExternalPaths(candidates),
|
|
@@ -57,6 +57,19 @@ export interface BashCommand {
|
|
|
57
57
|
* 0013 §10's fail-closed base case (#840).
|
|
58
58
|
*/
|
|
59
59
|
readonly parseUnresolved?: true;
|
|
60
|
+
/**
|
|
61
|
+
* Set when this unit came from a region re-parsed out of a subtree the
|
|
62
|
+
* primary parse could not resolve, rather than from the primary parse
|
|
63
|
+
* itself (#875).
|
|
64
|
+
*
|
|
65
|
+
* Narrower than {@link parseUnresolved}, which a primary unit also carries
|
|
66
|
+
* when its enclosing statement failed. The verdict fold needs the
|
|
67
|
+
* distinction to tell whether the *primary* parse found anything: when it
|
|
68
|
+
* found nothing, the whole command string is the only surface an explicit
|
|
69
|
+
* `deny` can reach (#452), and salvaging a unit must not make that check
|
|
70
|
+
* unreachable.
|
|
71
|
+
*/
|
|
72
|
+
readonly salvaged?: true;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/**
|
|
@@ -83,12 +96,34 @@ interface UnitScope {
|
|
|
83
96
|
* resolve, so every unit beneath it is floored rather than trusted (#840).
|
|
84
97
|
*/
|
|
85
98
|
readonly parseUnresolved: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* True when the walk started from a salvaged region rather than the primary
|
|
101
|
+
* parse tree (#875). Relayed unchanged, including into nested executions:
|
|
102
|
+
* everything found inside a salvaged region is salvaged.
|
|
103
|
+
*/
|
|
104
|
+
readonly salvaged: boolean;
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
/** A top-level command in the current shell, writing no file, fully parsed. */
|
|
89
108
|
const TOP_LEVEL_SCOPE: UnitScope = {
|
|
90
109
|
writesViaRedirect: false,
|
|
91
110
|
parseUnresolved: false,
|
|
111
|
+
salvaged: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The scope a salvaged region's own units run under.
|
|
116
|
+
*
|
|
117
|
+
* Marked unresolved because the region reached the salvage only by failing in
|
|
118
|
+
* the primary parse, so the verdict fold floors what it recovers rather than
|
|
119
|
+
* trusting it. `writesViaRedirect` starts false for the same reason
|
|
120
|
+
* {@link collectHostedCommands} resets it: a redirect established outside the
|
|
121
|
+
* region is the enclosing statement's, not the region's.
|
|
122
|
+
*/
|
|
123
|
+
const SALVAGED_SCOPE: UnitScope = {
|
|
124
|
+
writesViaRedirect: false,
|
|
125
|
+
parseUnresolved: true,
|
|
126
|
+
salvaged: true,
|
|
92
127
|
};
|
|
93
128
|
|
|
94
129
|
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
@@ -212,6 +247,22 @@ export function collectCommands(node: TSNode): BashCommand[] {
|
|
|
212
247
|
return out;
|
|
213
248
|
}
|
|
214
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Enumerate the command units of a region the primary parse could not resolve,
|
|
252
|
+
* re-parsed cleanly on its own (`unresolved-salvage.ts`, #875).
|
|
253
|
+
*
|
|
254
|
+
* The same walk as {@link collectCommands}, differing only in the scope it
|
|
255
|
+
* starts from: every unit is marked {@link BashCommand.parseUnresolved}, so a
|
|
256
|
+
* command the primary parse dropped is matched against the bash rules — an
|
|
257
|
+
* explicit `deny` fires — while its `allow` is still floored to `ask` by the
|
|
258
|
+
* verdict fold (#840).
|
|
259
|
+
*/
|
|
260
|
+
export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
261
|
+
const out: BashCommand[] = [];
|
|
262
|
+
collectCommandsInto(node, SALVAGED_SCOPE, out);
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
215
266
|
function collectCommandsInto(
|
|
216
267
|
node: TSNode,
|
|
217
268
|
inherited: UnitScope,
|
|
@@ -228,7 +279,7 @@ function collectCommandsInto(
|
|
|
228
279
|
out.push(makeCommandUnit(node, scope));
|
|
229
280
|
// A command's text already contains any substitution; descend its subtree
|
|
230
281
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
231
|
-
collectHostedCommands(node, out);
|
|
282
|
+
collectHostedCommands(node, scope, out);
|
|
232
283
|
return;
|
|
233
284
|
}
|
|
234
285
|
|
|
@@ -240,7 +291,7 @@ function collectCommandsInto(
|
|
|
240
291
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
241
292
|
// Not a command itself, but its subtree can host one that really runs
|
|
242
293
|
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
|
243
|
-
collectHostedCommands(node, out);
|
|
294
|
+
collectHostedCommands(node, scope, out);
|
|
244
295
|
return;
|
|
245
296
|
}
|
|
246
297
|
|
|
@@ -281,7 +332,7 @@ function collectCommandsInto(
|
|
|
281
332
|
// really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
|
|
282
333
|
// in addition to the statement (#742).
|
|
283
334
|
out.push(makeUnit(node.text, scope));
|
|
284
|
-
collectHostedCommands(node, out);
|
|
335
|
+
collectHostedCommands(node, scope, out);
|
|
285
336
|
}
|
|
286
337
|
|
|
287
338
|
/**
|
|
@@ -328,9 +379,10 @@ function makeUnit(
|
|
|
328
379
|
executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
329
380
|
const exempted =
|
|
330
381
|
floorExemption === undefined ? named : { ...named, floorExemption };
|
|
331
|
-
|
|
382
|
+
const marked: BashCommand = scope.parseUnresolved
|
|
332
383
|
? { ...exempted, parseUnresolved: true }
|
|
333
384
|
: exempted;
|
|
385
|
+
return scope.salvaged ? { ...marked, salvaged: true } : marked;
|
|
334
386
|
}
|
|
335
387
|
|
|
336
388
|
/**
|
|
@@ -450,7 +502,7 @@ function descendStatementChildren(
|
|
|
450
502
|
const child = node.child(i);
|
|
451
503
|
if (!child?.isNamed) continue;
|
|
452
504
|
if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
|
|
453
|
-
else collectHostedCommands(child, out);
|
|
505
|
+
else collectHostedCommands(child, scope, out);
|
|
454
506
|
}
|
|
455
507
|
}
|
|
456
508
|
|
|
@@ -465,7 +517,11 @@ function descendStatementChildren(
|
|
|
465
517
|
* `node` may be a context outright or merely host one, so the traversal is the
|
|
466
518
|
* root-inclusive `forEachExecutionIn`.
|
|
467
519
|
*/
|
|
468
|
-
function collectHostedCommands(
|
|
520
|
+
function collectHostedCommands(
|
|
521
|
+
node: TSNode,
|
|
522
|
+
scope: UnitScope,
|
|
523
|
+
out: BashCommand[],
|
|
524
|
+
): void {
|
|
469
525
|
forEachExecutionIn(node, (contextNode, context) => {
|
|
470
526
|
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
471
527
|
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
@@ -475,7 +531,12 @@ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
|
475
531
|
// units carry the mark regardless, so the verdict is unchanged (#840).
|
|
476
532
|
descendCommandChildren(
|
|
477
533
|
contextNode,
|
|
478
|
-
{
|
|
534
|
+
{
|
|
535
|
+
context,
|
|
536
|
+
writesViaRedirect: false,
|
|
537
|
+
parseUnresolved: false,
|
|
538
|
+
salvaged: scope.salvaged,
|
|
539
|
+
},
|
|
479
540
|
out,
|
|
480
541
|
);
|
|
481
542
|
});
|
|
@@ -79,16 +79,32 @@ export function parseUnresolvedAt(node: TSNode): boolean {
|
|
|
79
79
|
* fact about redirects, not about statements: a statement whose *predecessor*
|
|
80
80
|
* failed is not itself unparsed, and borrowing the wider predicate here would
|
|
81
81
|
* condemn every statement following a failed one.
|
|
82
|
+
*
|
|
83
|
+
* `unresolved-salvage.ts` asks the same question twice over: to locate the
|
|
84
|
+
* innermost region worth re-parsing, and to refuse the re-parse's own result
|
|
85
|
+
* when it failed too (#875).
|
|
82
86
|
*/
|
|
83
87
|
export function parseUnresolvedWithin(node: TSNode): boolean {
|
|
84
88
|
return node.hasError;
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
|
88
|
-
*
|
|
92
|
+
* The one parse capability a consumer needs to re-parse a fragment of a
|
|
93
|
+
* command on its own.
|
|
94
|
+
*
|
|
95
|
+
* Narrower than {@link TSParser} on purpose: that interface also carries the
|
|
96
|
+
* parser's own `delete()`, which destroys the process-wide memoized parser for
|
|
97
|
+
* every later command. A consumer re-parsing a fragment has no business
|
|
98
|
+
* holding that, so it takes this instead (`unresolved-salvage.ts`, #875).
|
|
89
99
|
*/
|
|
90
|
-
interface
|
|
100
|
+
export interface BashReparser {
|
|
91
101
|
parse(input: string): { rootNode: TSNode; delete(): void } | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Minimal subset of web-tree-sitter's Parser used by this module.
|
|
106
|
+
*/
|
|
107
|
+
interface TSParser extends BashReparser {
|
|
92
108
|
delete(): void;
|
|
93
109
|
}
|
|
94
110
|
|
|
@@ -4,8 +4,13 @@ import {
|
|
|
4
4
|
BashPathResolver,
|
|
5
5
|
type BashPathRuleCandidate,
|
|
6
6
|
} from "./bash-path-resolver";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type BashCommand,
|
|
9
|
+
collectCommands,
|
|
10
|
+
collectSalvagedCommands,
|
|
11
|
+
} from "./command-enumeration";
|
|
8
12
|
import { getParser } from "./parser";
|
|
13
|
+
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
9
14
|
|
|
10
15
|
export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
|
|
11
16
|
|
|
@@ -56,16 +61,21 @@ export class BashProgram {
|
|
|
56
61
|
if (!tree) return new BashProgram(command, [], [], []);
|
|
57
62
|
|
|
58
63
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
|
|
65
|
+
const { externalAccesses, ruleCandidates } = new BashPathResolver(
|
|
66
|
+
normalizer,
|
|
67
|
+
options?.workdir,
|
|
68
|
+
).resolve(tree.rootNode, salvaged);
|
|
69
|
+
return new BashProgram(
|
|
70
|
+
command,
|
|
71
|
+
[
|
|
72
|
+
...collectCommands(tree.rootNode),
|
|
73
|
+
...salvaged.flatMap(collectSalvagedCommands),
|
|
74
|
+
],
|
|
75
|
+
externalAccesses,
|
|
76
|
+
ruleCandidates,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
69
79
|
} finally {
|
|
70
80
|
tree.delete();
|
|
71
81
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type BashCommand,
|
|
3
|
+
collectCommands,
|
|
4
|
+
collectSalvagedCommands,
|
|
5
|
+
} from "./command-enumeration";
|
|
2
6
|
import { getWarmBashParser } from "./parser";
|
|
7
|
+
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Synchronously enumerate the command-pattern units of a bash command using the
|
|
@@ -21,7 +26,10 @@ export function parseBashCommandsSync(command: string): BashCommand[] | null {
|
|
|
21
26
|
const tree = parser.parse(command);
|
|
22
27
|
if (!tree) return [];
|
|
23
28
|
try {
|
|
24
|
-
return
|
|
29
|
+
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => [
|
|
30
|
+
...collectCommands(tree.rootNode),
|
|
31
|
+
...salvaged.flatMap(collectSalvagedCommands),
|
|
32
|
+
]);
|
|
25
33
|
} finally {
|
|
26
34
|
tree.delete();
|
|
27
35
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BashReparser,
|
|
3
|
+
parseUnresolvedWithin,
|
|
4
|
+
type TSNode,
|
|
5
|
+
} from "./parser";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `use` over the roots of every region the primary parse could not resolve
|
|
9
|
+
* but which re-parses cleanly on its own, then delete the trees it created.
|
|
10
|
+
*
|
|
11
|
+
* `tree-sitter-bash` 0.25.1 cannot parse a heredoc redirect combined with
|
|
12
|
+
* `2>&1` **and** a pipe, though each pairing alone is fine. Its recovery hangs
|
|
13
|
+
* an `ERROR` node holding only the `|` under `heredoc_redirect → file_redirect`
|
|
14
|
+
* and leaves the piped command's words as plain siblings of it — and
|
|
15
|
+
* `heredoc_redirect` is an execution host, descended for the substitutions it
|
|
16
|
+
* may carry and never read for text. So `git commit -F - <<'MSG' 2>&1 | rm -rf
|
|
17
|
+
* /tmp/x` enumerates `git commit -F` and nothing else, and a configured
|
|
18
|
+
* `bash: {"rm -rf *": "deny"}` is never evaluated against a command that
|
|
19
|
+
* really runs (#875).
|
|
20
|
+
*
|
|
21
|
+
* Re-parsing the dropped region's own source text recovers it, because the
|
|
22
|
+
* grammar gap is in the *combination* — `2>&1 | rm -rf /tmp/x` parses
|
|
23
|
+
* perfectly on its own.
|
|
24
|
+
*
|
|
25
|
+
* The roots are handed to a callback rather than returned because each belongs
|
|
26
|
+
* to a tree that must outlive its use and be released afterwards, exactly as
|
|
27
|
+
* the primary parse's caller already does for its own tree.
|
|
28
|
+
*
|
|
29
|
+
* Salvaging is purely additive: a caller enumerates these roots *in addition
|
|
30
|
+
* to* the primary one, so the result can only ever be more restrictive.
|
|
31
|
+
*/
|
|
32
|
+
export function withSalvagedRoots<T>(
|
|
33
|
+
primary: TSNode,
|
|
34
|
+
reparser: BashReparser,
|
|
35
|
+
use: (salvaged: readonly TSNode[]) => T,
|
|
36
|
+
): T {
|
|
37
|
+
const trees: { rootNode: TSNode; delete(): void }[] = [];
|
|
38
|
+
try {
|
|
39
|
+
for (const candidate of unresolvedRegionsWithin(primary)) {
|
|
40
|
+
const tree = reparser.parse(candidate.text);
|
|
41
|
+
if (!tree) continue;
|
|
42
|
+
// The whole safety argument: tree-sitter's error recovery *invents* the
|
|
43
|
+
// structure inside an unresolved region (#742), and invented structure
|
|
44
|
+
// does not re-parse. Without this check `cat <> rw.txt` salvages a
|
|
45
|
+
// command unit whose text is `">"`, matched against the bash rules like
|
|
46
|
+
// any real command.
|
|
47
|
+
if (parseUnresolvedWithin(tree.rootNode)) {
|
|
48
|
+
tree.delete();
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
trees.push(tree);
|
|
52
|
+
}
|
|
53
|
+
return use(trees.map(({ rootNode }) => rootNode));
|
|
54
|
+
} finally {
|
|
55
|
+
for (const tree of trees) tree.delete();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The innermost nodes beneath `root` whose subtree the parser could not
|
|
61
|
+
* resolve, in source order.
|
|
62
|
+
*
|
|
63
|
+
* Three exclusions shape the answer.
|
|
64
|
+
*
|
|
65
|
+
* An `ERROR` node is never a candidate and is never descended in search of
|
|
66
|
+
* one: its interior is recovery's invention rather than anything observed
|
|
67
|
+
* (#742), so the region worth re-parsing is the node that *holds* it.
|
|
68
|
+
*
|
|
69
|
+
* Only the innermost such node is offered. An enclosing statement reports the
|
|
70
|
+
* error too, and its text re-parses to the same failure, so offering it
|
|
71
|
+
* salvages nothing while burying the fragment that would have worked.
|
|
72
|
+
*
|
|
73
|
+
* `root` itself is never a candidate, for the same reason taken to its limit:
|
|
74
|
+
* re-parsing the whole source reproduces the whole failure by construction.
|
|
75
|
+
*/
|
|
76
|
+
function unresolvedRegionsWithin(root: TSNode): TSNode[] {
|
|
77
|
+
const found: TSNode[] = [];
|
|
78
|
+
collectInnermostUnresolved(root, root, found);
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectInnermostUnresolved(
|
|
83
|
+
node: TSNode,
|
|
84
|
+
root: TSNode,
|
|
85
|
+
found: TSNode[],
|
|
86
|
+
): void {
|
|
87
|
+
if (!parseUnresolvedWithin(node)) return;
|
|
88
|
+
const before = found.length;
|
|
89
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
90
|
+
const child = node.child(i);
|
|
91
|
+
if (child && child.type !== "ERROR") {
|
|
92
|
+
collectInnermostUnresolved(child, root, found);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const foundDeeper = found.length > before;
|
|
96
|
+
if (!foundDeeper && node !== root && node.type !== "ERROR") found.push(node);
|
|
97
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authorizer-chain-audit.ts — Report a configured `authorizerChain` link this
|
|
3
|
+
* node's registry could not resolve.
|
|
4
|
+
*
|
|
5
|
+
* Registrations are node-local (ADR 0012 decision 1), and a link produces a
|
|
6
|
+
* *verdict* rather than a fact, so live authority stays converged at the
|
|
7
|
+
* adjudicating node and a link is never inherited from an ancestor. A node that
|
|
8
|
+
* adjudicates locally therefore runs whatever links loaded in it, and skips the
|
|
9
|
+
* rest fail-safe (ADR 0007 §4 invariant 2) — the ask still reaches the
|
|
10
|
+
* terminal. That resolution is deliberate; its silence was not. The operator
|
|
11
|
+
* named a judge in config, did not get it, and the only trace was a line in a
|
|
12
|
+
* JSONL file.
|
|
13
|
+
*
|
|
14
|
+
* The two halves of the alarm fire at different rates on purpose, for two
|
|
15
|
+
* different readers. The review entry is the auditor's durable record and must
|
|
16
|
+
* be complete, so it is written for every skipped ask. The visible warning is
|
|
17
|
+
* for the operator about to answer the prompt the link should have answered, so
|
|
18
|
+
* it is latched: one per configured name, whose count is bounded by
|
|
19
|
+
* `authorizerChain.length`.
|
|
20
|
+
*
|
|
21
|
+
* A relaying node cannot reach this audit at all. `AuthorizerSelection` returns
|
|
22
|
+
* from `linksFor` before resolving anything, recording
|
|
23
|
+
* `authorizer_chain_delegated` instead — its ask is adjudicated one hop up
|
|
24
|
+
* (ADR 0007 §7), where an absent link is the design rather than a
|
|
25
|
+
* misconfiguration.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The narrow log seam this audit needs (ISP): a durable record and a warning. */
|
|
29
|
+
export interface AuthorizerChainAuditLog {
|
|
30
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
31
|
+
warn(message: string): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A configured chain link this node's registry could not resolve. */
|
|
35
|
+
export interface UnregisteredLink {
|
|
36
|
+
/** The operator-configured name, exactly as written in `authorizerChain`. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The ask whose chain resolution skipped it. */
|
|
39
|
+
requestId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The audit seam `AuthorizerSelection` drives when it skips a name (ISP). */
|
|
43
|
+
export interface UnregisteredLinkAuditor {
|
|
44
|
+
auditUnregisteredLink(link: UnregisteredLink): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The agent-facing text for a configured link that is not registered here.
|
|
49
|
+
*
|
|
50
|
+
* Three causes leave the identical absence — the provider was excluded from
|
|
51
|
+
* this session's extensions, it failed to load, or it declined to register
|
|
52
|
+
* because it has no configuration of its own (the shape
|
|
53
|
+
* `@gotgenes/pi-permission-model-judge` uses to let an operator opt out
|
|
54
|
+
* per project). The message names the likeliest and admits the others, rather
|
|
55
|
+
* than accusing the operator of a contradiction it cannot prove.
|
|
56
|
+
*
|
|
57
|
+
* Every clause is a statement of what already happened. It deliberately does
|
|
58
|
+
* not claim later asks will skip the link too: registration is honored any time
|
|
59
|
+
* before an ask (ADR 0007 §4), so a link may yet arrive.
|
|
60
|
+
*/
|
|
61
|
+
export function unregisteredLinkMessage(name: string): string {
|
|
62
|
+
return (
|
|
63
|
+
`pi-permission-system: authorizerChain names "${name}", but no link with ` +
|
|
64
|
+
"that name is registered in this session, so this ask is being decided " +
|
|
65
|
+
"without it. Most often the extension providing the link is not loaded " +
|
|
66
|
+
"here (a subagent child's excludedExtensionPackages does this); it may " +
|
|
67
|
+
"also have failed to load, or declined to register because it has no " +
|
|
68
|
+
"configuration of its own. Every skipped ask is recorded in the " +
|
|
69
|
+
"permission review log as authorizer_chain_unregistered_link."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Records each skipped chain link, and warns the operator once per name.
|
|
75
|
+
*
|
|
76
|
+
* The latch is a plain field with no re-arm hook, because the extension factory
|
|
77
|
+
* is re-invoked per session generation — a `/new`, `/resume`, `/fork`, or
|
|
78
|
+
* `/import` switch builds a fresh audit. A `session_start` with
|
|
79
|
+
* `reason: "reload"` reuses this instance and deliberately does not re-warn for
|
|
80
|
+
* a name already reported; a name newly added to `authorizerChain` by that same
|
|
81
|
+
* reload has no entry yet, so it warns on its first skip.
|
|
82
|
+
*/
|
|
83
|
+
export class AuthorizerChainAudit implements UnregisteredLinkAuditor {
|
|
84
|
+
private readonly warned = new Set<string>();
|
|
85
|
+
|
|
86
|
+
constructor(private readonly log: AuthorizerChainAuditLog) {}
|
|
87
|
+
|
|
88
|
+
auditUnregisteredLink(link: UnregisteredLink): void {
|
|
89
|
+
this.log.review("authorizer_chain_unregistered_link", {
|
|
90
|
+
requestId: link.requestId,
|
|
91
|
+
name: link.name,
|
|
92
|
+
});
|
|
93
|
+
if (this.warned.has(link.name)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.warned.add(link.name);
|
|
97
|
+
this.log.warn(unregisteredLinkMessage(link.name));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
selectAuthorizer,
|
|
8
8
|
} from "./authorizer";
|
|
9
9
|
import { composeAuthorizerChain } from "./authorizer-chain";
|
|
10
|
+
import type { UnregisteredLinkAuditor } from "./authorizer-chain-audit";
|
|
10
11
|
import type { AuthorizerLookup } from "./authorizer-registry";
|
|
11
12
|
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
|
12
13
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
@@ -61,6 +62,28 @@ export interface AdjudicationRole {
|
|
|
61
62
|
adjudicatesLocally(): boolean;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Everything {@link AuthorizerSelection} is constructed with: the
|
|
67
|
+
* {@link AuthorizerSelectionDeps} `selectAuthorizer` itself needs, plus the
|
|
68
|
+
* collaborators only the class uses to resolve and run the chain.
|
|
69
|
+
*
|
|
70
|
+
* Named rather than left anonymous on the constructor because the test
|
|
71
|
+
* fixtures mirror it: an addition here is otherwise an addition in two places.
|
|
72
|
+
* `selectAuthorizer` keeps the narrower parameter type (ISP) — it resolves no
|
|
73
|
+
* links and must not see the chain collaborators.
|
|
74
|
+
*/
|
|
75
|
+
export type AuthorizerSelectionConstructorDeps = AuthorizerSelectionDeps & {
|
|
76
|
+
prompter: PermissionPrompterApi;
|
|
77
|
+
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
78
|
+
getPermissionQuery: () => PermissionQuery;
|
|
79
|
+
/** Read-only lookup of registered links by name. */
|
|
80
|
+
authorizerRegistry: AuthorizerLookup;
|
|
81
|
+
/** The operator's configured link names, read live per ask. */
|
|
82
|
+
getAuthorizerChain: () => string[];
|
|
83
|
+
/** Told about each configured name the registry could not resolve. */
|
|
84
|
+
chainAudit: UnregisteredLinkAuditor;
|
|
85
|
+
};
|
|
86
|
+
|
|
64
87
|
/**
|
|
65
88
|
* Context-owning selection root for the Authorizer spine.
|
|
66
89
|
*
|
|
@@ -79,17 +102,7 @@ export class AuthorizerSelection
|
|
|
79
102
|
private authority: SelectedAuthority | null = null;
|
|
80
103
|
private relayTarget: PermissionForwardingTarget | null = null;
|
|
81
104
|
|
|
82
|
-
constructor(
|
|
83
|
-
private readonly deps: AuthorizerSelectionDeps & {
|
|
84
|
-
prompter: PermissionPrompterApi;
|
|
85
|
-
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
86
|
-
getPermissionQuery: () => PermissionQuery;
|
|
87
|
-
/** Read-only lookup of registered links by name. */
|
|
88
|
-
authorizerRegistry: AuthorizerLookup;
|
|
89
|
-
/** The operator's configured link names, read live per ask. */
|
|
90
|
-
getAuthorizerChain: () => string[];
|
|
91
|
-
},
|
|
92
|
-
) {}
|
|
105
|
+
constructor(private readonly deps: AuthorizerSelectionConstructorDeps) {}
|
|
93
106
|
|
|
94
107
|
/**
|
|
95
108
|
* Select the live authority for `ctx` and store it. The non-terminal
|
|
@@ -165,10 +178,11 @@ export class AuthorizerSelection
|
|
|
165
178
|
|
|
166
179
|
/**
|
|
167
180
|
* Resolve the operator's `authorizerChain` names to registered links, in
|
|
168
|
-
* config order (ADR 0007 invariant 1). An unregistered name is skipped
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
181
|
+
* config order (ADR 0007 invariant 1). An unregistered name is skipped
|
|
182
|
+
* fail-safe (invariant 2 — more prompting, never less) and handed to the
|
|
183
|
+
* chain audit, which records it and tells the operator once per name; each
|
|
184
|
+
* resolved link is wrapped in the bounded-delegation envelope so an `allow`
|
|
185
|
+
* on an excluded surface cannot exceed the operator's policy.
|
|
172
186
|
*
|
|
173
187
|
* The resolved names are recorded against the ask before any link runs — a
|
|
174
188
|
* link that defers decides nothing and would otherwise leave no evidence it
|
|
@@ -184,10 +198,7 @@ export class AuthorizerSelection
|
|
|
184
198
|
for (const name of configured) {
|
|
185
199
|
const authorize = this.deps.authorizerRegistry.get(name);
|
|
186
200
|
if (authorize === undefined) {
|
|
187
|
-
this.deps.
|
|
188
|
-
requestId,
|
|
189
|
-
name,
|
|
190
|
-
});
|
|
201
|
+
this.deps.chainAudit.auditUnregisteredLink({ requestId, name });
|
|
191
202
|
continue;
|
|
192
203
|
}
|
|
193
204
|
resolved.push(name);
|
|
@@ -30,15 +30,19 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
30
30
|
* from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
|
|
31
31
|
* untouched (`deny > ask > allow`).
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* A trivially-empty command (an empty, whitespace-only, or comment-only line)
|
|
34
|
+
* has genuinely nothing to gate, so the whole `command` is resolved as before.
|
|
35
|
+
*
|
|
36
|
+
* When the *primary* parse matched nothing, the whole command string is the
|
|
37
|
+
* only surface an explicit `deny` can reach, so it is resolved first and a
|
|
38
|
+
* `deny` covering it denies outright rather than being masked into an
|
|
39
|
+
* approvable prompt (#712). With no units at all the result also fails closed
|
|
40
|
+
* to a synthetic `ask`, so a permissive top-level `*` cannot silently allow an
|
|
38
41
|
* unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
|
|
39
|
-
* the empty-parse path) — #452.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
+
* the empty-parse path) — #452. A command whose units are *all* salvaged
|
|
43
|
+
* (#875) takes the same whole-string check, because its primary parse matched
|
|
44
|
+
* nothing either; only the synthetic `ask` is skipped, since the recovered
|
|
45
|
+
* units now carry the verdict.
|
|
42
46
|
*
|
|
43
47
|
* A *partial* parse failure is the other half of that clause: the units the
|
|
44
48
|
* recovery produced are enumerated normally, and any one the enumerator marked
|
|
@@ -70,22 +74,32 @@ export function resolveBashCommandCheck(
|
|
|
70
74
|
agentName: string | undefined,
|
|
71
75
|
resolver: ScopedPermissionResolver,
|
|
72
76
|
): PermissionCheckResult {
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
if (isTriviallyEmptyCommand(command)) {
|
|
78
|
+
return resolveOnBashSurface(command, agentName, resolver);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!commands.some((cmd) => cmd.salvaged !== true)) {
|
|
82
|
+
// The primary parse matched nothing, so the whole command string is the
|
|
83
|
+
// only surface an explicit `deny` can reach (#452, #712) — a rule naming
|
|
84
|
+
// the command in context (`"* rm -rf *"`) matches the string and not the
|
|
85
|
+
// fragment. This runs whether or not the salvage went on to recover units
|
|
86
|
+
// from the wreckage: `> f <<'M' 2>&1 | rm -rf /tmp/x` has zero primary
|
|
87
|
+
// units and one salvaged one, and keying the check on the combined list
|
|
88
|
+
// would silently drop a `deny` the pre-salvage gate reached (#875).
|
|
77
89
|
const whole = resolveOnBashSurface(command, agentName, resolver);
|
|
78
90
|
if (whole.state === "deny") {
|
|
79
91
|
return whole;
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
if (commands.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
state: "ask",
|
|
96
|
+
toolName: "bash",
|
|
97
|
+
source: "bash",
|
|
98
|
+
origin: "builtin",
|
|
99
|
+
command,
|
|
100
|
+
matchedPattern: "<unparseable-bash-command>",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
const results = commands.map((cmd) =>
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "#src/access-intent/bash/parser";
|
|
4
4
|
import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
|
|
5
|
+
import { AuthorizerChainAudit } from "#src/authority/authorizer-chain-audit";
|
|
5
6
|
import {
|
|
6
7
|
AuthorizerRegistry,
|
|
7
8
|
ObservedAuthorizerRegistrar,
|
|
@@ -168,6 +169,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
168
169
|
// resolved in config order at activation.
|
|
169
170
|
authorizerRegistry,
|
|
170
171
|
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
|
|
172
|
+
// Records each configured name this node could not resolve, and tells the
|
|
173
|
+
// operator once per name — the ask is decided without the judge they
|
|
174
|
+
// asked for, and the review log alone never said so (#861).
|
|
175
|
+
chainAudit: new AuthorizerChainAudit(logger),
|
|
171
176
|
});
|
|
172
177
|
|
|
173
178
|
// Resolver composes the manager + session ruleset and owns the
|