@gotgenes/pi-permission-system 27.0.0 → 27.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +26 -6
- package/config/config.example.json +3 -1
- package/dist/public.d.ts +21 -1
- package/docs/configuration.md +184 -5
- package/docs/troubleshooting.md +4 -1
- package/package.json +3 -1
- package/schemas/permissions.schema.json +52 -4
- package/src/access-intent/bash/bash-path-resolver.ts +104 -34
- package/src/access-intent/bash/command-effects.ts +305 -0
- package/src/access-intent/bash/command-enumeration.ts +116 -40
- package/src/access-intent/bash/program.ts +15 -11
- package/src/access-intent/bash/redirect-analysis.ts +98 -0
- package/src/access-intent/bash/token-collection.ts +102 -22
- package/src/access-intent/bash/wrapper-analysis.ts +101 -11
- package/src/access-intent/effect.ts +56 -0
- package/src/access-intent/input-normalizer.ts +2 -2
- package/src/access-intent/path-surfaces.ts +110 -4
- package/src/authority/delegation-envelope.ts +18 -7
- package/src/config-schema.ts +95 -6
- package/src/handlers/gates/bash-command.ts +53 -17
- package/src/handlers/gates/bash-external-directory.ts +41 -8
- package/src/handlers/gates/bash-path-extractor.ts +3 -3
- package/src/handlers/gates/bash-path.ts +31 -13
- package/src/handlers/gates/external-directory-policy.ts +37 -13
- package/src/handlers/gates/external-directory.ts +11 -4
- package/src/handlers/gates/path.ts +12 -5
- package/src/handlers/gates/tool.ts +22 -0
- package/src/normalize.ts +70 -1
- package/src/permission-manager.ts +3 -2
- package/src/permission-resolver.ts +23 -4
- package/src/presentation/path-ask-payload.ts +17 -6
- package/src/restrictiveness.ts +48 -0
- package/src/rule.ts +13 -13
- package/src/scope-merge.ts +8 -2
- package/src/session-rules.ts +19 -8
- package/src/types.ts +22 -1
- package/src/handlers/gates/candidate-check.ts +0 -32
|
@@ -3,13 +3,15 @@ import {
|
|
|
3
3
|
forEachNestedExecution,
|
|
4
4
|
} from "#src/access-intent/bash/nested-execution";
|
|
5
5
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
6
|
+
import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
|
|
6
7
|
import {
|
|
7
8
|
type CommandWord,
|
|
8
9
|
classifyWrapperWords,
|
|
9
10
|
executedUnitOf,
|
|
11
|
+
isTransparentWrapper,
|
|
10
12
|
type WrapperKind,
|
|
11
13
|
} from "#src/access-intent/bash/wrapper-analysis";
|
|
12
|
-
import type { BashCommandContext } from "#src/types";
|
|
14
|
+
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
13
15
|
|
|
14
16
|
export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
|
|
15
17
|
|
|
@@ -37,25 +39,55 @@ export interface BashCommand {
|
|
|
37
39
|
*/
|
|
38
40
|
readonly wrapperKind?: WrapperKind;
|
|
39
41
|
/**
|
|
40
|
-
* The command this wrapper unit actually runs (#713).
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* The command this wrapper unit actually runs (#713). Absent for an ordinary
|
|
43
|
+
* command, and for a wrapper whose inner command cannot be established.
|
|
44
|
+
*
|
|
45
|
+
* Display-only, and deliberately looks past an `sh -c` layer the gate must
|
|
46
|
+
* not look past — {@link floorExemption} is the gateable answer, established
|
|
47
|
+
* by its own walk rather than read off this string (#803).
|
|
44
48
|
*/
|
|
45
49
|
readonly executedUnit?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Set when this wrapper unit's floor has no reason left to hold, naming the
|
|
52
|
+
* reason (#803). Only ever present alongside `wrapperKind: "indirection"`
|
|
53
|
+
* and an established {@link executedUnit}.
|
|
54
|
+
*/
|
|
55
|
+
readonly floorExemption?: FloorExemption;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the statement enclosing a command unit establishes about it.
|
|
60
|
+
*
|
|
61
|
+
* Both facts flow down the walk together because both are the *statement's*,
|
|
62
|
+
* not the command's: a subshell's commands run in a subshell however they are
|
|
63
|
+
* spelled, and a redirected statement writes a file however read-only the
|
|
64
|
+
* command in front of the operator is.
|
|
65
|
+
*/
|
|
66
|
+
interface UnitScope {
|
|
67
|
+
/**
|
|
68
|
+
* Execution context for a nested command (substitution or subshell); absent
|
|
69
|
+
* for a current-shell (top-level) command.
|
|
70
|
+
*/
|
|
71
|
+
readonly context?: BashCommandContext;
|
|
72
|
+
/**
|
|
73
|
+
* True when the enclosing statement redirects output into a real file, which
|
|
74
|
+
* withholds the floor exemption from any wrapper unit beneath it.
|
|
75
|
+
*/
|
|
76
|
+
readonly writesViaRedirect: boolean;
|
|
46
77
|
}
|
|
47
78
|
|
|
79
|
+
/** A top-level command in the current shell, writing no file. */
|
|
80
|
+
const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
|
|
81
|
+
|
|
48
82
|
// ── Command enumeration ──────────────────────────────────────────────────────
|
|
49
83
|
|
|
50
84
|
/**
|
|
51
|
-
* Container node types descended into
|
|
85
|
+
* Container node types descended into with the enclosing scope unchanged.
|
|
86
|
+
*
|
|
87
|
+
* `redirected_statement` is descended too, but has its own branch: it is the
|
|
88
|
+
* node that can establish a write, so it descends with a scope of its own.
|
|
52
89
|
*/
|
|
53
|
-
const COMMAND_ENUM_DESCEND = new Set([
|
|
54
|
-
"program",
|
|
55
|
-
"list",
|
|
56
|
-
"pipeline",
|
|
57
|
-
"redirected_statement",
|
|
58
|
-
]);
|
|
90
|
+
const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
|
|
59
91
|
|
|
60
92
|
/**
|
|
61
93
|
* Named node types abandoned during command enumeration: they are neither
|
|
@@ -95,13 +127,13 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
|
|
95
127
|
*/
|
|
96
128
|
export function collectCommands(node: TSNode): BashCommand[] {
|
|
97
129
|
const out: BashCommand[] = [];
|
|
98
|
-
collectCommandsInto(node,
|
|
130
|
+
collectCommandsInto(node, TOP_LEVEL_SCOPE, out);
|
|
99
131
|
return out;
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
function collectCommandsInto(
|
|
103
135
|
node: TSNode,
|
|
104
|
-
|
|
136
|
+
scope: UnitScope,
|
|
105
137
|
out: BashCommand[],
|
|
106
138
|
): void {
|
|
107
139
|
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
|
|
@@ -110,13 +142,18 @@ function collectCommandsInto(
|
|
|
110
142
|
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
111
143
|
|
|
112
144
|
if (node.type === "command") {
|
|
113
|
-
out.push(makeCommandUnit(node,
|
|
145
|
+
out.push(makeCommandUnit(node, scope));
|
|
114
146
|
// A command's text already contains any substitution; descend its subtree
|
|
115
147
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
116
148
|
collectHostedCommands(node, out);
|
|
117
149
|
return;
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
if (node.type === "redirected_statement") {
|
|
153
|
+
descendCommandChildren(node, redirectedScope(node, scope), out);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
121
158
|
// Not a command itself, but its subtree can host one that really runs
|
|
122
159
|
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
|
@@ -125,48 +162,80 @@ function collectCommandsInto(
|
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
if (node.type === "subshell") {
|
|
128
|
-
out.push(makeUnit(node.text,
|
|
129
|
-
descendCommandChildren(node, "subshell", out);
|
|
165
|
+
out.push(makeUnit(node.text, scope)); // never-weaker whole emit
|
|
166
|
+
descendCommandChildren(node, { ...scope, context: "subshell" }, out);
|
|
130
167
|
return;
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
if (COMMAND_ENUM_DESCEND.has(node.type)) {
|
|
134
|
-
descendCommandChildren(node,
|
|
171
|
+
descendCommandChildren(node, scope, out);
|
|
135
172
|
return;
|
|
136
173
|
}
|
|
137
174
|
|
|
138
175
|
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
|
139
176
|
// function_definition): emit whole, do not descend — deferred (#306).
|
|
140
|
-
out.push(makeUnit(node.text,
|
|
177
|
+
out.push(makeUnit(node.text, scope));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The wrapper facts a `command` node's words establish about its unit. */
|
|
181
|
+
interface WrapperFacts {
|
|
182
|
+
readonly wrapperKind?: WrapperKind;
|
|
183
|
+
readonly executedUnit?: string;
|
|
184
|
+
readonly floorExemption?: FloorExemption;
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
function makeUnit(
|
|
144
188
|
text: string,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
executedUnit?: string,
|
|
189
|
+
scope: UnitScope,
|
|
190
|
+
wrapper: WrapperFacts = {},
|
|
148
191
|
): BashCommand {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
192
|
+
const { wrapperKind, executedUnit, floorExemption } = wrapper;
|
|
193
|
+
const scoped: BashCommand = scope.context
|
|
194
|
+
? { text, context: scope.context }
|
|
195
|
+
: { text };
|
|
196
|
+
const flagged = wrapperKind ? { ...scoped, wrapperKind } : scoped;
|
|
197
|
+
const named =
|
|
198
|
+
executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
199
|
+
return floorExemption === undefined ? named : { ...named, floorExemption };
|
|
152
200
|
}
|
|
153
201
|
|
|
154
202
|
/**
|
|
155
|
-
* Build the unit for a `command` node, reading its words once to answer
|
|
156
|
-
* wrapper questions: whether the unit is floored,
|
|
203
|
+
* Build the unit for a `command` node, reading its words once to answer all
|
|
204
|
+
* three wrapper questions: whether the unit is floored, what it actually runs,
|
|
205
|
+
* and whether the floor still has a reason to hold.
|
|
157
206
|
*/
|
|
158
|
-
function makeCommandUnit(
|
|
159
|
-
node: TSNode,
|
|
160
|
-
context: BashCommandContext | undefined,
|
|
161
|
-
): BashCommand {
|
|
207
|
+
function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
162
208
|
const text = commandUnitText(node);
|
|
163
209
|
const words = readCommandWords(node);
|
|
164
|
-
return makeUnit(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
return makeUnit(text, scope, {
|
|
211
|
+
wrapperKind: classifyWrapperWords(words),
|
|
212
|
+
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
213
|
+
floorExemption: isTransparentWrapper(words, scope)
|
|
214
|
+
? "core-reader"
|
|
215
|
+
: undefined,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The scope a `redirected_statement`'s children run under: the enclosing one,
|
|
221
|
+
* plus a write unless every one of its redirects provably only reads.
|
|
222
|
+
*
|
|
223
|
+
* The redirect belongs to the last element of a pipeline, but it hangs off the
|
|
224
|
+
* whole statement in the parse tree, so every command beneath it is marked.
|
|
225
|
+
* Over-attributing is the fail-closed direction — the flag can only withhold an
|
|
226
|
+
* exemption, never grant one — which is also why the question asked of each
|
|
227
|
+
* redirect is a refusal rather than a proof.
|
|
228
|
+
*/
|
|
229
|
+
function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
230
|
+
if (scope.writesViaRedirect) return scope;
|
|
231
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
232
|
+
const child = node.child(i);
|
|
233
|
+
if (child?.type !== "file_redirect") continue;
|
|
234
|
+
if (redirectMayWriteFile(child)) {
|
|
235
|
+
return { ...scope, writesViaRedirect: true };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return scope;
|
|
170
239
|
}
|
|
171
240
|
|
|
172
241
|
/**
|
|
@@ -213,12 +282,12 @@ function commandUnitText(node: TSNode): string {
|
|
|
213
282
|
|
|
214
283
|
function descendCommandChildren(
|
|
215
284
|
node: TSNode,
|
|
216
|
-
|
|
285
|
+
scope: UnitScope,
|
|
217
286
|
out: BashCommand[],
|
|
218
287
|
): void {
|
|
219
288
|
for (let i = 0; i < node.childCount; i++) {
|
|
220
289
|
const child = node.child(i);
|
|
221
|
-
if (child) collectCommandsInto(child,
|
|
290
|
+
if (child) collectCommandsInto(child, scope, out);
|
|
222
291
|
}
|
|
223
292
|
}
|
|
224
293
|
|
|
@@ -232,6 +301,13 @@ function descendCommandChildren(
|
|
|
232
301
|
*/
|
|
233
302
|
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
234
303
|
forEachNestedExecution(node, (contextNode, context) => {
|
|
235
|
-
|
|
304
|
+
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
305
|
+
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
306
|
+
// nested command's path tokens to its own command.
|
|
307
|
+
descendCommandChildren(
|
|
308
|
+
contextNode,
|
|
309
|
+
{ context, writesViaRedirect: false },
|
|
310
|
+
out,
|
|
311
|
+
);
|
|
236
312
|
});
|
|
237
313
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
1
|
import {
|
|
2
|
+
type BashExternalPath,
|
|
3
3
|
BashPathResolver,
|
|
4
4
|
type BashPathRuleCandidate,
|
|
5
5
|
} from "#src/access-intent/bash/bash-path-resolver";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { getParser } from "#src/access-intent/bash/parser";
|
|
11
11
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
12
12
|
|
|
13
|
-
export type { BashCommand, BashPathRuleCandidate };
|
|
13
|
+
export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* A bash command parsed once into a born-ready representation.
|
|
@@ -26,7 +26,7 @@ export class BashProgram {
|
|
|
26
26
|
private constructor(
|
|
27
27
|
private readonly sourceCommand: string,
|
|
28
28
|
private readonly commandUnits: readonly BashCommand[],
|
|
29
|
-
private readonly
|
|
29
|
+
private readonly resolvedExternalAccesses: readonly BashExternalPath[],
|
|
30
30
|
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
|
|
31
31
|
) {}
|
|
32
32
|
|
|
@@ -59,14 +59,14 @@ export class BashProgram {
|
|
|
59
59
|
if (!tree) return new BashProgram(command, [], [], []);
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
|
-
const {
|
|
62
|
+
const { externalAccesses, ruleCandidates } = new BashPathResolver(
|
|
63
63
|
normalizer,
|
|
64
64
|
options?.workdir,
|
|
65
65
|
).resolve(tree.rootNode);
|
|
66
66
|
return new BashProgram(
|
|
67
67
|
command,
|
|
68
68
|
collectCommands(tree.rootNode),
|
|
69
|
-
|
|
69
|
+
externalAccesses,
|
|
70
70
|
ruleCandidates,
|
|
71
71
|
);
|
|
72
72
|
} finally {
|
|
@@ -104,21 +104,25 @@ export class BashProgram {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* Deduplicated
|
|
108
|
-
*
|
|
109
|
-
* forms behind distinct accessors
|
|
107
|
+
* Deduplicated accesses that resolve outside `cwd`: each an
|
|
108
|
+
* {@link AccessPath} value object holding both the lexical (as-typed) and
|
|
109
|
+
* canonical (symlink-resolved) forms behind distinct accessors, paired with
|
|
110
|
+
* the effect the command stream proved for it.
|
|
110
111
|
*
|
|
111
112
|
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
|
112
113
|
* `parse()` (platform + cwd baked in).
|
|
113
114
|
* Use `.matchValues()` for `external_directory` pattern matching and
|
|
114
115
|
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
|
|
116
|
+
* Two attributions of the same resolved path fold rather than split, so the
|
|
117
|
+
* entry count is a function of the paths alone (#807).
|
|
115
118
|
*/
|
|
116
|
-
|
|
117
|
-
return [...this.
|
|
119
|
+
externalAccesses(): BashExternalPath[] {
|
|
120
|
+
return [...this.resolvedExternalAccesses];
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
/**
|
|
121
|
-
* Path-rule candidates paired with their policy lookup values
|
|
124
|
+
* Path-rule candidates paired with their policy lookup values and the
|
|
125
|
+
* effect the command stream proved for each (#807).
|
|
122
126
|
*
|
|
123
127
|
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
|
124
128
|
* `parse()` (platform + cwd baked in).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { redirectDestinationEffect } from "#src/access-intent/bash/command-effects";
|
|
2
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
3
|
+
import type { TokenEffect } from "#src/access-intent/effect";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a redirect node in the parse tree proves.
|
|
7
|
+
*
|
|
8
|
+
* `command-effects.ts` owns the operator *table* — which spelling means read,
|
|
9
|
+
* which means write — and this module owns reading a `file_redirect` node well
|
|
10
|
+
* enough to consult it: finding the operator among the node's children, and
|
|
11
|
+
* telling a destination that names a file from one that names a descriptor.
|
|
12
|
+
*
|
|
13
|
+
* The split exists because two callers need different answers from the same
|
|
14
|
+
* read, and — importantly — they need them under different burdens of proof.
|
|
15
|
+
* The token collector asks what effect to *attribute* to a destination it is
|
|
16
|
+
* about to emit, so it answers with a proof. The command enumerator asks
|
|
17
|
+
* whether it is safe to *remove* the wrapper floor, so it answers with a
|
|
18
|
+
* refusal: anything it cannot resolve counts against the exemption (#803).
|
|
19
|
+
* One reader of the node keeps the two from drifting on what a redirect is.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The effect `redirect` proves for `destination`, or `null` when the redirect
|
|
24
|
+
* names no file and no token should be collected.
|
|
25
|
+
*
|
|
26
|
+
* `>&` and `<&` are the two operators that may name either a file descriptor
|
|
27
|
+
* (`2>&1`) or a real file (`cmd >& out`); the destination node's type is the
|
|
28
|
+
* parse-tree fact that tells them apart.
|
|
29
|
+
*/
|
|
30
|
+
export function redirectEffectForDestination(
|
|
31
|
+
redirect: TSNode,
|
|
32
|
+
destination: TSNode,
|
|
33
|
+
): TokenEffect | null {
|
|
34
|
+
return redirectDestinationEffect(
|
|
35
|
+
redirectOperatorOf(redirect),
|
|
36
|
+
DESCRIPTOR_NODE_TYPES.has(destination.type),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True unless `redirect` provably only reads — the fail-closed question the
|
|
42
|
+
* floor exemption asks.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately **not** the negation of a write proof. A destination this module
|
|
45
|
+
* cannot resolve counts as a write here, because the caller is deciding whether
|
|
46
|
+
* to remove a guard: `> $OUT`, `>${OUT}`, and `> $(mktemp)` name a file chosen
|
|
47
|
+
* at run time, and the parse can say nothing about which. Reading those as "no
|
|
48
|
+
* write proved, therefore no write" would hand the exemption to exactly the
|
|
49
|
+
* shapes least visible to every other surface — the path projection does not
|
|
50
|
+
* collect them either (#609).
|
|
51
|
+
*
|
|
52
|
+
* Only two things clear it: a descriptor duplication (`2>&1`), which names no
|
|
53
|
+
* file, and an operator that proves a read — reading a file alongside a pure
|
|
54
|
+
* reader leaves it a pure reader.
|
|
55
|
+
*/
|
|
56
|
+
export function redirectMayWriteFile(redirect: TSNode): boolean {
|
|
57
|
+
for (let i = 0; i < redirect.childCount; i++) {
|
|
58
|
+
const child = redirect.child(i);
|
|
59
|
+
// The operator itself is the redirect's only unnamed child.
|
|
60
|
+
if (!child?.isNamed) continue;
|
|
61
|
+
// A source or duplicated descriptor (`2`, `&1`) names no file.
|
|
62
|
+
if (DESCRIPTOR_NODE_TYPES.has(child.type)) continue;
|
|
63
|
+
// A shape the grammar could not resolve (`<>` degrades to one) says nothing
|
|
64
|
+
// about direction, so it cannot clear the check.
|
|
65
|
+
if (child.type === "ERROR") return true;
|
|
66
|
+
if (redirectEffectForDestination(redirect, child)?.effect !== "read") {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Destination node types that name a file descriptor rather than a file, so
|
|
75
|
+
* `>&` / `<&` duplicate a stream instead of touching the filesystem.
|
|
76
|
+
*
|
|
77
|
+
* Neither type is in {@link ARG_NODE_TYPES}, so `2>&1`'s `1` is already never
|
|
78
|
+
* collected; the check is what keeps that true if the argument set widens.
|
|
79
|
+
*/
|
|
80
|
+
const DESCRIPTOR_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
81
|
+
"file_descriptor",
|
|
82
|
+
"number",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The redirect operator of a redirect node.
|
|
87
|
+
*
|
|
88
|
+
* tree-sitter-bash emits it as an unnamed child whose `type` is the operator
|
|
89
|
+
* text itself, and a redirect's only unnamed child is that operator — so the
|
|
90
|
+
* syntax proof is a lookup on the first one found.
|
|
91
|
+
*/
|
|
92
|
+
function redirectOperatorOf(node: TSNode): string {
|
|
93
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
94
|
+
const child = node.child(i);
|
|
95
|
+
if (child && !child.isNamed) return child.type;
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
|
|
2
3
|
import {
|
|
3
4
|
EXECUTION_HOST_TYPES,
|
|
4
5
|
forEachNestedExecution,
|
|
@@ -10,6 +11,20 @@ import {
|
|
|
10
11
|
SKIP_SUBTREE_TYPES,
|
|
11
12
|
} from "#src/access-intent/bash/node-text";
|
|
12
13
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
14
|
+
import { redirectEffectForDestination } from "#src/access-intent/bash/redirect-analysis";
|
|
15
|
+
import type { TokenEffect } from "#src/access-intent/effect";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A collected path-candidate token paired with the effect its position proved.
|
|
19
|
+
*
|
|
20
|
+
* The pairing is made where the token is *produced*, never by mapping a whole
|
|
21
|
+
* result: a nested execution's tokens carry their own command's attribution
|
|
22
|
+
* and must not be overwritten by the enclosing one.
|
|
23
|
+
*/
|
|
24
|
+
export interface PathToken {
|
|
25
|
+
readonly token: string;
|
|
26
|
+
readonly effect: TokenEffect;
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
15
30
|
|
|
@@ -29,7 +44,7 @@ import type { TSNode } from "#src/access-intent/bash/parser";
|
|
|
29
44
|
* as path candidates. For all other commands, collects all
|
|
30
45
|
* arguments generically.
|
|
31
46
|
*/
|
|
32
|
-
export function collectPathCandidateTokens(node: TSNode):
|
|
47
|
+
export function collectPathCandidateTokens(node: TSNode): PathToken[] {
|
|
33
48
|
if (node.type === "command") return collectCommandTokens(node);
|
|
34
49
|
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
35
50
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
@@ -37,7 +52,7 @@ export function collectPathCandidateTokens(node: TSNode): string[] {
|
|
|
37
52
|
}
|
|
38
53
|
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
39
54
|
|
|
40
|
-
const tokens:
|
|
55
|
+
const tokens: PathToken[] = [];
|
|
41
56
|
for (let i = 0; i < node.childCount; i++) {
|
|
42
57
|
const child = node.child(i);
|
|
43
58
|
if (child) tokens.push(...collectPathCandidateTokens(child));
|
|
@@ -49,16 +64,25 @@ export function collectPathCandidateTokens(node: TSNode): string[] {
|
|
|
49
64
|
* Select the collection strategy for a `command` node: pattern-first
|
|
50
65
|
* commands use `collectPatternCommandTokens`; all others use
|
|
51
66
|
* `collectGenericCommandTokens`.
|
|
67
|
+
*
|
|
68
|
+
* Every token the command owns carries the effect its head word proves — the
|
|
69
|
+
* pure-reader core, read through the *raw* head word so a path-qualified
|
|
70
|
+
* spelling proves nothing. A nested execution collected along the way keeps
|
|
71
|
+
* its own command's attribution instead.
|
|
52
72
|
*/
|
|
53
|
-
export function collectCommandTokens(node: TSNode):
|
|
73
|
+
export function collectCommandTokens(node: TSNode): PathToken[] {
|
|
74
|
+
const effect = proveCommandEffect(
|
|
75
|
+
extractCommandWord(node) ?? "",
|
|
76
|
+
commandArgumentWords(node),
|
|
77
|
+
);
|
|
54
78
|
const commandName = extractCommandName(node);
|
|
55
79
|
const config = commandName
|
|
56
80
|
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
57
81
|
: undefined;
|
|
58
82
|
const tokens = config
|
|
59
|
-
? collectPatternCommandTokens(node, config)
|
|
60
|
-
: collectGenericCommandTokens(node);
|
|
61
|
-
return [...tokens, ...collectEmbeddedOptionValues(node)];
|
|
83
|
+
? collectPatternCommandTokens(node, config, effect)
|
|
84
|
+
: collectGenericCommandTokens(node, effect);
|
|
85
|
+
return [...tokens, ...collectEmbeddedOptionValues(node, effect)];
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
/**
|
|
@@ -72,14 +96,24 @@ export function collectCommandTokens(node: TSNode): string[] {
|
|
|
72
96
|
* Both passes are needed: a substitution can be the destination outright, or be
|
|
73
97
|
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
|
|
74
98
|
* argument node.
|
|
99
|
+
*
|
|
100
|
+
* The operator proves the destination's effect outright, and that proof is
|
|
101
|
+
* absolute: it overrides whatever the redirected command's own head word
|
|
102
|
+
* proved, because `> out.txt` writes `out.txt` however read-only the command
|
|
103
|
+
* in front of it is. A destination the operator names as a file descriptor
|
|
104
|
+
* (`2>&1`) contributes no token at all.
|
|
105
|
+
*
|
|
106
|
+
* Reading the redirect node itself belongs to `redirect-analysis.ts`, which
|
|
107
|
+
* the command enumerator consults for the same fact (#803).
|
|
75
108
|
*/
|
|
76
|
-
export function collectRedirectTokens(node: TSNode):
|
|
77
|
-
const tokens:
|
|
109
|
+
export function collectRedirectTokens(node: TSNode): PathToken[] {
|
|
110
|
+
const tokens: PathToken[] = [];
|
|
78
111
|
for (let i = 0; i < node.childCount; i++) {
|
|
79
112
|
const child = node.child(i);
|
|
80
113
|
if (!child) continue;
|
|
81
114
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
82
|
-
|
|
115
|
+
const effect = redirectEffectForDestination(node, child);
|
|
116
|
+
if (effect) tokens.push({ token: resolveNodeText(child), effect });
|
|
83
117
|
}
|
|
84
118
|
tokens.push(...collectHostedExecutionTokens(child));
|
|
85
119
|
}
|
|
@@ -97,11 +131,11 @@ export function collectRedirectTokens(node: TSNode): string[] {
|
|
|
97
131
|
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
|
|
98
132
|
* subtree, so the first case is checked here.
|
|
99
133
|
*/
|
|
100
|
-
function collectHostedExecutionTokens(node: TSNode):
|
|
134
|
+
function collectHostedExecutionTokens(node: TSNode): PathToken[] {
|
|
101
135
|
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
|
102
136
|
return collectPathCandidateTokens(node);
|
|
103
137
|
}
|
|
104
|
-
const tokens:
|
|
138
|
+
const tokens: PathToken[] = [];
|
|
105
139
|
forEachNestedExecution(node, (contextNode) => {
|
|
106
140
|
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
107
141
|
});
|
|
@@ -112,14 +146,34 @@ function collectHostedExecutionTokens(node: TSNode): string[] {
|
|
|
112
146
|
* Extract the command name from a `command` node.
|
|
113
147
|
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
114
148
|
* if the command name cannot be determined (e.g. variable expansion).
|
|
149
|
+
*
|
|
150
|
+
* The basename is what {@link PATTERN_FIRST_COMMANDS} needs: `/usr/bin/sed`
|
|
151
|
+
* parses its arguments exactly as `sed` does. It is the wrong question for a
|
|
152
|
+
* capability claim, where the directory prefix is the whole point — use
|
|
153
|
+
* {@link extractCommandWord} there.
|
|
115
154
|
*/
|
|
116
155
|
export function extractCommandName(node: TSNode): string | undefined {
|
|
156
|
+
const word = extractCommandWord(node);
|
|
157
|
+
return word === undefined ? undefined : basename(word);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract the head word of a `command` node exactly as it was written, or
|
|
162
|
+
* undefined when it cannot be determined (e.g. variable expansion).
|
|
163
|
+
*
|
|
164
|
+
* Unbasenamed on purpose: `./grep` and `/tmp/evil/grep` name programs the
|
|
165
|
+
* pure-reader core's audit never saw, so an effect proof must be able to tell
|
|
166
|
+
* them from a bare `grep` — the Codex lesson the bare-basename rule encodes.
|
|
167
|
+
* Documented against {@link extractCommandName}, which answers the other
|
|
168
|
+
* question.
|
|
169
|
+
*/
|
|
170
|
+
export function extractCommandWord(node: TSNode): string | undefined {
|
|
117
171
|
for (let i = 0; i < node.childCount; i++) {
|
|
118
172
|
const child = node.child(i);
|
|
119
173
|
if (!child) continue;
|
|
120
174
|
if (child.type === "command_name") {
|
|
121
175
|
const text = resolveNodeText(child);
|
|
122
|
-
return text ?
|
|
176
|
+
return text === "" ? undefined : text;
|
|
123
177
|
}
|
|
124
178
|
}
|
|
125
179
|
return undefined;
|
|
@@ -127,6 +181,25 @@ export function extractCommandName(node: TSNode): string | undefined {
|
|
|
127
181
|
|
|
128
182
|
// ── Private helpers and config ─────────────────────────────────────────────
|
|
129
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The command's own argument words, which the retraction guards read.
|
|
186
|
+
*
|
|
187
|
+
* Reads the argument nodes directly rather than the collected tokens, because
|
|
188
|
+
* a guard fires on an *option* (`find -delete`) and no collector emits one.
|
|
189
|
+
*/
|
|
190
|
+
function commandArgumentWords(node: TSNode): string[] {
|
|
191
|
+
const words: string[] = [];
|
|
192
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
193
|
+
const child = node.child(i);
|
|
194
|
+
if (!child) continue;
|
|
195
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
196
|
+
continue;
|
|
197
|
+
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
198
|
+
words.push(resolveNodeText(child));
|
|
199
|
+
}
|
|
200
|
+
return words;
|
|
201
|
+
}
|
|
202
|
+
|
|
130
203
|
/**
|
|
131
204
|
* A long or short option carrying its value inline: one or two leading dashes,
|
|
132
205
|
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
|
@@ -148,8 +221,11 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
|
148
221
|
* here is what lets the projection see option-embedded paths without per-command
|
|
149
222
|
* option tables (ADR 0009, #645).
|
|
150
223
|
*/
|
|
151
|
-
function collectEmbeddedOptionValues(
|
|
152
|
-
|
|
224
|
+
function collectEmbeddedOptionValues(
|
|
225
|
+
node: TSNode,
|
|
226
|
+
effect: TokenEffect,
|
|
227
|
+
): PathToken[] {
|
|
228
|
+
const values: PathToken[] = [];
|
|
153
229
|
for (let i = 0; i < node.childCount; i++) {
|
|
154
230
|
const child = node.child(i);
|
|
155
231
|
if (!child) continue;
|
|
@@ -158,7 +234,7 @@ function collectEmbeddedOptionValues(node: TSNode): string[] {
|
|
|
158
234
|
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
159
235
|
|
|
160
236
|
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
|
|
161
|
-
if (value !== undefined) values.push(value);
|
|
237
|
+
if (value !== undefined) values.push({ token: value, effect });
|
|
162
238
|
}
|
|
163
239
|
return values;
|
|
164
240
|
}
|
|
@@ -322,13 +398,14 @@ function classifyPatternCommandFlag(
|
|
|
322
398
|
function collectPatternCommandTokens(
|
|
323
399
|
node: TSNode,
|
|
324
400
|
config: PatternCommandConfig,
|
|
325
|
-
|
|
401
|
+
effect: TokenEffect,
|
|
402
|
+
): PathToken[] {
|
|
326
403
|
const patternPositionals = config.patternPositionals ?? 1;
|
|
327
404
|
let hasExplicitScript = false;
|
|
328
405
|
let positionalsSeen = 0;
|
|
329
406
|
let nextArgAction: "skip" | "extract" | null = null;
|
|
330
407
|
let pastEndOfFlags = false;
|
|
331
|
-
const tokens:
|
|
408
|
+
const tokens: PathToken[] = [];
|
|
332
409
|
|
|
333
410
|
for (let i = 0; i < node.childCount; i++) {
|
|
334
411
|
const child = node.child(i);
|
|
@@ -353,7 +430,7 @@ function collectPatternCommandTokens(
|
|
|
353
430
|
continue;
|
|
354
431
|
}
|
|
355
432
|
if (nextArgAction === "extract") {
|
|
356
|
-
tokens.push(text);
|
|
433
|
+
tokens.push({ token: text, effect });
|
|
357
434
|
nextArgAction = null;
|
|
358
435
|
continue;
|
|
359
436
|
}
|
|
@@ -387,7 +464,7 @@ function collectPatternCommandTokens(
|
|
|
387
464
|
}
|
|
388
465
|
|
|
389
466
|
// File argument — collect as path candidate.
|
|
390
|
-
tokens.push(text);
|
|
467
|
+
tokens.push({ token: text, effect });
|
|
391
468
|
}
|
|
392
469
|
|
|
393
470
|
return tokens;
|
|
@@ -397,8 +474,11 @@ function collectPatternCommandTokens(
|
|
|
397
474
|
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
|
398
475
|
* skipping the command name and variable assignments.
|
|
399
476
|
*/
|
|
400
|
-
function collectGenericCommandTokens(
|
|
401
|
-
|
|
477
|
+
function collectGenericCommandTokens(
|
|
478
|
+
node: TSNode,
|
|
479
|
+
effect: TokenEffect,
|
|
480
|
+
): PathToken[] {
|
|
481
|
+
const tokens: PathToken[] = [];
|
|
402
482
|
let seenCommandName = false;
|
|
403
483
|
|
|
404
484
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -421,7 +501,7 @@ function collectGenericCommandTokens(node: TSNode): string[] {
|
|
|
421
501
|
|
|
422
502
|
// Argument nodes: resolve their text and collect.
|
|
423
503
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
424
|
-
tokens.push(resolveNodeText(child));
|
|
504
|
+
tokens.push({ token: resolveNodeText(child), effect });
|
|
425
505
|
continue;
|
|
426
506
|
}
|
|
427
507
|
|