@gotgenes/pi-permission-system 25.2.1 → 25.3.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 +30 -0
- package/README.md +1 -1
- package/config/config.example.json +3 -0
- package/dist/public.d.ts +156 -41
- package/docs/configuration.md +22 -2
- package/package.json +1 -1
- package/schemas/permissions.schema.json +16 -0
- package/src/access-intent/bash/command-enumeration.ts +76 -155
- 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/access-intent/bash/wrapper-analysis.ts +335 -0
- package/src/authority/forwarded-request-server.ts +5 -14
- package/src/authority/local-user-authorizer.ts +2 -3
- package/src/authority/permission-prompt-component.ts +87 -47
- package/src/authority/permission-prompter.ts +9 -0
- package/src/config-loader.ts +2 -0
- package/src/config-schema.ts +14 -0
- package/src/extension-config.ts +10 -0
- package/src/handlers/gates/bash-command.ts +7 -2
- package/src/handlers/gates/bash-external-directory.ts +11 -6
- package/src/handlers/gates/bash-path.ts +10 -6
- package/src/handlers/gates/external-directory.ts +13 -8
- package/src/handlers/gates/path.ts +11 -14
- package/src/handlers/gates/skill-input.ts +5 -2
- package/src/handlers/gates/skill-read.ts +5 -6
- package/src/handlers/gates/tool.ts +10 -5
- package/src/index.ts +2 -0
- package/src/permission-prompts.ts +4 -72
- package/src/presentation/dialog-renderer.ts +404 -0
- package/src/presentation/forwarded-ask-payload.ts +45 -0
- package/src/presentation/legacy-message.ts +117 -0
- package/src/presentation/line-fitting.ts +27 -0
- package/src/presentation/path-ask-payload.ts +128 -0
- package/src/presentation/prompt-payload.ts +137 -0
- package/src/presentation/skill-ask-payload.ts +50 -0
- package/src/presentation/tool-ask-payload.ts +104 -0
- package/src/tool-preview-formatter.ts +1 -1
- package/src/types.ts +6 -0
- package/src/handlers/gates/external-directory-messages.ts +0 -28
|
@@ -1,6 +1,18 @@
|
|
|
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";
|
|
6
|
+
import {
|
|
7
|
+
type CommandWord,
|
|
8
|
+
classifyWrapperWords,
|
|
9
|
+
executedUnitOf,
|
|
10
|
+
type WrapperKind,
|
|
11
|
+
} from "#src/access-intent/bash/wrapper-analysis";
|
|
2
12
|
import type { BashCommandContext } from "#src/types";
|
|
3
13
|
|
|
14
|
+
export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
|
|
15
|
+
|
|
4
16
|
// ── Command type ─────────────────────────────────────────────────────────────
|
|
5
17
|
|
|
6
18
|
/**
|
|
@@ -11,16 +23,6 @@ import type { BashCommandContext } from "#src/types";
|
|
|
11
23
|
* The type is the stable extension point: #306 adds an execution `context`,
|
|
12
24
|
* #307 adds per-command path candidates and an effective working directory.
|
|
13
25
|
*/
|
|
14
|
-
/**
|
|
15
|
-
* Why a command unit's decision is floored to at least `ask`.
|
|
16
|
-
* `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
|
|
17
|
-
* program is not re-parsed (#481).
|
|
18
|
-
* `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
|
|
19
|
-
* whose inner command is a visible argument but is not gated on its own (#490).
|
|
20
|
-
* The kind selects the audit sentinel; both floor identically.
|
|
21
|
-
*/
|
|
22
|
-
export type WrapperKind = "opaque-payload" | "indirection";
|
|
23
|
-
|
|
24
26
|
export interface BashCommand {
|
|
25
27
|
readonly text: string;
|
|
26
28
|
/**
|
|
@@ -34,6 +36,13 @@ export interface BashCommand {
|
|
|
34
36
|
* Absent for an ordinary command.
|
|
35
37
|
*/
|
|
36
38
|
readonly wrapperKind?: WrapperKind;
|
|
39
|
+
/**
|
|
40
|
+
* The command this wrapper unit actually runs (#713). Display-only — it is
|
|
41
|
+
* never gated on its own, so the wrapper floor still applies. Absent for an
|
|
42
|
+
* ordinary command, and for a wrapper whose inner command cannot be
|
|
43
|
+
* established.
|
|
44
|
+
*/
|
|
45
|
+
readonly executedUnit?: string;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// ── Command enumeration ──────────────────────────────────────────────────────
|
|
@@ -49,32 +58,19 @@ const COMMAND_ENUM_DESCEND = new Set([
|
|
|
49
58
|
]);
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
|
-
* Named node types
|
|
53
|
-
*
|
|
61
|
+
* Named node types abandoned during command enumeration: they are neither
|
|
62
|
+
* commands nor able to host one, so nothing in their subtree ever runs.
|
|
63
|
+
*
|
|
64
|
+
* A redirect and a heredoc body are deliberately NOT listed here. Neither is a
|
|
65
|
+
* command, but each can host a substitution that really executes, so both are
|
|
66
|
+
* {@link EXECUTION_HOST_TYPES} members instead — conflating the two questions
|
|
67
|
+
* ("is this a command?" and "can this host one?") is the bypass #741 fixed.
|
|
68
|
+
*
|
|
54
69
|
* Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
|
|
55
70
|
* delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
|
|
56
71
|
* listed here.
|
|
57
72
|
*/
|
|
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
|
-
]);
|
|
73
|
+
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
78
74
|
|
|
79
75
|
/**
|
|
80
76
|
* Enumerate the command units of a bash program, in source order.
|
|
@@ -114,12 +110,17 @@ function collectCommandsInto(
|
|
|
114
110
|
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
115
111
|
|
|
116
112
|
if (node.type === "command") {
|
|
117
|
-
out.push(
|
|
118
|
-
makeUnit(commandUnitText(node), context, classifyWrapperCommand(node)),
|
|
119
|
-
);
|
|
113
|
+
out.push(makeCommandUnit(node, context));
|
|
120
114
|
// A command's text already contains any substitution; descend its subtree
|
|
121
115
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
122
|
-
|
|
116
|
+
collectHostedCommands(node, out);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
121
|
+
// Not a command itself, but its subtree can host one that really runs
|
|
122
|
+
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
|
123
|
+
collectHostedCommands(node, out);
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
|
|
@@ -143,125 +144,50 @@ function makeUnit(
|
|
|
143
144
|
text: string,
|
|
144
145
|
context: BashCommandContext | undefined,
|
|
145
146
|
wrapperKind?: WrapperKind,
|
|
147
|
+
executedUnit?: string,
|
|
146
148
|
): BashCommand {
|
|
147
149
|
const unit: BashCommand = context ? { text, context } : { text };
|
|
148
|
-
|
|
150
|
+
const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
|
|
151
|
+
return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
/**
|
|
152
|
-
*
|
|
153
|
-
|
|
154
|
-
const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Indirection wrappers that always invoke a following command, so the wrapper
|
|
158
|
-
* (not the inner command) is what a bash rule matches. Floored by command-name
|
|
159
|
-
* basename alone. Extend this set to cover another always-invoking wrapper.
|
|
160
|
-
*/
|
|
161
|
-
const INDIRECTION_WRAPPER_NAMES = new Set([
|
|
162
|
-
"sudo",
|
|
163
|
-
"env",
|
|
164
|
-
"xargs",
|
|
165
|
-
"time",
|
|
166
|
-
"nohup",
|
|
167
|
-
"timeout",
|
|
168
|
-
"nice",
|
|
169
|
-
// Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
|
|
170
|
-
// (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
|
|
171
|
-
// (setsid/stdbuf/watch/flock) that all always invoke a following command.
|
|
172
|
-
"parallel",
|
|
173
|
-
"rust-parallel",
|
|
174
|
-
"rush",
|
|
175
|
-
"doas",
|
|
176
|
-
"setsid",
|
|
177
|
-
"stdbuf",
|
|
178
|
-
"watch",
|
|
179
|
-
"flock",
|
|
180
|
-
]);
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Search tools that invoke a command per result only when an exec flag is
|
|
184
|
-
* present; a bare search runs no subcommand. Floored only when an argument
|
|
185
|
-
* exactly matches one of the tool's exec flags. Extend by adding a tool with
|
|
186
|
-
* its exec-flag set.
|
|
187
|
-
*/
|
|
188
|
-
const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
|
189
|
-
["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
|
|
190
|
-
["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
|
|
191
|
-
]);
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Classify a `command` node as a floored wrapper, or `undefined` for an
|
|
195
|
-
* ordinary command. Reads only the node's own named children (a shallow walk),
|
|
196
|
-
* skipping any leading `variable_assignment` prefix, and matches the command
|
|
197
|
-
* name on its basename (so `/bin/bash -c …` counts).
|
|
198
|
-
*
|
|
199
|
-
* `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
|
|
200
|
-
* `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
|
|
201
|
-
* argument the enumerator does not re-parse (#481).
|
|
202
|
-
*
|
|
203
|
-
* `"indirection"`: an always-invoking prefix/exec wrapper
|
|
204
|
-
* (`INDIRECTION_WRAPPER_NAMES`), or a search tool (`EXEC_CONDITIONAL_WRAPPERS`,
|
|
205
|
-
* `find`/`fd`) carrying a per-result exec flag — the inner command is a visible
|
|
206
|
-
* argument that a `<cmd> *` rule would otherwise never match (#490). A bare
|
|
207
|
-
* `find`/`fd` search runs no subcommand and is not flagged.
|
|
155
|
+
* Build the unit for a `command` node, reading its words once to answer both
|
|
156
|
+
* wrapper questions: whether the unit is floored, and what it actually runs.
|
|
208
157
|
*/
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
158
|
+
function makeCommandUnit(
|
|
159
|
+
node: TSNode,
|
|
160
|
+
context: BashCommandContext | undefined,
|
|
161
|
+
): BashCommand {
|
|
162
|
+
const text = commandUnitText(node);
|
|
163
|
+
const words = readCommandWords(node);
|
|
164
|
+
return makeUnit(
|
|
165
|
+
text,
|
|
166
|
+
context,
|
|
167
|
+
classifyWrapperWords(words),
|
|
168
|
+
executedUnitOf(text, words) ?? undefined,
|
|
169
|
+
);
|
|
220
170
|
}
|
|
221
171
|
|
|
222
172
|
/**
|
|
223
|
-
* A `command` node's
|
|
224
|
-
*
|
|
225
|
-
*
|
|
173
|
+
* A `command` node's words — its `command_name` followed by its arguments — each
|
|
174
|
+
* carrying its offset into the unit text `commandUnitText` produces.
|
|
175
|
+
*
|
|
176
|
+
* A leading `variable_assignment` prefix is skipped (matching
|
|
177
|
+
* `commandUnitText`), so offsets are relative to the `command_name`. An empty
|
|
178
|
+
* list means a pure assignment with no `command_name`.
|
|
226
179
|
*/
|
|
227
|
-
function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
} {
|
|
231
|
-
let commandName: string | undefined;
|
|
232
|
-
const args: string[] = [];
|
|
180
|
+
function readCommandWords(node: TSNode): CommandWord[] {
|
|
181
|
+
const words: CommandWord[] = [];
|
|
182
|
+
let unitStart: number | undefined;
|
|
233
183
|
for (let i = 0; i < node.childCount; i++) {
|
|
234
184
|
const child = node.child(i);
|
|
235
185
|
if (!child?.isNamed) continue;
|
|
236
186
|
if (child.type === "variable_assignment") continue;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
args.push(child.text);
|
|
242
|
-
}
|
|
243
|
-
return { commandName, args };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* True when an argument list has a short-flag cluster containing `c` before any
|
|
248
|
-
* `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
|
|
249
|
-
* flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
|
|
250
|
-
*/
|
|
251
|
-
function hasShortFlagC(args: string[]): boolean {
|
|
252
|
-
for (const arg of args) {
|
|
253
|
-
if (arg === "--") return false;
|
|
254
|
-
if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
187
|
+
unitStart ??= child.startIndex;
|
|
188
|
+
words.push({ text: child.text, offset: child.startIndex - unitStart });
|
|
257
189
|
}
|
|
258
|
-
return
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** The final path segment of a command name (`/bin/bash` → `bash`). */
|
|
262
|
-
function basename(name: string): string {
|
|
263
|
-
const slash = name.lastIndexOf("/");
|
|
264
|
-
return slash === -1 ? name : name.slice(slash + 1);
|
|
190
|
+
return words;
|
|
265
191
|
}
|
|
266
192
|
|
|
267
193
|
/**
|
|
@@ -297,20 +223,15 @@ function descendCommandChildren(
|
|
|
297
223
|
}
|
|
298
224
|
|
|
299
225
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
226
|
+
* Enumerate the commands of every nested execution context in a subtree, each
|
|
227
|
+
* tagged with the context it was found in.
|
|
228
|
+
*
|
|
229
|
+
* The traversal itself lives in `nested-execution.ts` so the bash path surface
|
|
230
|
+
* shares one definition of what counts as a nested execution (#741); this
|
|
231
|
+
* function supplies the command-surface interpretation of each one found.
|
|
304
232
|
*/
|
|
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
|
-
}
|
|
233
|
+
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
234
|
+
forEachNestedExecution(node, (contextNode, context) => {
|
|
235
|
+
descendCommandChildren(contextNode, context, out);
|
|
236
|
+
});
|
|
316
237
|
}
|
|
@@ -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
|