@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.
- package/CHANGELOG.md +14 -0
- package/README.md +40 -125
- package/index.ts +6 -18
- package/package.json +4 -5
- package/src/ask-user-panel.ts +1 -1
- package/permission.json.example +0 -299
- package/src/base-command.ts +0 -287
- package/src/bash-scan.ts +0 -402
- package/src/describe-tool.ts +0 -141
- package/src/permission-panel.ts +0 -276
- package/src/permission-store.ts +0 -580
- package/src/permission.ts +0 -364
- package/src/wildcard.ts +0 -91
package/src/base-command.ts
DELETED
|
@@ -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
|
-
}
|