@adeildo/pi-ask-permission 4.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/CONTRIBUTING.md +43 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/assets/preview.png +0 -0
- package/package.json +63 -0
- package/src/core/always-yes.ts +183 -0
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +118 -0
- package/src/core/config/patterns.ts +51 -0
- package/src/core/config/schema.ts +72 -0
- package/src/core/config/settings.ts +374 -0
- package/src/core/config/store.ts +57 -0
- package/src/core/decide.ts +106 -0
- package/src/core/judge/backends/factory.ts +30 -0
- package/src/core/judge/backends/jev.ts +227 -0
- package/src/core/judge/backends/pi-model.ts +174 -0
- package/src/core/judge/compose.ts +91 -0
- package/src/core/judge/config.ts +60 -0
- package/src/core/judge/decode.ts +51 -0
- package/src/core/judge/gate.ts +70 -0
- package/src/core/judge/pipeline.ts +106 -0
- package/src/core/judge/policy.ts +114 -0
- package/src/core/judge/probe.ts +51 -0
- package/src/core/judge/report.ts +73 -0
- package/src/core/judge/request.ts +78 -0
- package/src/core/judge/types.ts +72 -0
- package/src/core/mode.ts +42 -0
- package/src/core/readonly-bash.ts +689 -0
- package/src/core/tools.ts +208 -0
- package/src/core/workspace.ts +66 -0
- package/src/identity.ts +5 -0
- package/src/index.ts +29 -0
- package/src/pi/api.ts +44 -0
- package/src/pi/bash-timer.ts +49 -0
- package/src/pi/commands.ts +193 -0
- package/src/pi/events.ts +260 -0
- package/src/pi/mode.ts +48 -0
- package/src/pi/preview.ts +82 -0
- package/src/pi/provider.ts +11 -0
- package/src/pi/session-entries.ts +74 -0
- package/src/pi/session.ts +129 -0
- package/src/ui/clipboard.ts +51 -0
- package/src/ui/decision-options.ts +23 -0
- package/src/ui/dialog.ts +395 -0
- package/src/ui/judge-entry.ts +130 -0
- package/src/ui/paste.ts +27 -0
- package/src/ui/picker.ts +90 -0
- package/src/ui/selector.ts +42 -0
- package/src/ui/settings/judge.ts +299 -0
- package/src/ui/settings/screen.ts +268 -0
- package/src/ui/settings/status.ts +53 -0
- package/src/ui/typing.ts +72 -0
- package/src/util/primitives.ts +11 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { parse, type ParseEntry } from "shell-quote";
|
|
2
|
+
|
|
3
|
+
const SEPARATORS = new Set(["&&", "||", ";", "|", "&"]);
|
|
4
|
+
const FIND_ACTIONS = [
|
|
5
|
+
"-exec",
|
|
6
|
+
"-execdir",
|
|
7
|
+
"-ok",
|
|
8
|
+
"-okdir",
|
|
9
|
+
"-delete",
|
|
10
|
+
"-fprint",
|
|
11
|
+
"-fprint0",
|
|
12
|
+
"-fprintf",
|
|
13
|
+
"-fls",
|
|
14
|
+
];
|
|
15
|
+
const GIT_READ_ONLY = new Set([
|
|
16
|
+
"status",
|
|
17
|
+
"diff",
|
|
18
|
+
"log",
|
|
19
|
+
"show",
|
|
20
|
+
"blame",
|
|
21
|
+
"annotate",
|
|
22
|
+
"shortlog",
|
|
23
|
+
"describe",
|
|
24
|
+
"rev-parse",
|
|
25
|
+
"rev-list",
|
|
26
|
+
"ls-files",
|
|
27
|
+
"ls-tree",
|
|
28
|
+
"ls-remote",
|
|
29
|
+
"cat-file",
|
|
30
|
+
"show-ref",
|
|
31
|
+
"for-each-ref",
|
|
32
|
+
"symbolic-ref",
|
|
33
|
+
"merge-base",
|
|
34
|
+
"name-rev",
|
|
35
|
+
"whatchanged",
|
|
36
|
+
"reflog",
|
|
37
|
+
"cherry",
|
|
38
|
+
"grep",
|
|
39
|
+
"diff-tree",
|
|
40
|
+
"diff-index",
|
|
41
|
+
"diff-files",
|
|
42
|
+
"verify-commit",
|
|
43
|
+
"verify-tag",
|
|
44
|
+
"count-objects",
|
|
45
|
+
"fsck",
|
|
46
|
+
"var",
|
|
47
|
+
"version",
|
|
48
|
+
"help",
|
|
49
|
+
"check-ignore",
|
|
50
|
+
"check-attr",
|
|
51
|
+
"check-ref-format",
|
|
52
|
+
]);
|
|
53
|
+
const GIT_EXEC_FLAGS = [
|
|
54
|
+
"-O",
|
|
55
|
+
"--open-files-in-pager",
|
|
56
|
+
"--ext-diff",
|
|
57
|
+
"--textconv",
|
|
58
|
+
"--use-textconv",
|
|
59
|
+
];
|
|
60
|
+
const GIT_BRANCH_FLAGS = new Set([
|
|
61
|
+
"-a",
|
|
62
|
+
"--all",
|
|
63
|
+
"-r",
|
|
64
|
+
"--remotes",
|
|
65
|
+
"-l",
|
|
66
|
+
"--list",
|
|
67
|
+
"-v",
|
|
68
|
+
"--verbose",
|
|
69
|
+
"-i",
|
|
70
|
+
"--ignore-case",
|
|
71
|
+
"--show-current",
|
|
72
|
+
"--no-column",
|
|
73
|
+
"--no-color",
|
|
74
|
+
]);
|
|
75
|
+
const GIT_BRANCH_SHORT_LETTERS = new Set(["a", "r", "l", "v", "i"]);
|
|
76
|
+
const GIT_BRANCH_VALUE_FLAGS = new Set([
|
|
77
|
+
"--contains",
|
|
78
|
+
"--no-contains",
|
|
79
|
+
"--merged",
|
|
80
|
+
"--no-merged",
|
|
81
|
+
"--points-at",
|
|
82
|
+
"--sort",
|
|
83
|
+
"--format",
|
|
84
|
+
]);
|
|
85
|
+
// Git only reads an optional value when it is attached with `=`, so a separate word
|
|
86
|
+
// stays a branch name and must not be swallowed here.
|
|
87
|
+
const GIT_BRANCH_OPTIONAL_VALUE_FLAGS = new Set(["--color", "--column", "--abbrev"]);
|
|
88
|
+
const GIT_REMOTE_FLAGS = new Set(["-v", "--verbose"]);
|
|
89
|
+
const GIT_REMOTE_SUBCOMMANDS = new Set(["show", "get-url"]);
|
|
90
|
+
|
|
91
|
+
const LOOP_DEPTH_LIMIT = 4;
|
|
92
|
+
const LOOP_WORD_LIMIT = 128;
|
|
93
|
+
const LOOP_TOKEN_LIMIT = 4096;
|
|
94
|
+
const REFERENCE = "AASKREF";
|
|
95
|
+
const LOOP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
96
|
+
const LOOP_WORD = /^[A-Za-z0-9_./@%+,:^~*?[\]-]+$/;
|
|
97
|
+
|
|
98
|
+
const READ_ONLY = new Set([
|
|
99
|
+
"cat",
|
|
100
|
+
"head",
|
|
101
|
+
"tail",
|
|
102
|
+
"wc",
|
|
103
|
+
"ls",
|
|
104
|
+
"pwd",
|
|
105
|
+
"cd",
|
|
106
|
+
":",
|
|
107
|
+
"echo",
|
|
108
|
+
"printf",
|
|
109
|
+
"grep",
|
|
110
|
+
"egrep",
|
|
111
|
+
"fgrep",
|
|
112
|
+
"diff",
|
|
113
|
+
"cmp",
|
|
114
|
+
"comm",
|
|
115
|
+
"cut",
|
|
116
|
+
"tr",
|
|
117
|
+
"nl",
|
|
118
|
+
"od",
|
|
119
|
+
"xxd",
|
|
120
|
+
"strings",
|
|
121
|
+
"file",
|
|
122
|
+
"stat",
|
|
123
|
+
"du",
|
|
124
|
+
"df",
|
|
125
|
+
"basename",
|
|
126
|
+
"dirname",
|
|
127
|
+
"readlink",
|
|
128
|
+
"realpath",
|
|
129
|
+
"whoami",
|
|
130
|
+
"id",
|
|
131
|
+
"groups",
|
|
132
|
+
"uname",
|
|
133
|
+
"uptime",
|
|
134
|
+
"free",
|
|
135
|
+
"ps",
|
|
136
|
+
"printenv",
|
|
137
|
+
"which",
|
|
138
|
+
"type",
|
|
139
|
+
"whereis",
|
|
140
|
+
"locale",
|
|
141
|
+
"tty",
|
|
142
|
+
"md5sum",
|
|
143
|
+
"sha1sum",
|
|
144
|
+
"sha256sum",
|
|
145
|
+
"sha512sum",
|
|
146
|
+
"cksum",
|
|
147
|
+
"sum",
|
|
148
|
+
"test",
|
|
149
|
+
"true",
|
|
150
|
+
"false",
|
|
151
|
+
"sleep",
|
|
152
|
+
"seq",
|
|
153
|
+
"expr",
|
|
154
|
+
"jq",
|
|
155
|
+
"rev",
|
|
156
|
+
"tac",
|
|
157
|
+
"expand",
|
|
158
|
+
"unexpand",
|
|
159
|
+
"numfmt",
|
|
160
|
+
"fmt",
|
|
161
|
+
"fold",
|
|
162
|
+
"column",
|
|
163
|
+
"base64",
|
|
164
|
+
"[",
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
function hasNoOutputFlag(args: string[]): boolean {
|
|
168
|
+
return !args.some(isOutputFlag);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ARG_CHECKS: Record<string, (args: string[]) => boolean> = {
|
|
172
|
+
rg: (args) => !args.some((arg) => isOneOf(arg, ["--pre", "--hostname-bin"])),
|
|
173
|
+
find: (args) => !args.some((arg) => isOneOf(arg, FIND_ACTIONS)),
|
|
174
|
+
sort: hasNoOutputFlag,
|
|
175
|
+
tree: hasNoOutputFlag,
|
|
176
|
+
date: (args) => !args.some((arg) => arg.startsWith("-s") || arg.startsWith("--set")),
|
|
177
|
+
uniq: (args) => args.filter((arg) => !arg.startsWith("-")).length <= 1,
|
|
178
|
+
hostname: (args) =>
|
|
179
|
+
args.every((arg) => arg.startsWith("-") && !arg.startsWith("--file") && !/^-F./.test(arg)),
|
|
180
|
+
sed: safeSed,
|
|
181
|
+
git: safeGit,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// A classifier, not a sandbox: names are matched as written.
|
|
185
|
+
export function isReadOnlyCommand(command: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
186
|
+
if (env.BASH_ENV) return false;
|
|
187
|
+
if (hasCommandSubstitution(command)) return false;
|
|
188
|
+
|
|
189
|
+
const normalized = normalizeCommand(command);
|
|
190
|
+
if (normalized === undefined) return false;
|
|
191
|
+
|
|
192
|
+
let tokens: ParseEntry[];
|
|
193
|
+
try {
|
|
194
|
+
tokens = parse(normalized);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const expanded = expandLoops(tokens);
|
|
200
|
+
if (expanded === undefined) return false;
|
|
201
|
+
tokens = expanded;
|
|
202
|
+
|
|
203
|
+
let segment: string[] = [];
|
|
204
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
205
|
+
const token = tokens[index];
|
|
206
|
+
if (token === undefined) continue;
|
|
207
|
+
if (typeof token === "string") {
|
|
208
|
+
segment.push(token);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if ("comment" in token) continue;
|
|
212
|
+
if (token.op === "glob") {
|
|
213
|
+
segment.push(token.pattern);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const consumed = safeRedirect(tokens, index);
|
|
218
|
+
if (consumed !== undefined) {
|
|
219
|
+
index += consumed;
|
|
220
|
+
dropFileDescriptor(segment);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!SEPARATORS.has(token.op)) return false;
|
|
225
|
+
if (!isReadOnlySegment(segment, env)) return false;
|
|
226
|
+
segment = [];
|
|
227
|
+
}
|
|
228
|
+
return isReadOnlySegment(segment, env);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A reference the classifier cannot see is not a literal: `cat$f` must not read as `cat`.
|
|
232
|
+
// The name stays in the marker so the loop can put the value back.
|
|
233
|
+
function markerFor(name: string): string {
|
|
234
|
+
return `${REFERENCE}${Buffer.from(name, "utf8").toString("base64url")}${REFERENCE}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readReference(command: string, start: number): { end: number; name: string } | undefined {
|
|
238
|
+
const next = command[start + 1];
|
|
239
|
+
if (next === "{") {
|
|
240
|
+
const close = command.indexOf("}", start + 2);
|
|
241
|
+
return close < 0 ? undefined : { end: close + 1, name: command.slice(start + 2, close) };
|
|
242
|
+
}
|
|
243
|
+
if (!/[A-Za-z0-9_@*#?$!-]/.test(next ?? "")) return undefined;
|
|
244
|
+
|
|
245
|
+
let end = start + 2;
|
|
246
|
+
while (/[A-Za-z0-9_]/.test(command[end] ?? "")) end++;
|
|
247
|
+
return { end, name: command.slice(start + 1, end) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A newline is whitespace only where a list can continue, which the classifier does
|
|
251
|
+
// not model. Every newline becomes a separator and readLoop absorbs the extras.
|
|
252
|
+
function normalizeCommand(command: string): string | undefined {
|
|
253
|
+
let out = "";
|
|
254
|
+
let single = false;
|
|
255
|
+
let double = false;
|
|
256
|
+
|
|
257
|
+
for (let index = 0; index < command.length; index++) {
|
|
258
|
+
const char = command[index] ?? "";
|
|
259
|
+
if (char === "\r") return undefined;
|
|
260
|
+
|
|
261
|
+
if (single) {
|
|
262
|
+
out += char;
|
|
263
|
+
if (char === "'") single = false;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (char === "\\") {
|
|
268
|
+
const next = command[index + 1];
|
|
269
|
+
if (next === "\n") {
|
|
270
|
+
index++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
out += char;
|
|
274
|
+
if (next !== undefined) {
|
|
275
|
+
out += next;
|
|
276
|
+
index++;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (char === "'") {
|
|
282
|
+
if (!double) single = true;
|
|
283
|
+
out += char;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (char === '"') {
|
|
288
|
+
double = !double;
|
|
289
|
+
out += char;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A comment has to go before the newline folds, or it would swallow the next line.
|
|
294
|
+
if (char === "#" && !double && startsWord(out)) {
|
|
295
|
+
while (index + 1 < command.length && command[index + 1] !== "\n") index++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (char === "\n") {
|
|
300
|
+
out += double ? "\n" : ";";
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (char === "$") {
|
|
305
|
+
const reference = readReference(command, index);
|
|
306
|
+
if (reference !== undefined) {
|
|
307
|
+
out += markerFor(reference.name);
|
|
308
|
+
index = reference.end - 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// shell-quote reads a `#` anywhere as a comment, so a mid-word one is escaped.
|
|
314
|
+
out += char === "#" && !double ? "\\#" : char;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return single || double ? undefined : out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function startsWord(text: string): boolean {
|
|
321
|
+
const last = text.at(-1);
|
|
322
|
+
return last === undefined || /[\s;&|()<>]/.test(last);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function startsSegment(tokens: ParseEntry[], index: number): boolean {
|
|
326
|
+
if (index === 0) return true;
|
|
327
|
+
if (tokens[index - 1] === "do") return true;
|
|
328
|
+
|
|
329
|
+
const op = operatorOf(tokens[index - 1]);
|
|
330
|
+
return op !== undefined && SEPARATORS.has(op);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isSemicolon(entry: ParseEntry | undefined): boolean {
|
|
334
|
+
return operatorOf(entry) === ";";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
interface Loop {
|
|
338
|
+
name: string;
|
|
339
|
+
words: string[];
|
|
340
|
+
body: ParseEntry[];
|
|
341
|
+
end: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The value goes back into the body, so the flags and the workspace see what will run.
|
|
345
|
+
function expandLoops(tokens: ParseEntry[], depth = 0): ParseEntry[] | undefined {
|
|
346
|
+
if (depth > LOOP_DEPTH_LIMIT) return undefined;
|
|
347
|
+
|
|
348
|
+
const expanded: ParseEntry[] = [];
|
|
349
|
+
let index = 0;
|
|
350
|
+
|
|
351
|
+
while (index < tokens.length) {
|
|
352
|
+
const token = tokens[index];
|
|
353
|
+
if (token === undefined) break;
|
|
354
|
+
if (token === "for" && startsSegment(tokens, index)) {
|
|
355
|
+
const loop = readLoop(tokens, index);
|
|
356
|
+
if (loop === undefined) return undefined;
|
|
357
|
+
|
|
358
|
+
const body = expandLoops(loop.body, depth + 1);
|
|
359
|
+
if (body === undefined) return undefined;
|
|
360
|
+
|
|
361
|
+
// A list that reads as empty still has to face the check.
|
|
362
|
+
const marker = markerFor(loop.name);
|
|
363
|
+
const copies = Math.max(loop.words.length, 1);
|
|
364
|
+
for (let copy = 0; copy < copies; copy++) {
|
|
365
|
+
if (copy > 0) expanded.push({ op: ";" });
|
|
366
|
+
const word = loop.words[copy];
|
|
367
|
+
for (const entry of body) {
|
|
368
|
+
expanded.push(word === undefined ? entry : substitute(entry, marker, word));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (expanded.length > LOOP_TOKEN_LIMIT) return undefined;
|
|
372
|
+
|
|
373
|
+
index = loop.end;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
expanded.push(token);
|
|
378
|
+
index++;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return expanded;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function substitute(token: ParseEntry, marker: string, word: string): ParseEntry {
|
|
385
|
+
if (typeof token === "string") return token.replaceAll(marker, word);
|
|
386
|
+
return "pattern" in token ? { ...token, pattern: token.pattern.replaceAll(marker, word) } : token;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The literal words of a command, loop variables resolved. Undefined when a reference
|
|
390
|
+
* or a construct cannot be read. */
|
|
391
|
+
export function commandWords(
|
|
392
|
+
command: string,
|
|
393
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
394
|
+
): string[] | undefined {
|
|
395
|
+
if (env.BASH_ENV) return undefined;
|
|
396
|
+
if (hasCommandSubstitution(command)) return undefined;
|
|
397
|
+
|
|
398
|
+
const normalized = normalizeCommand(command);
|
|
399
|
+
if (normalized === undefined) return undefined;
|
|
400
|
+
|
|
401
|
+
let tokens: ParseEntry[];
|
|
402
|
+
try {
|
|
403
|
+
tokens = parse(normalized);
|
|
404
|
+
} catch {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const expanded = expandLoops(tokens);
|
|
409
|
+
if (expanded === undefined) return undefined;
|
|
410
|
+
|
|
411
|
+
const words: string[] = [];
|
|
412
|
+
for (const token of expanded) {
|
|
413
|
+
const word = typeof token === "string" ? token : "pattern" in token ? token.pattern : undefined;
|
|
414
|
+
if (word === undefined) continue;
|
|
415
|
+
if (word.includes(REFERENCE)) return undefined;
|
|
416
|
+
words.push(word);
|
|
417
|
+
}
|
|
418
|
+
return words;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readLoop(tokens: ParseEntry[], start: number): Loop | undefined {
|
|
422
|
+
let index = start + 1;
|
|
423
|
+
|
|
424
|
+
const name = tokens[index];
|
|
425
|
+
if (typeof name !== "string" || name.includes(REFERENCE) || !LOOP_NAME.test(name)) {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
index++;
|
|
429
|
+
|
|
430
|
+
if (tokens[index] !== "in") return undefined;
|
|
431
|
+
index++;
|
|
432
|
+
|
|
433
|
+
const words: string[] = [];
|
|
434
|
+
// Bash reserves `do` only where a separator opened the position.
|
|
435
|
+
while (index < tokens.length) {
|
|
436
|
+
const token = tokens[index];
|
|
437
|
+
if (token === "do" && startsSegment(tokens, index)) break;
|
|
438
|
+
|
|
439
|
+
if (isSemicolon(token)) {
|
|
440
|
+
index++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const word = loopWord(token);
|
|
445
|
+
if (word === undefined) return undefined;
|
|
446
|
+
if (words.push(word) > LOOP_WORD_LIMIT) return undefined;
|
|
447
|
+
index++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (tokens[index] !== "do") return undefined;
|
|
451
|
+
index++;
|
|
452
|
+
|
|
453
|
+
const body: ParseEntry[] = [];
|
|
454
|
+
let nested = 0;
|
|
455
|
+
while (index < tokens.length) {
|
|
456
|
+
const token = tokens[index];
|
|
457
|
+
if (token === undefined) break;
|
|
458
|
+
if (typeof token === "string" && startsSegment(tokens, index)) {
|
|
459
|
+
if (token === "for") nested++;
|
|
460
|
+
if (token === "done") {
|
|
461
|
+
if (nested === 0) break;
|
|
462
|
+
nested--;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
body.push(token);
|
|
466
|
+
index++;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (tokens[index] !== "done") return undefined;
|
|
470
|
+
|
|
471
|
+
return { name, words, body: trimSeparators(body), end: index + 1 };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function loopWord(entry: ParseEntry | undefined): string | undefined {
|
|
475
|
+
if (typeof entry === "string") return isLoopWord(entry) ? entry : undefined;
|
|
476
|
+
if (entry !== undefined && "op" in entry && entry.op === "glob" && isLoopWord(entry.pattern)) {
|
|
477
|
+
return entry.pattern;
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The value is invisible, so the word list stays literal: no references, flags, or
|
|
483
|
+
// shell metacharacters.
|
|
484
|
+
function isLoopWord(word: string): boolean {
|
|
485
|
+
return !word.includes(REFERENCE) && !word.startsWith("-") && LOOP_WORD.test(word);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function trimSeparators(tokens: ParseEntry[]): ParseEntry[] {
|
|
489
|
+
let start = 0;
|
|
490
|
+
let end = tokens.length;
|
|
491
|
+
while (start < end && isSemicolon(tokens[start])) start++;
|
|
492
|
+
while (end > start && isSemicolon(tokens[end - 1])) end--;
|
|
493
|
+
return tokens.slice(start, end);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function hasCommandSubstitution(command: string): boolean {
|
|
497
|
+
let singleQuoted = false;
|
|
498
|
+
|
|
499
|
+
for (let index = 0; index < command.length; index++) {
|
|
500
|
+
const char = command[index];
|
|
501
|
+
if (char === "'") {
|
|
502
|
+
singleQuoted = !singleQuoted;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (singleQuoted) continue;
|
|
506
|
+
if (char === "\\") {
|
|
507
|
+
index++;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (char === "`") return true;
|
|
511
|
+
if (char === "$" && command[index + 1] === "(") return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function operatorOf(entry: ParseEntry | undefined): string | undefined {
|
|
518
|
+
if (entry === undefined || typeof entry === "string" || !("op" in entry)) return undefined;
|
|
519
|
+
return entry.op;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Returns how many following tokens the redirect consumed.
|
|
523
|
+
function safeRedirect(tokens: ParseEntry[], index: number): number | undefined {
|
|
524
|
+
const op = operatorOf(tokens[index]);
|
|
525
|
+
if (op === undefined) return undefined;
|
|
526
|
+
|
|
527
|
+
if (op === "&") {
|
|
528
|
+
const inner = operatorOf(tokens[index + 1]);
|
|
529
|
+
if (inner !== ">" && inner !== ">>") return undefined;
|
|
530
|
+
return tokens[index + 2] === "/dev/null" ? 2 : undefined;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (op === ">" || op === "<") {
|
|
534
|
+
return tokens[index + 1] === "/dev/null" ? 1 : undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (op === ">&") {
|
|
538
|
+
const target = tokens[index + 1];
|
|
539
|
+
return target === "1" || target === "2" ? 1 : undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// `ls 2>/dev/null` parses the descriptor as a plain word before the operator.
|
|
546
|
+
function dropFileDescriptor(segment: string[]): void {
|
|
547
|
+
if (segment.at(-1) === "1" || segment.at(-1) === "2") segment.pop();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function isReadOnlySegment(words: string[], env: NodeJS.ProcessEnv): boolean {
|
|
551
|
+
if (words.some(isSynthesizedFlag)) return false;
|
|
552
|
+
|
|
553
|
+
let start = 0;
|
|
554
|
+
while (words[start] === "!") start++;
|
|
555
|
+
|
|
556
|
+
const name = words[start];
|
|
557
|
+
if (name === undefined) return true;
|
|
558
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(name)) return false;
|
|
559
|
+
if (env[`BASH_FUNC_${name}%%`] !== undefined) return false;
|
|
560
|
+
|
|
561
|
+
const check = ARG_CHECKS[name];
|
|
562
|
+
return check ? check(words.slice(start + 1)) : READ_ONLY.has(name);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// A reference hides its value, so a dash-led word could be a flag the value completes.
|
|
566
|
+
function isSynthesizedFlag(word: string): boolean {
|
|
567
|
+
return word.startsWith("-") && word.includes(REFERENCE);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function safeSed(args: string[]): boolean {
|
|
571
|
+
const scripts: string[] = [];
|
|
572
|
+
let sawScript = false;
|
|
573
|
+
|
|
574
|
+
for (let index = 0; index < args.length; index++) {
|
|
575
|
+
const arg = args[index] ?? "";
|
|
576
|
+
if (/^-[^-]*[if]/.test(arg) || arg.startsWith("--in-place") || arg.startsWith("--file"))
|
|
577
|
+
return false;
|
|
578
|
+
if (arg === "-e" || arg === "--expression") {
|
|
579
|
+
scripts.push(args[++index] ?? "");
|
|
580
|
+
sawScript = true;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (arg.startsWith("--expression=")) {
|
|
584
|
+
scripts.push(arg.slice("--expression=".length));
|
|
585
|
+
sawScript = true;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (arg.startsWith("-")) continue;
|
|
589
|
+
if (sawScript) continue;
|
|
590
|
+
scripts.push(arg);
|
|
591
|
+
sawScript = true;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return scripts.length > 0 && scripts.every(safeSedScript);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const SED_ADDRESS = String.raw`(?:\d+|\$|\/[^/]*\/)`;
|
|
598
|
+
const SED_ADDRESS_RANGE = new RegExp(`^${SED_ADDRESS}(?:,${SED_ADDRESS})?[pd]$`);
|
|
599
|
+
|
|
600
|
+
function safeSedScript(script: string): boolean {
|
|
601
|
+
if (script.includes("{") || script.includes("}")) return false;
|
|
602
|
+
return script.split(/[;\n]/).every((part) => {
|
|
603
|
+
const piece = part.trim();
|
|
604
|
+
if (piece === "") return true;
|
|
605
|
+
if (SED_ADDRESS_RANGE.test(piece)) return true;
|
|
606
|
+
|
|
607
|
+
const substitution = /^s(.)[\s\S]*?\1[\s\S]*?\1([gip0-9]*)$/.exec(piece);
|
|
608
|
+
return substitution !== null && !/[we]/.test(substitution[2] ?? "");
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function safeGit(args: string[]): boolean {
|
|
613
|
+
if (args.some((arg) => isOneOf(arg, GIT_EXEC_FLAGS))) return false;
|
|
614
|
+
|
|
615
|
+
let index = 0;
|
|
616
|
+
while (index < args.length) {
|
|
617
|
+
const arg = args[index] ?? "";
|
|
618
|
+
if (arg === "-C" || arg === "-c") {
|
|
619
|
+
index += 2;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (arg.startsWith("-")) {
|
|
623
|
+
index++;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const check = GIT_SUBCOMMAND_CHECKS[arg];
|
|
627
|
+
return check ? check(args.slice(index + 1)) : GIT_READ_ONLY.has(arg);
|
|
628
|
+
}
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const GIT_SUBCOMMAND_CHECKS: Record<string, (args: string[]) => boolean> = {
|
|
633
|
+
branch: safeGitBranch,
|
|
634
|
+
remote: safeGitRemote,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// `git branch` lists for free but creates, deletes, and renames with the same name,
|
|
638
|
+
// so only the filter flags pass and a bare word passes only after `--list`.
|
|
639
|
+
function safeGitBranch(args: string[]): boolean {
|
|
640
|
+
let lists = false;
|
|
641
|
+
|
|
642
|
+
for (let index = 0; index < args.length; index++) {
|
|
643
|
+
const arg = args[index] ?? "";
|
|
644
|
+
if (arg === "-l" || arg === "--list") lists = true;
|
|
645
|
+
|
|
646
|
+
const [flag, value] = splitFlag(arg);
|
|
647
|
+
if (GIT_BRANCH_VALUE_FLAGS.has(flag)) {
|
|
648
|
+
if (value === undefined) index++;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (GIT_BRANCH_OPTIONAL_VALUE_FLAGS.has(flag)) continue;
|
|
652
|
+
if (GIT_BRANCH_FLAGS.has(arg)) continue;
|
|
653
|
+
if (isShortCluster(arg, GIT_BRANCH_SHORT_LETTERS)) continue;
|
|
654
|
+
if (arg.startsWith("-")) return false;
|
|
655
|
+
if (!lists) return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// `git remote` lists or shows by default; every mutation is its own subcommand.
|
|
662
|
+
function safeGitRemote(args: string[]): boolean {
|
|
663
|
+
let index = 0;
|
|
664
|
+
while (index < args.length && GIT_REMOTE_FLAGS.has(args[index] ?? "")) index++;
|
|
665
|
+
if (index === args.length) return true;
|
|
666
|
+
return GIT_REMOTE_SUBCOMMANDS.has(args[index] ?? "");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function splitFlag(arg: string): [string, string | undefined] {
|
|
670
|
+
const equals = arg.indexOf("=");
|
|
671
|
+
if (equals < 0) return [arg, undefined];
|
|
672
|
+
return [arg.slice(0, equals), arg.slice(equals + 1)];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isShortCluster(arg: string, letters: Set<string>): boolean {
|
|
676
|
+
if (!/^-[^-]/.test(arg)) return false;
|
|
677
|
+
for (const letter of arg.slice(1)) {
|
|
678
|
+
if (!letters.has(letter)) return false;
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function isOutputFlag(arg: string): boolean {
|
|
684
|
+
return arg === "--output" || arg.startsWith("--output=") || /^-[^-]*o/.test(arg);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function isOneOf(arg: string, values: string[]): boolean {
|
|
688
|
+
return values.some((value) => arg === value || arg.startsWith(`${value}=`));
|
|
689
|
+
}
|