@gotgenes/pi-permission-system 16.0.1 → 16.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 +14 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +88 -0
- package/src/access-intent/bash/command-enumeration.ts +154 -0
- package/src/access-intent/bash/cwd-projection.ts +498 -0
- package/src/access-intent/bash/node-text.ts +75 -0
- package/src/access-intent/bash/parser.ts +42 -0
- package/src/access-intent/bash/program.ts +102 -0
- package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
- package/src/access-intent/bash/token-collection.ts +351 -0
- package/src/handlers/gates/bash-command.ts +1 -1
- package/src/handlers/gates/bash-external-directory.ts +19 -33
- package/src/handlers/gates/bash-path-extractor.ts +10 -4
- package/src/handlers/gates/bash-path.ts +2 -2
- package/src/handlers/gates/external-directory-policy.ts +65 -0
- package/src/handlers/gates/external-directory.ts +8 -11
- package/src/handlers/gates/path.ts +1 -3
- package/src/handlers/gates/skill-read.ts +0 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
- package/src/handlers/gates/tool.ts +1 -1
- package/src/handlers/gates/types.ts +1 -1
- package/src/path-utils.ts +0 -20
- package/test/access-intent/access-path.test.ts +107 -0
- package/test/access-intent/bash/node-text.test.ts +147 -0
- package/test/access-intent/bash/parser.test.ts +19 -0
- package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +114 -73
- package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
- package/test/access-intent/bash/token-collection.test.ts +300 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
- package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
- package/test/handlers/gates/bash-external-directory.test.ts +2 -10
- package/test/handlers/gates/bash-path.test.ts +2 -2
- package/test/handlers/gates/external-directory-policy.test.ts +112 -0
- package/test/handlers/gates/external-directory.test.ts +0 -5
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +7 -3
- package/test/path-utils.test.ts +0 -34
- package/src/handlers/gates/bash-program.ts +0 -1143
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import {
|
|
3
|
+
type BashCommand,
|
|
4
|
+
collectCommands,
|
|
5
|
+
} from "#src/access-intent/bash/command-enumeration";
|
|
6
|
+
import {
|
|
7
|
+
type BashPathRuleCandidate,
|
|
8
|
+
collectPathCandidates,
|
|
9
|
+
projectExternalPaths,
|
|
10
|
+
projectRuleCandidates,
|
|
11
|
+
} from "#src/access-intent/bash/cwd-projection";
|
|
12
|
+
import { getParser } from "#src/access-intent/bash/parser";
|
|
13
|
+
|
|
14
|
+
export type { BashCommand, BashPathRuleCandidate };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A bash command parsed once into a born-ready representation.
|
|
18
|
+
*
|
|
19
|
+
* Parsing is the expensive step (tree-sitter WASM); `BashProgram` performs it
|
|
20
|
+
* a single time and eagerly resolves all three typed slices so the bash
|
|
21
|
+
* permission gates do not each re-parse or re-walk the command, and so the
|
|
22
|
+
* slices are guaranteed to agree.
|
|
23
|
+
*
|
|
24
|
+
* Construct via the async `parse()` factory; the constructor is private.
|
|
25
|
+
*/
|
|
26
|
+
export class BashProgram {
|
|
27
|
+
private constructor(
|
|
28
|
+
private readonly commandUnits: readonly BashCommand[],
|
|
29
|
+
private readonly resolvedExternalPaths: readonly AccessPath[],
|
|
30
|
+
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a bash command into a born-ready `BashProgram`.
|
|
35
|
+
*
|
|
36
|
+
* Uses tree-sitter-bash to build the full AST, enumerates command units and
|
|
37
|
+
* walks path-candidate tokens once, then eagerly resolves all three slices
|
|
38
|
+
* against `cwd`. Heredoc bodies, comments, and other non-argument content are
|
|
39
|
+
* skipped. An unparseable command yields an empty program.
|
|
40
|
+
*/
|
|
41
|
+
static async parse(command: string, cwd: string): Promise<BashProgram> {
|
|
42
|
+
const parser = await getParser();
|
|
43
|
+
const tree = parser.parse(command);
|
|
44
|
+
if (!tree) return new BashProgram([], [], []);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const candidates = collectPathCandidates(tree.rootNode);
|
|
48
|
+
return new BashProgram(
|
|
49
|
+
collectCommands(tree.rootNode),
|
|
50
|
+
projectExternalPaths(candidates, cwd),
|
|
51
|
+
projectRuleCandidates(candidates, cwd),
|
|
52
|
+
);
|
|
53
|
+
} finally {
|
|
54
|
+
tree.delete();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The top-level command-pattern units of the chain, in source order.
|
|
60
|
+
*
|
|
61
|
+
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
|
|
62
|
+
* quotes, command substitution, and subshells are respected by the parser and
|
|
63
|
+
* are NOT split — a subshell or other compound statement is emitted whole.
|
|
64
|
+
* May be empty (e.g. an empty command or a comment-only line); callers fall
|
|
65
|
+
* back to the whole command so the surface is never evaluated weaker than
|
|
66
|
+
* before.
|
|
67
|
+
*/
|
|
68
|
+
// Used by resolveBashCommandCheck (bash-command.ts) and tests. Fallow's
|
|
69
|
+
// syntactic analysis cannot resolve the static-factory return type (private
|
|
70
|
+
// ctor), so it reports a false positive here.
|
|
71
|
+
// fallow-ignore-next-line unused-class-member
|
|
72
|
+
commands(): BashCommand[] {
|
|
73
|
+
return [...this.commandUnits];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
|
|
78
|
+
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
|
|
79
|
+
* forms behind distinct accessors.
|
|
80
|
+
*
|
|
81
|
+
* Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
|
|
82
|
+
* Use `.matchValues()` for `external_directory` pattern matching and
|
|
83
|
+
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
|
|
84
|
+
*/
|
|
85
|
+
externalPaths(): AccessPath[] {
|
|
86
|
+
return [...this.resolvedExternalPaths];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Path-rule candidates paired with their policy lookup values.
|
|
91
|
+
*
|
|
92
|
+
* Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
|
|
93
|
+
* Each token is resolved against the effective working directory in force at
|
|
94
|
+
* the token's position (folding literal current-shell `cd` commands), while
|
|
95
|
+
* raw and project-relative aliases are retained for backward-compatible
|
|
96
|
+
* relative rules. A token after a non-literal `cd` keeps only its literal
|
|
97
|
+
* value so no spurious absolute rule can match (#393).
|
|
98
|
+
*/
|
|
99
|
+
pathRuleCandidates(): BashPathRuleCandidate[] {
|
|
100
|
+
return [...this.resolvedRuleCandidates];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
3
|
*
|
|
4
|
-
* Exports two classifiers consumed by `
|
|
4
|
+
* Exports two classifiers consumed by `cwd-projection.ts`:
|
|
5
5
|
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
6
|
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
7
|
*
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
ARG_NODE_TYPES,
|
|
4
|
+
resolveNodeText,
|
|
5
|
+
SKIP_SUBTREE_TYPES,
|
|
6
|
+
} from "#src/access-intent/bash/node-text";
|
|
7
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
8
|
+
|
|
9
|
+
// ── Public surface ─────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively visit the AST and collect resolved text of nodes that
|
|
13
|
+
* represent command arguments or redirect destinations.
|
|
14
|
+
*
|
|
15
|
+
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
16
|
+
*
|
|
17
|
+
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
18
|
+
* argument skipping to avoid collecting inline patterns/scripts
|
|
19
|
+
* as path candidates. For all other commands, collects all
|
|
20
|
+
* arguments generically.
|
|
21
|
+
*/
|
|
22
|
+
export function collectPathCandidateTokens(node: TSNode): string[] {
|
|
23
|
+
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
24
|
+
if (node.type === "command") return collectCommandTokens(node);
|
|
25
|
+
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
26
|
+
|
|
27
|
+
const tokens: string[] = [];
|
|
28
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
29
|
+
const child = node.child(i);
|
|
30
|
+
if (child) tokens.push(...collectPathCandidateTokens(child));
|
|
31
|
+
}
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Select the collection strategy for a `command` node: pattern-first
|
|
37
|
+
* commands use `collectPatternCommandTokens`; all others use
|
|
38
|
+
* `collectGenericCommandTokens`.
|
|
39
|
+
*/
|
|
40
|
+
export function collectCommandTokens(node: TSNode): string[] {
|
|
41
|
+
const commandName = extractCommandName(node);
|
|
42
|
+
const config = commandName
|
|
43
|
+
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
44
|
+
: undefined;
|
|
45
|
+
return config
|
|
46
|
+
? collectPatternCommandTokens(node, config)
|
|
47
|
+
: collectGenericCommandTokens(node);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect redirect-destination tokens from a `file_redirect` node.
|
|
52
|
+
*/
|
|
53
|
+
export function collectRedirectTokens(node: TSNode): string[] {
|
|
54
|
+
const tokens: string[] = [];
|
|
55
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
56
|
+
const child = node.child(i);
|
|
57
|
+
if (!child) continue;
|
|
58
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
59
|
+
tokens.push(resolveNodeText(child));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tokens;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract the command name from a `command` node.
|
|
67
|
+
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
68
|
+
* if the command name cannot be determined (e.g. variable expansion).
|
|
69
|
+
*/
|
|
70
|
+
export function extractCommandName(node: TSNode): string | undefined {
|
|
71
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
72
|
+
const child = node.child(i);
|
|
73
|
+
if (!child) continue;
|
|
74
|
+
if (child.type === "command_name") {
|
|
75
|
+
const text = resolveNodeText(child);
|
|
76
|
+
return text ? basename(text) : undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Private helpers and config ─────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
interface PatternCommandConfig {
|
|
85
|
+
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
|
86
|
+
readonly argConsumingFlags: ReadonlySet<string>;
|
|
87
|
+
/** Flags that consume the next argument as a file path */
|
|
88
|
+
readonly fileConsumingFlags: ReadonlySet<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Number of leading positional arguments that are patterns/scripts, not paths.
|
|
91
|
+
* Default: 1 (covers sed, awk, grep, rg).
|
|
92
|
+
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
|
|
93
|
+
*/
|
|
94
|
+
readonly patternPositionals?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Commands whose first N positional arguments are inline patterns/scripts,
|
|
99
|
+
* not filesystem paths. The map stores per-command flag configuration so
|
|
100
|
+
* the walker can correctly identify which arguments are consumed by flags
|
|
101
|
+
* vs. which are positional.
|
|
102
|
+
*/
|
|
103
|
+
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
|
104
|
+
new Map([
|
|
105
|
+
[
|
|
106
|
+
"sed",
|
|
107
|
+
{
|
|
108
|
+
argConsumingFlags: new Set(["-e", "-i"]),
|
|
109
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
"awk",
|
|
114
|
+
{
|
|
115
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
116
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
"gawk",
|
|
121
|
+
{
|
|
122
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
123
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
"nawk",
|
|
128
|
+
{
|
|
129
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
130
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
"grep",
|
|
135
|
+
{
|
|
136
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
137
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
"egrep",
|
|
142
|
+
{
|
|
143
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
144
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
"fgrep",
|
|
149
|
+
{
|
|
150
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
151
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"rg",
|
|
156
|
+
{
|
|
157
|
+
argConsumingFlags: new Set([
|
|
158
|
+
"-e",
|
|
159
|
+
"-A",
|
|
160
|
+
"-B",
|
|
161
|
+
"-C",
|
|
162
|
+
"-m",
|
|
163
|
+
"-g",
|
|
164
|
+
"-t",
|
|
165
|
+
"-T",
|
|
166
|
+
"-j",
|
|
167
|
+
"-M",
|
|
168
|
+
"-r",
|
|
169
|
+
"-E",
|
|
170
|
+
]),
|
|
171
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
"sd",
|
|
176
|
+
{
|
|
177
|
+
argConsumingFlags: new Set(["-n", "-f"]),
|
|
178
|
+
fileConsumingFlags: new Set([]),
|
|
179
|
+
patternPositionals: 2,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Describes what the walker should do when it encounters a flag word inside
|
|
186
|
+
* a pattern-first command. Using a discriminated union lets the `switch` in
|
|
187
|
+
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
|
|
188
|
+
* assertion (which would trigger the Biome/ESLint assertion conflict).
|
|
189
|
+
*/
|
|
190
|
+
type PatternCommandFlagDirective =
|
|
191
|
+
| { kind: "end-of-flags" }
|
|
192
|
+
| { kind: "regular-flag" }
|
|
193
|
+
| {
|
|
194
|
+
kind: "consume-arg";
|
|
195
|
+
nextArgAction: "skip" | "extract";
|
|
196
|
+
setsExplicitScript: boolean;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Classify a flag word from a pattern-first command into a directive that
|
|
201
|
+
* tells the walker how to handle the flag and its following argument.
|
|
202
|
+
*/
|
|
203
|
+
function classifyPatternCommandFlag(
|
|
204
|
+
text: string,
|
|
205
|
+
config: PatternCommandConfig,
|
|
206
|
+
): PatternCommandFlagDirective {
|
|
207
|
+
if (text === "--") return { kind: "end-of-flags" };
|
|
208
|
+
if (config.argConsumingFlags.has(text)) {
|
|
209
|
+
return {
|
|
210
|
+
kind: "consume-arg",
|
|
211
|
+
nextArgAction: "skip",
|
|
212
|
+
setsExplicitScript: text === "-e" || text === "-f",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (config.fileConsumingFlags.has(text)) {
|
|
216
|
+
return {
|
|
217
|
+
kind: "consume-arg",
|
|
218
|
+
nextArgAction: "extract",
|
|
219
|
+
setsExplicitScript: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { kind: "regular-flag" };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Collect path-candidate tokens from a command known to have
|
|
227
|
+
* pattern/script arguments in leading positional slots.
|
|
228
|
+
*
|
|
229
|
+
* Uses position-based skipping: the first N positional arguments
|
|
230
|
+
* (where N = patternPositionals, default 1) are assumed to be
|
|
231
|
+
* inline patterns/scripts and are skipped. Remaining positional
|
|
232
|
+
* arguments are collected as path candidates.
|
|
233
|
+
*
|
|
234
|
+
* Flags listed in `argConsumingFlags` consume the next argument
|
|
235
|
+
* (skipped). Flags in `fileConsumingFlags` consume the next
|
|
236
|
+
* argument as a file path (collected). The flags `-e` and `-f`
|
|
237
|
+
* additionally signal that an explicit script was provided via
|
|
238
|
+
* flag, so no inline positional script is expected.
|
|
239
|
+
*/
|
|
240
|
+
function collectPatternCommandTokens(
|
|
241
|
+
node: TSNode,
|
|
242
|
+
config: PatternCommandConfig,
|
|
243
|
+
): string[] {
|
|
244
|
+
const patternPositionals = config.patternPositionals ?? 1;
|
|
245
|
+
let hasExplicitScript = false;
|
|
246
|
+
let positionalsSeen = 0;
|
|
247
|
+
let nextArgAction: "skip" | "extract" | null = null;
|
|
248
|
+
let pastEndOfFlags = false;
|
|
249
|
+
const tokens: string[] = [];
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
252
|
+
const child = node.child(i);
|
|
253
|
+
if (!child) continue;
|
|
254
|
+
|
|
255
|
+
// Skip command_name and variable_assignment nodes.
|
|
256
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
257
|
+
continue;
|
|
258
|
+
|
|
259
|
+
// Only process argument-like nodes; recurse into others
|
|
260
|
+
// (e.g. command_substitution) for nested commands.
|
|
261
|
+
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
262
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const text = resolveNodeText(child);
|
|
267
|
+
|
|
268
|
+
// Handle consumed argument from previous flag.
|
|
269
|
+
if (nextArgAction === "skip") {
|
|
270
|
+
nextArgAction = null;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (nextArgAction === "extract") {
|
|
274
|
+
tokens.push(text);
|
|
275
|
+
nextArgAction = null;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Flag detection (only before "--" end-of-flags marker).
|
|
280
|
+
if (
|
|
281
|
+
!pastEndOfFlags &&
|
|
282
|
+
child.type === "word" &&
|
|
283
|
+
text.startsWith("-") &&
|
|
284
|
+
text.length > 1
|
|
285
|
+
) {
|
|
286
|
+
const directive = classifyPatternCommandFlag(text, config);
|
|
287
|
+
switch (directive.kind) {
|
|
288
|
+
case "end-of-flags":
|
|
289
|
+
pastEndOfFlags = true;
|
|
290
|
+
break;
|
|
291
|
+
case "consume-arg":
|
|
292
|
+
nextArgAction = directive.nextArgAction;
|
|
293
|
+
if (directive.setsExplicitScript) hasExplicitScript = true;
|
|
294
|
+
break;
|
|
295
|
+
case "regular-flag":
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Positional argument.
|
|
302
|
+
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
303
|
+
positionalsSeen++;
|
|
304
|
+
continue; // Skip: this is an inline pattern/script.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// File argument — collect as path candidate.
|
|
308
|
+
tokens.push(text);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return tokens;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
|
316
|
+
* skipping the command name and variable assignments.
|
|
317
|
+
*/
|
|
318
|
+
function collectGenericCommandTokens(node: TSNode): string[] {
|
|
319
|
+
const tokens: string[] = [];
|
|
320
|
+
let seenCommandName = false;
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
323
|
+
const child = node.child(i);
|
|
324
|
+
if (!child) continue;
|
|
325
|
+
|
|
326
|
+
if (child.type === "command_name") {
|
|
327
|
+
seenCommandName = true;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Skip variable_assignment nodes (FOO=/bar)
|
|
331
|
+
if (child.type === "variable_assignment") continue;
|
|
332
|
+
|
|
333
|
+
// If there was no explicit command_name node, the first word-like
|
|
334
|
+
// child is the command name itself — skip it.
|
|
335
|
+
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
336
|
+
seenCommandName = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Argument nodes: resolve their text and collect.
|
|
341
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
342
|
+
tokens.push(resolveNodeText(child));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Recurse into other children (e.g. command_substitution nested in args)
|
|
347
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return tokens;
|
|
351
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BashCommand } from "#src/
|
|
1
|
+
import type { BashCommand } from "#src/access-intent/bash/command-enumeration";
|
|
2
2
|
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import type { PermissionCheckResult } from "#src/types";
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
+
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
1
2
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
|
-
import { getExternalDirectoryPolicyValues } from "#src/path-utils";
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import { SessionApproval } from "#src/session-approval";
|
|
5
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
7
|
-
import type { BashProgram } from "./bash-program";
|
|
8
|
-
import { pickMostRestrictive } from "./candidate-check";
|
|
9
6
|
import type { GateResult } from "./descriptor";
|
|
10
7
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
8
|
+
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
11
9
|
import type { ToolCallContext } from "./types";
|
|
12
10
|
|
|
13
11
|
/**
|
|
@@ -24,37 +22,27 @@ export function describeBashExternalDirectoryGate(
|
|
|
24
22
|
bashProgram: BashProgram | null,
|
|
25
23
|
resolver: ScopedPermissionResolver,
|
|
26
24
|
): GateResult {
|
|
27
|
-
if (tcc.toolName !== "bash"
|
|
25
|
+
if (tcc.toolName !== "bash") return null;
|
|
28
26
|
|
|
29
27
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
30
28
|
if (!command) return null;
|
|
31
29
|
|
|
32
30
|
if (!bashProgram) return null;
|
|
33
31
|
|
|
34
|
-
const externalPaths = bashProgram.externalPaths(
|
|
32
|
+
const externalPaths = bashProgram.externalPaths();
|
|
35
33
|
if (externalPaths.length === 0) return null;
|
|
36
34
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Match each path against both its typed and symlink-resolved aliases on
|
|
46
|
-
// the external_directory surface, so a config pattern on either form
|
|
47
|
-
// applies (#418).
|
|
48
|
-
const check = resolver.resolvePathPolicy(
|
|
49
|
-
getExternalDirectoryPolicyValues(p, tcc.cwd),
|
|
35
|
+
// Resolve every external path on the external_directory surface and keep the
|
|
36
|
+
// ones not already allowed (config-level allows suppress the prompt just as
|
|
37
|
+
// session-level allows do); the shared helper single-sources the #418 alias
|
|
38
|
+
// matching and the worst-uncovered selection.
|
|
39
|
+
const { uncovered: uncoveredEntries, worstCheck } =
|
|
40
|
+
selectUncoveredExternalPaths(
|
|
41
|
+
externalPaths,
|
|
42
|
+
resolver,
|
|
50
43
|
tcc.agentName ?? undefined,
|
|
51
|
-
"external_directory",
|
|
52
44
|
);
|
|
53
|
-
|
|
54
|
-
uncoveredEntries.push({ path: p, check });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
|
|
45
|
+
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
|
|
58
46
|
|
|
59
47
|
if (uncoveredPaths.length === 0) {
|
|
60
48
|
return {
|
|
@@ -67,19 +55,17 @@ export function describeBashExternalDirectoryGate(
|
|
|
67
55
|
toolName: tcc.toolName,
|
|
68
56
|
agentName: tcc.agentName,
|
|
69
57
|
command,
|
|
70
|
-
externalPaths,
|
|
58
|
+
externalPaths: externalPaths.map((p) => p.value()),
|
|
71
59
|
resolution: "session_approved",
|
|
72
60
|
},
|
|
73
61
|
},
|
|
74
62
|
};
|
|
75
63
|
}
|
|
76
64
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const worstCheck
|
|
81
|
-
pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
|
|
82
|
-
uncoveredEntries[0].check;
|
|
65
|
+
// After the early bypass, at least one path is uncovered, so worstCheck is
|
|
66
|
+
// defined; the fallback keeps TypeScript happy across the early return. A
|
|
67
|
+
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
|
68
|
+
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
|
83
69
|
|
|
84
70
|
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
85
71
|
command,
|
|
@@ -122,6 +108,6 @@ export function describeBashExternalDirectoryGate(
|
|
|
122
108
|
surface: "external_directory",
|
|
123
109
|
value: command,
|
|
124
110
|
},
|
|
125
|
-
preCheck
|
|
111
|
+
preCheck,
|
|
126
112
|
};
|
|
127
113
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import { BashProgram } from "
|
|
1
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Extract paths from a bash command string that resolve outside CWD.
|
|
5
5
|
*
|
|
6
6
|
* Thin facade over {@link BashProgram.externalPaths}; parses the command and
|
|
7
|
-
* returns the cd-aware external paths
|
|
8
|
-
* resolution semantics.
|
|
7
|
+
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
|
8
|
+
* See `BashProgram` for the parsing and resolution semantics.
|
|
9
|
+
*
|
|
10
|
+
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
|
11
|
+
* test suite in `bash-external-directory.test.ts` can assert path values
|
|
12
|
+
* without migrating every call site.
|
|
9
13
|
*/
|
|
10
14
|
export async function extractExternalPathsFromBashCommand(
|
|
11
15
|
command: string,
|
|
12
16
|
cwd: string,
|
|
13
17
|
): Promise<string[]> {
|
|
14
|
-
return (await BashProgram.parse(command))
|
|
18
|
+
return (await BashProgram.parse(command, cwd))
|
|
19
|
+
.externalPaths()
|
|
20
|
+
.map((p) => p.value());
|
|
15
21
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
1
2
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
6
|
import type { PermissionCheckResult } from "#src/types";
|
|
6
|
-
import type { BashProgram } from "./bash-program";
|
|
7
7
|
import { pickMostRestrictive } from "./candidate-check";
|
|
8
8
|
import type { GateResult } from "./descriptor";
|
|
9
9
|
import { formatPathAskPrompt } from "./path";
|
|
@@ -36,7 +36,7 @@ export function describeBashPathGate(
|
|
|
36
36
|
|
|
37
37
|
if (!bashProgram) return null;
|
|
38
38
|
|
|
39
|
-
const candidates = bashProgram.pathRuleCandidates(
|
|
39
|
+
const candidates = bashProgram.pathRuleCandidates();
|
|
40
40
|
if (candidates.length === 0) return null;
|
|
41
41
|
const tokens = candidates.map(({ token }) => token);
|
|
42
42
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
4
|
+
import { pickMostRestrictive } from "./candidate-check";
|
|
5
|
+
|
|
6
|
+
/** An external path whose resolved `external_directory` state is not "allow". */
|
|
7
|
+
export interface UncoveredExternalPath {
|
|
8
|
+
path: AccessPath;
|
|
9
|
+
check: PermissionCheckResult;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The uncovered external paths plus the most restrictive check among them. */
|
|
13
|
+
export interface UncoveredExternalPaths {
|
|
14
|
+
uncovered: UncoveredExternalPath[];
|
|
15
|
+
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
|
|
16
|
+
worstCheck: PermissionCheckResult | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve one external path's policy on the `external_directory` surface.
|
|
21
|
+
*
|
|
22
|
+
* Matches against the typed and symlink-resolved aliases
|
|
23
|
+
* ({@link AccessPath.matchValues}) so a config pattern on either form applies
|
|
24
|
+
* (#418). This is the single source for the alias-derivation plus
|
|
25
|
+
* surface-tagged resolve that the two external-directory gates previously
|
|
26
|
+
* duplicated.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveExternalDirectoryPolicy(
|
|
29
|
+
path: AccessPath,
|
|
30
|
+
resolver: ScopedPermissionResolver,
|
|
31
|
+
agentName: string | undefined,
|
|
32
|
+
): PermissionCheckResult {
|
|
33
|
+
return resolver.resolvePathPolicy(
|
|
34
|
+
path.matchValues(),
|
|
35
|
+
agentName,
|
|
36
|
+
"external_directory",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a set of external paths and select those not already allowed.
|
|
42
|
+
*
|
|
43
|
+
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
|
|
44
|
+
* whose state is not "allow" are collected (filtering on state, not source, so
|
|
45
|
+
* config-level allow rules suppress the prompt just as session-level allow
|
|
46
|
+
* rules do), and the most restrictive uncovered check is returned so a config
|
|
47
|
+
* "deny" is not downgraded to the catch-all "ask".
|
|
48
|
+
*/
|
|
49
|
+
export function selectUncoveredExternalPaths(
|
|
50
|
+
paths: readonly AccessPath[],
|
|
51
|
+
resolver: ScopedPermissionResolver,
|
|
52
|
+
agentName: string | undefined,
|
|
53
|
+
): UncoveredExternalPaths {
|
|
54
|
+
const uncovered: UncoveredExternalPath[] = [];
|
|
55
|
+
for (const path of paths) {
|
|
56
|
+
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
|
|
57
|
+
if (check.state !== "allow") {
|
|
58
|
+
uncovered.push({ path, check });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
uncovered,
|
|
63
|
+
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
|
|
64
|
+
};
|
|
65
|
+
}
|