@itc-steve/pi-ask-complete 0.2.0 → 1.0.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.
@@ -1,287 +0,0 @@
1
- /**
2
- * Extract the base command name used for permanent whitelist entries.
3
- *
4
- * Examples:
5
- * grep -f "Hi" → grep
6
- * /usr/bin/grep foo → grep
7
- * sudo apt install x → apt
8
- * FOO=1 BAR=2 ls -la → ls
9
- * env FOO=1 npm test → npm
10
- * strace -f rm -rf x → rm
11
- */
12
-
13
- /** True when the command is invoked via sudo/doas (bash should use sudo_run instead). */
14
- export function isSudoPrefixed(command: string): boolean {
15
- const s = command.trim();
16
- if (!s) return false;
17
- // sudo / doas, optional long flags before the real command
18
- return /^(sudo|doas)(\s|$)/i.test(s);
19
- }
20
-
21
- /** Strip a leading sudo/doas (+ common flags) so rules match the real binary. */
22
- export function stripSudoPrefix(command: string): string {
23
- let s = command.trim();
24
- // sudo -n -u root pacman … → pacman …
25
- s = s.replace(/^(sudo|doas)(\s+(-[A-Za-z]+|--\S+))*(?=\s|$)/i, "").trim();
26
- return s;
27
- }
28
-
29
- /**
30
- * Exec wrappers whose own allow must never stand for the inner binary.
31
- * Keep in sync with stripWrappers.
32
- */
33
- const WRAPPERS = new Set([
34
- "command",
35
- "builtin",
36
- "time",
37
- "nohup",
38
- "nice",
39
- "env",
40
- "strace",
41
- "ltrace",
42
- "timeout",
43
- "xargs",
44
- "stdbuf",
45
- "setsid",
46
- "chroot",
47
- "unshare",
48
- ]);
49
-
50
- /** Long opts that take a separate argument for the wrappers above. */
51
- const WRAPPER_LONG_VALUE = new Set([
52
- "user",
53
- "unset",
54
- "chdir",
55
- "split-string",
56
- "arg-file",
57
- "delimiter",
58
- "replace",
59
- "signal",
60
- "kill-after",
61
- "max-procs",
62
- "max-args",
63
- "max-chars",
64
- ]);
65
-
66
- /** Short opts that take a separate argument (`-u NAME`, `-e EXPR`, …). */
67
- const WRAPPER_SHORT_VALUE = new Set([
68
- "u",
69
- "e",
70
- "p",
71
- "s",
72
- "k",
73
- "n",
74
- "C",
75
- "I",
76
- "E",
77
- "L",
78
- "P",
79
- "S",
80
- "o", // stdbuf -o MODE
81
- ]);
82
-
83
- /** Drop leading wrapper flags (`-f`, `-i`, `-u NAME`, `--flag`, timeout duration). */
84
- function skipWrapperFlags(rest: string): string {
85
- for (let n = 0; n < 24 && rest; n++) {
86
- if (rest === "--") return "";
87
- if (rest.startsWith("-- ")) return rest.slice(3).trim();
88
-
89
- if (rest.startsWith("--")) {
90
- const m = rest.match(/^--([^=\s]+)(?:=(\S+))?(?:\s+|$)([\s\S]*)/);
91
- if (!m) break;
92
- rest = (m[3] ?? "").trim();
93
- // --user NAME (value not glued with =)
94
- if (m[2] === undefined && WRAPPER_LONG_VALUE.has(m[1]!) && rest && !rest.startsWith("-")) {
95
- rest = rest.replace(/^\S+\s*/, "").trim();
96
- }
97
- continue;
98
- }
99
-
100
- if (rest[0] === "-" && rest[1] && rest[1] !== "-") {
101
- // -f / -i / -u NAME / -e=expr
102
- const m = rest.match(/^-([A-Za-z0-9]+)(?:=(\S+))?(?:\s+|$)([\s\S]*)/);
103
- if (!m) break;
104
- const flags = m[1]!;
105
- rest = (m[3] ?? "").trim();
106
- if (m[2] !== undefined) continue; // -e=expr already consumed
107
- // single short opt that takes a value: -u NAME
108
- if (
109
- flags.length === 1 &&
110
- WRAPPER_SHORT_VALUE.has(flags) &&
111
- rest &&
112
- !rest.startsWith("-")
113
- ) {
114
- rest = rest.replace(/^\S+\s*/, "").trim();
115
- }
116
- continue;
117
- }
118
-
119
- // timeout duration: 5 / 5s / 1m
120
- if (/^\d/.test(rest)) {
121
- rest = rest.replace(/^\S+\s*/, "").trim();
122
- continue;
123
- }
124
- break;
125
- }
126
- return rest;
127
- }
128
-
129
- /**
130
- * True when command starts with a wrapper that has trailing tokens.
131
- * Combined with stripWrappers() === "", means the inner binary is unresolvable.
132
- */
133
- export function wrapperHasArgs(command: string): boolean {
134
- let s = command.trim();
135
- s = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "").trim();
136
- if (!s) return false;
137
- const m = s.match(/^(\S+)(?:\s+(\S+))?/);
138
- if (!m?.[2]) return false;
139
- const tok = m[1]!.replace(/^['"]|['"]$/g, "");
140
- const bin = (tok.split(/[/\\]/).pop() ?? tok).toLowerCase();
141
- return WRAPPERS.has(bin);
142
- }
143
-
144
- /**
145
- * Peel leading ENV=value assignments + exec wrappers (env/strace/timeout/…).
146
- * Returns the inner command, or "" when only a bare wrapper remains.
147
- * Callers must treat "" + a wrapper prefix as unresolvable (ask, never allow).
148
- */
149
- export function stripWrappers(command: string): string {
150
- let s = command.trim();
151
- if (!s) return "";
152
-
153
- for (let i = 0; i < 8; i++) {
154
- // FOO=1 BAR=2 cmd → cmd
155
- s = s
156
- .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "")
157
- .trim();
158
- if (!s) return "";
159
-
160
- const m = s.match(/^(\S+)(?:\s+([\s\S]*))?$/);
161
- if (!m) return s;
162
- const tok = m[1]!.replace(/^['"]|['"]$/g, "");
163
- const bin = (tok.split(/[/\\]/).pop() ?? tok).toLowerCase();
164
- if (!WRAPPERS.has(bin)) return s; // real binary — stop
165
-
166
- const rest = skipWrapperFlags((m[2] ?? "").trim());
167
- if (!rest) return ""; // bare wrapper / flags only
168
- s = rest;
169
- }
170
- return s.trim();
171
- }
172
-
173
- export function baseCommand(command: string): string {
174
- let s = stripSudoPrefix(command.trim());
175
- if (!s) return "";
176
-
177
- // Resolve through wrappers so `strace -f rm` → rm (not strace).
178
- // Bare `env` peels to "" — fall back so the wrapper name itself is the base.
179
- s = stripWrappers(s) || s;
180
-
181
- // If the user wrote a pipeline/list, whitelist against the first segment.
182
- const first = s.split(/[|;&\n]/)[0]?.trim() ?? s;
183
- const token = first.split(/\s+/)[0] ?? "";
184
- if (!token) return "";
185
-
186
- // Drop surrounding quotes and path prefix.
187
- const unquoted = token.replace(/^['"]|['"]$/g, "");
188
- const base = unquoted.split(/[/\\]/).pop() ?? unquoted;
189
- return base;
190
- }
191
-
192
- /** Keywords / non-binaries that must never become permanent allow keys. */
193
- const BLOCKED_BASH_KEYS = new Set([
194
- // shell grammar
195
- "if",
196
- "then",
197
- "else",
198
- "elif",
199
- "fi",
200
- "for",
201
- "while",
202
- "until",
203
- "do",
204
- "done",
205
- "case",
206
- "esac",
207
- "function",
208
- "select",
209
- "coproc",
210
- "in",
211
- // common leaks from node/python heredoc bodies
212
- "const",
213
- "let",
214
- "var",
215
- "return",
216
- "class",
217
- "import",
218
- "from",
219
- "def",
220
- ]);
221
-
222
- /**
223
- * True when a bash allow key is safe to write to permission.json.
224
- * Permanent allow only stores simple binary names (python3, mkfs.ext4) — never
225
- * prose fragments, JS lines, or shell keywords that leaked from bad splits.
226
- */
227
- export function isPersistableBashKey(key: string): boolean {
228
- if (!key || key.length > 64) return false;
229
- if (BLOCKED_BASH_KEYS.has(key)) return false;
230
- // Simple binary: rg, python3, docker-compose
231
- if (/^[A-Za-z_][A-Za-z0-9_+-]*$/.test(key)) return true;
232
- // Dotted binaries only for known families (mkfs.ext4, python3.12) — not console.log
233
- if (/^(mkfs|fsck|python|pip|node)[0-9]*\.[A-Za-z0-9_+-]+$/.test(key)) return true;
234
- return false;
235
- }
236
-
237
- /** True when `command` is covered by a whitelist entry (base name or exact). */
238
- export function commandMatchesWhitelist(
239
- command: string,
240
- allowed: ReadonlySet<string> | readonly string[],
241
- ): boolean {
242
- const set = allowed instanceof Set ? allowed : new Set(allowed);
243
- if (set.size === 0) return false;
244
- const full = command.trim();
245
- if (set.has(full)) return true;
246
- const base = baseCommand(command);
247
- return base !== "" && set.has(base);
248
- }
249
-
250
- /**
251
- * Strip noise so rule patterns match the logical command.
252
- * Today: git globals (`-C`, `-c`, `--git-dir`, …) so `git status*` matches
253
- * `git -C /path status`.
254
- *
255
- * ponytail: regex strip of known globals; quoted paths with spaces not handled.
256
- */
257
- export function normalizeCommandForMatch(command: string): string {
258
- const trimmed = command.trim();
259
- if (!trimmed) return trimmed;
260
-
261
- // First token's basename is `git` (covers git, /usr/bin/git, ./git).
262
- // Always rewrite to bare `git` so patterns like `git status*` match.
263
- const tok = trimmed.match(/^\S+/);
264
- if (!tok) return trimmed;
265
- const bin = (tok[0]!.split(/[/\\]/).pop() ?? tok[0]!).replace(/^['"]|['"]$/g, "");
266
- if (!/^git$/i.test(bin)) return trimmed;
267
-
268
- let rest = trimmed.slice(tok[0]!.length).replace(/^\s+/, "");
269
-
270
- // Repeatedly peel one global option from the front until the subcommand.
271
- // Options that take a value: -C path, -c key=val, --git-dir[=]path, …
272
- const withVal =
273
- /^(?:-C|--git-dir|--work-tree|--namespace|--config-env|--exec-path)(?:=|\s+)\S+\s*/;
274
- const shortC = /^-c\s+\S+\s*/;
275
- const flagOnly =
276
- /^(?:-p|--paginate|-P|--no-pager|--bare|--no-replace-objects|--no-optional-locks|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs)\s+/;
277
-
278
- for (let i = 0; i < 16; i++) {
279
- let next = rest.replace(withVal, "").replace(shortC, "").replace(flagOnly, "");
280
- // --exec-path with no value (prints path; rare in agent cmds)
281
- next = next.replace(/^--exec-path\s+/, "");
282
- if (next === rest) break;
283
- rest = next;
284
- }
285
-
286
- return rest ? `git ${rest}`.trim() : "git";
287
- }
package/src/bash-scan.ts DELETED
@@ -1,402 +0,0 @@
1
- /**
2
- * Heuristic bash decomposition for the permission gate.
3
- *
4
- * Two jobs:
5
- * - splitUnits: break a command line into individual command "units" so a rule
6
- * lookup can require EVERY unit to be allowed (closes `ls; rm -rf x` bypass).
7
- * Units include segments split on ; | && || & \n AND the contents of
8
- * command substitution `$(...)` / backticks (closes `echo $(rm x)`).
9
- * - pathArgs: pull path-like arguments (incl. redirect targets) out of a
10
- * command so they can be checked against path deny rules
11
- * (closes `cat .env` / `echo x > .env`).
12
- *
13
- * ponytail: regex heuristic, not a real shell parser. Misses full $VAR resolution
14
- * and nested quote mazes. Unresolved globs/braces/$'' force ask at the store.
15
- * Upgrade to a shell AST (mvdan-style) only if those vectors matter.
16
- */
17
-
18
- const MAX_DEPTH = 6;
19
-
20
- /**
21
- * Blank out <<EOF … EOF heredoc bodies so their lines are not treated as units
22
- * (node <<'EOF' / const / for / console.log were landing in permission.json).
23
- */
24
- export function stripHeredocs(command: string): string {
25
- // <<[-]? optional quotes WORD then body until a line that is exactly WORD
26
- return command.replace(
27
- /<<(-)?\s*(['"]?)(\w+)\2\r?\n[\s\S]*?\r?\n\3(?=\r?\n|$)/g,
28
- " ",
29
- );
30
- }
31
-
32
- /**
33
- * Drop `# …` comments outside quotes so agent annotations don't become units
34
- * (`# setup; then rm x` → no `#`/`then` bases from prose).
35
- * ponytail: not a full lexer; # inside $'…' / unclosed quotes is best-effort.
36
- */
37
- export function stripComments(command: string): string {
38
- let out = "";
39
- let quote: "'" | '"' | null = null;
40
- for (let i = 0; i < command.length; i++) {
41
- const c = command[i]!;
42
- if (quote) {
43
- out += c;
44
- if (c === "\\" && quote === '"' && i + 1 < command.length) {
45
- out += command[++i];
46
- } else if (c === quote) {
47
- quote = null;
48
- }
49
- continue;
50
- }
51
- if (c === "'" || c === '"') {
52
- quote = c;
53
- out += c;
54
- continue;
55
- }
56
- // Bash: # starts a comment only at a word boundary (not $# / ${#x} / foo#bar).
57
- const prev = i === 0 ? "" : command[i - 1]!;
58
- if (c === "#" && (i === 0 || /[\s;|&()]/.test(prev))) {
59
- while (i + 1 < command.length && command[i + 1] !== "\n") i++;
60
- continue;
61
- }
62
- out += c;
63
- }
64
- return out;
65
- }
66
-
67
- /**
68
- * Split on shell control operators, but NOT inside quotes.
69
- * Fixes `python3 -c "…\n…"` being shredded into fake units (and crashing the
70
- * permission panel with a 20-base label).
71
- */
72
- function splitSegments(command: string): string[] {
73
- const parts: string[] = [];
74
- let cur = "";
75
- let quote: "'" | '"' | null = null;
76
- for (let i = 0; i < command.length; i++) {
77
- const c = command[i]!;
78
- if (quote) {
79
- cur += c;
80
- if (c === "\\" && quote === '"' && i + 1 < command.length) {
81
- cur += command[++i];
82
- } else if (c === quote) {
83
- quote = null;
84
- }
85
- continue;
86
- }
87
- if (c === "'" || c === '"') {
88
- quote = c;
89
- cur += c;
90
- continue;
91
- }
92
- // && or ||
93
- if ((c === "&" || c === "|") && command[i + 1] === c) {
94
- if (cur.trim()) parts.push(cur.trim());
95
- cur = "";
96
- i++;
97
- continue;
98
- }
99
- // Redirects that contain & must not become split points:
100
- // 2>&1 >&2 &>file &>>file
101
- // (bare `cmd &` / `cmd1 & cmd2` still split — that's intentional).
102
- if (c === "&") {
103
- const prev = cur.length ? cur[cur.length - 1]! : "";
104
- const next = command[i + 1] ?? "";
105
- if (prev === ">" || next === ">") {
106
- cur += c;
107
- continue;
108
- }
109
- }
110
- if (c === ";" || c === "|" || c === "&" || c === "\n") {
111
- if (cur.trim()) parts.push(cur.trim());
112
- cur = "";
113
- continue;
114
- }
115
- cur += c;
116
- }
117
- if (cur.trim()) parts.push(cur.trim());
118
- return parts;
119
- }
120
-
121
- /** Extract `$(...)`, backtick, and process substitution <(...)/>(...) bodies.
122
- * (one level; recursion + segment split handles nesting). */
123
- function extractSubstitutions(command: string): string[] {
124
- const out: string[] = [];
125
- // $( ... )
126
- const dollar = /\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
127
- let m: RegExpExecArray | null;
128
- while ((m = dollar.exec(command))) {
129
- if (m[1]?.trim()) out.push(m[1].trim());
130
- }
131
- // backticks
132
- const back = /`([^`]*)`/g;
133
- while ((m = back.exec(command))) {
134
- if (m[1]?.trim()) out.push(m[1].trim());
135
- }
136
- // process substitution <(cmd) and >(cmd) — C1 fix
137
- const proc = /[<>]\(\s*([^()]*?(?:\([^()]*\)[^()]*?)*)\s*\)/g;
138
- while ((m = proc.exec(command))) {
139
- if (m[1]?.trim()) out.push(m[1].trim());
140
- }
141
- return out;
142
- }
143
-
144
- /**
145
- * All command units in a line: each segment, plus every substitution body
146
- * (recursively), each itself segment-split.
147
- */
148
- export function splitUnits(command: string, depth = 0): string[] {
149
- // Top-level only: drop comments + heredoc bodies before segmenting.
150
- const cleaned =
151
- depth === 0 ? stripComments(stripHeredocs(command)) : command;
152
- const trimmed = cleaned.trim();
153
- if (!trimmed || depth > MAX_DEPTH) return trimmed ? [trimmed] : [];
154
-
155
- const units: string[] = [];
156
- for (const seg of splitSegments(trimmed)) {
157
- const subs = extractSubstitutions(seg);
158
- // The segment with substitutions blanked out is still a real command to check.
159
- const bare = seg
160
- .replace(/\$\([^)]*\)/g, " ")
161
- .replace(/`[^`]*`/g, " ")
162
- .replace(/[<>]\([^)]*\)/g, " ")
163
- .trim();
164
- if (bare) units.push(bare);
165
- for (const sub of subs) units.push(...splitUnits(sub, depth + 1));
166
- }
167
- return units.length ? units : [];
168
- }
169
-
170
- /**
171
- * Collapse shell quoting/escapes to the path value the shell would open.
172
- * - unquoted \x → x; drop unescaped ' and " anywhere (not just edges)
173
- * - single-quoted text is literal (including $ and \) — closes C3 while
174
- * keeping `'$HOME'/.env` as the literal path $HOME/.env
175
- * - double-quoted: \ only escapes $ ` " \ newline; $ residue stays so
176
- * isUnresolvedPath still fires for "$HOME"/.env
177
- * Unclosed quote → return s unchanged (fail closed: ambiguous).
178
- */
179
- function collapseShellToken(s: string): string {
180
- let out = "";
181
- let i = 0;
182
- while (i < s.length) {
183
- const c = s[i]!;
184
- if (c === "'") {
185
- i++;
186
- let closed = false;
187
- while (i < s.length) {
188
- if (s[i] === "'") {
189
- closed = true;
190
- i++;
191
- break;
192
- }
193
- out += s[i++];
194
- }
195
- if (!closed) return s;
196
- continue;
197
- }
198
- if (c === '"') {
199
- i++;
200
- let closed = false;
201
- while (i < s.length) {
202
- if (s[i] === '"') {
203
- closed = true;
204
- i++;
205
- break;
206
- }
207
- if (s[i] === "\\" && i + 1 < s.length) {
208
- const n = s[i + 1]!;
209
- // bash: inside double quotes only $ ` " \ and newline are special
210
- if (n === "$" || n === "`" || n === '"' || n === "\\" || n === "\n") {
211
- out += n;
212
- i += 2;
213
- } else {
214
- out += s[i++]; // keep the backslash
215
- }
216
- } else {
217
- out += s[i++];
218
- }
219
- }
220
- if (!closed) return s;
221
- continue;
222
- }
223
- if (c === "\\" && i + 1 < s.length) {
224
- out += s[i + 1];
225
- i += 2;
226
- continue;
227
- }
228
- out += c;
229
- i++;
230
- }
231
- return out;
232
- }
233
-
234
- function unquote(s: string): string {
235
- return collapseShellToken(s).trim();
236
- }
237
-
238
- /**
239
- * Normalize a raw argv token into a path candidate:
240
- * - strip @file upload prefix, trailing ) from subs
241
- * - strip trailing shell operators glued on (`cat .env;true` → `.env`)
242
- * - peel git pathspecs (`HEAD:.env` → `.env`)
243
- * - peel dd-style if=/of= assignments
244
- * - collapse intra-word quotes/escapes (`.en''v` → `.env`) so path globs match
245
- */
246
- export function cleanPathToken(token: string): string {
247
- let t = token.trim();
248
- if (!t) return "";
249
- t = t.replace(/^@/, "");
250
- // Operators glued to the path without whitespace (bypass vector).
251
- t = t.replace(/[;|&`].*$/, "");
252
- t = t.replace(/\)+$/, "").trim();
253
- if (!t) return "";
254
-
255
- // dd/install style: if=.env of=/tmp/x
256
- const assign = t.match(
257
- /^(?:if|of|in|out|file|path|filename|dest|source)=(.+)$/i,
258
- );
259
- if (assign?.[1]) t = assign[1];
260
-
261
- // git pathspec rev:path / :path — not Windows drive (C:\… / C:/…)
262
- if (
263
- t.includes(":") &&
264
- !/^[A-Za-z]:[\\/]/.test(t) &&
265
- !/^[A-Za-z]:$/.test(t)
266
- ) {
267
- const pathPart = t.slice(t.lastIndexOf(":") + 1);
268
- if (pathPart) t = pathPart;
269
- }
270
-
271
- // After operator/assign/pathspec peel: collapse quotes so **/.env matches.
272
- return collapseShellToken(t).trim();
273
- }
274
-
275
- /**
276
- * True when the token still needs shell expansion before path policy can allow.
277
- * Quote-aware: single-quoted text is literal (`'$HOME'` is not an expansion);
278
- * `$VAR` / `${…}` / `$(…)` / `$ '…'` / globs / braces outside single quotes
279
- * (and `$` inside double quotes) still force unresolved.
280
- */
281
- export function isUnresolvedPath(token: string): boolean {
282
- if (!token) return false;
283
- // Bash expands ~user, but this gate only resolves the current user's ~/.
284
- if (/^~[^/\s]+(?:\/|$)/.test(token)) return true;
285
- let i = 0;
286
- while (i < token.length) {
287
- const c = token[i]!;
288
- if (c === "'") {
289
- const end = token.indexOf("'", i + 1);
290
- if (end < 0) return true; // unclosed — fail closed
291
- i = end + 1;
292
- continue;
293
- }
294
- if (c === '"') {
295
- i++;
296
- while (i < token.length && token[i] !== '"') {
297
- if (token[i] === "\\" && i + 1 < token.length) {
298
- i += 2;
299
- continue;
300
- }
301
- // $ expands inside double quotes; globs do not
302
- if (token[i] === "$" && i + 1 < token.length) {
303
- const n = token[i + 1]!;
304
- if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
305
- }
306
- i++;
307
- }
308
- if (i >= token.length) return true; // unclosed
309
- i++;
310
- continue;
311
- }
312
- if (c === "\\" && i + 1 < token.length) {
313
- i += 2;
314
- continue;
315
- }
316
- // unquoted glob / brace / $
317
- if (c === "*" || c === "?" || c === "[" || c === "{") return true;
318
- if (c === "$" && i + 1 < token.length) {
319
- const n = token[i + 1]!;
320
- if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
321
- }
322
- i++;
323
- }
324
- return false;
325
- }
326
-
327
- // Looks like a path argument (has a slash or a dotfile/ext), not a flag.
328
- function looksLikePath(token: string): boolean {
329
- if (!token || token.startsWith("-")) return false;
330
- if (token.includes("=")) return false; // env assignment / --opt=val left after clean
331
- return (
332
- token.includes("/") ||
333
- /^\.?[\w.-]+\.\w+$/.test(token) ||
334
- token.startsWith(".") ||
335
- // git pathspec residue or plain secret basenames without a dot (id_rsa)
336
- /^(id_rsa|id_ed25519|id_ecdsa|id_dsa|shadow|gshadow|sudoers|kubeconfig)$/i.test(
337
- token,
338
- )
339
- );
340
- }
341
-
342
- /**
343
- * Path-like arguments across the whole command line, including redirect targets
344
- * (`> file`, `>> file`, `2> file`). Quotes stripped; returned cleaned.
345
- */
346
- export function pathArgs(command: string): string[] {
347
- const src = stripComments(stripHeredocs(command));
348
- const out: string[] = [];
349
-
350
- const push = (raw: string) => {
351
- const t = cleanPathToken(raw);
352
- if (t && (looksLikePath(t) || isUnresolvedPath(t))) out.push(t);
353
- };
354
-
355
- // redirect targets: >, >>, <, 2>, &> followed by a filename (but NOT <( > ( process subs)
356
- const redir = /(?:\d*&?>{1,2}|<)\s*("[^"]+"|'[^']+'|(?!\()\S+)/g;
357
- let m: RegExpExecArray | null;
358
- while ((m = redir.exec(src))) {
359
- const t = unquote(m[1]!);
360
- if (t && !t.startsWith("&")) push(t);
361
- }
362
-
363
- // bare tokens (and if=/.git pathspec forms via cleanPathToken)
364
- for (const rawTok of src.split(/\s+/)) {
365
- if (!rawTok) continue;
366
- // Attached option values commonly carry paths (`--file=.env`, `--chdir=/tmp`).
367
- const optionValue = rawTok.match(/^--?[A-Za-z][A-Za-z0-9-]*=(.+)$/)?.[1];
368
- if (optionValue) {
369
- push(optionValue);
370
- continue;
371
- }
372
- // Keep if=.env visible to cleanPathToken (looksLikePath alone would skip `=`).
373
- if (/^(?:if|of|in|out|file|path|filename|dest|source)=/i.test(rawTok)) {
374
- push(rawTok);
375
- continue;
376
- }
377
- push(rawTok.replace(/^[<>]+\(?/, ""));
378
- }
379
-
380
- return [...new Set(out)];
381
- }
382
-
383
- /**
384
- * True when the line has unquoted glob/brace/ANSI-C tokens so path policy
385
- * cannot prove the real path — caller should force ask (never silent allow).
386
- */
387
- export function hasUnresolvedExpansion(command: string): boolean {
388
- const src = stripComments(stripHeredocs(command));
389
- for (const rawTok of src.split(/\s+/)) {
390
- if (!rawTok || rawTok.startsWith("-")) continue;
391
- // Quote-aware on the raw token first: '$HOME' is literal, "$HOME" is not.
392
- // Do not re-scan the collapsed form for $ — that would turn single-quoted
393
- // literal $ into a false expansion after cleanPathToken strips the quotes.
394
- if (isUnresolvedPath(rawTok)) return true;
395
- const t = cleanPathToken(rawTok);
396
- // Globs/braces that survive collapse (unquoted) still force ask.
397
- if (t && /[*?[{]/.test(t)) return true;
398
- }
399
- // Whole-line $'…' even when glued
400
- if (/\$'[^']*'/.test(src)) return true;
401
- return false;
402
- }