opencode-workflow-guard 1.18.1 → 1.19.1
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/README.md +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +31 -3
- package/docs/testing.md +3 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +134 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +301 -2
- package/src/lib/types.ts +71 -1
- package/src/lib/v2-plugin.ts +32 -1
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +56 -5
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +1286 -20
package/src/lib/shell.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { homedir, userInfo } from "node:os";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
1
4
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
2
5
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3
6
|
return undefined;
|
|
@@ -193,6 +196,290 @@ function envSplitWords(value: string): string[] {
|
|
|
193
196
|
return words;
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
export interface HeredocRange {
|
|
200
|
+
/** Offset of the `<<` that introduces the heredoc. */
|
|
201
|
+
start: number;
|
|
202
|
+
/** Offset of the body's first byte. */
|
|
203
|
+
bodyStart: number;
|
|
204
|
+
/** Offset of the terminator line, or end of input when unterminated. */
|
|
205
|
+
bodyEnd: number;
|
|
206
|
+
/** Offset of the terminator line's newline, or end of input. */
|
|
207
|
+
end: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface HeredocHeader {
|
|
211
|
+
start: number;
|
|
212
|
+
/** Offset just past the delimiter word. */
|
|
213
|
+
end: number;
|
|
214
|
+
delimiter: string;
|
|
215
|
+
stripTabs: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function heredocHeaderAt(command: string, start: number): HeredocHeader | undefined {
|
|
219
|
+
// `<<<` is a herestring, whose operand is a single word on the same line.
|
|
220
|
+
if (command[start + 2] === "<") return undefined;
|
|
221
|
+
const header = /^<<(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'";|&()<>\n]+))/.exec(command.slice(start));
|
|
222
|
+
if (!header) return undefined;
|
|
223
|
+
const delimiter = header[2] ?? header[3] ?? header[4];
|
|
224
|
+
if (!delimiter) return undefined;
|
|
225
|
+
return { start, end: start + header[0].length, delimiter, stripTabs: header[1] === "-" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function heredocBodyAt(
|
|
229
|
+
command: string,
|
|
230
|
+
from: number,
|
|
231
|
+
delimiter: string,
|
|
232
|
+
stripTabs: boolean,
|
|
233
|
+
): { bodyStart: number; bodyEnd: number; end: number } {
|
|
234
|
+
let cursor = from;
|
|
235
|
+
while (cursor <= command.length) {
|
|
236
|
+
const nextEnd = command.indexOf("\n", cursor);
|
|
237
|
+
const line = command.slice(cursor, nextEnd < 0 ? command.length : nextEnd);
|
|
238
|
+
const comparable = stripTabs ? line.replace(/^\t+/, "") : line;
|
|
239
|
+
if (comparable === delimiter) {
|
|
240
|
+
// Stop at the terminator's own newline and let the caller split on it,
|
|
241
|
+
// so the command that follows the heredoc starts its own segment.
|
|
242
|
+
return { bodyStart: from, bodyEnd: cursor, end: nextEnd < 0 ? command.length : nextEnd };
|
|
243
|
+
}
|
|
244
|
+
if (nextEnd < 0) break;
|
|
245
|
+
cursor = nextEnd + 1;
|
|
246
|
+
}
|
|
247
|
+
// A missing terminator runs to end of input, which is where the shell would
|
|
248
|
+
// stop reading too. Bash still runs the body it collected.
|
|
249
|
+
return { bodyStart: from, bodyEnd: command.length, end: command.length };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every heredoc introduced on the line that starts at `start`.
|
|
254
|
+
*
|
|
255
|
+
* One header line can introduce several bodies (`cat <<A <<B`), and the shell
|
|
256
|
+
* reads them one after another. Ignoring the later ones read a second body as
|
|
257
|
+
* top-level commands.
|
|
258
|
+
*/
|
|
259
|
+
export function heredocRunAt(command: string, start: number): HeredocRange[] | undefined {
|
|
260
|
+
const headers: HeredocHeader[] = [];
|
|
261
|
+
let cursor = start;
|
|
262
|
+
for (;;) {
|
|
263
|
+
const header = heredocHeaderAt(command, cursor);
|
|
264
|
+
if (!header) break;
|
|
265
|
+
headers.push(header);
|
|
266
|
+
let next = header.end;
|
|
267
|
+
while (next < command.length && /[ \t]/.test(command[next]!)) next += 1;
|
|
268
|
+
if (command[next] !== "<" || command[next + 1] !== "<") break;
|
|
269
|
+
cursor = next;
|
|
270
|
+
}
|
|
271
|
+
if (headers.length === 0) return undefined;
|
|
272
|
+
const lastHeader = headers[headers.length - 1]!;
|
|
273
|
+
const lineEnd = command.indexOf("\n", lastHeader.end);
|
|
274
|
+
if (lineEnd < 0) return undefined;
|
|
275
|
+
const ranges: HeredocRange[] = [];
|
|
276
|
+
let bodyStart = lineEnd + 1;
|
|
277
|
+
for (const header of headers) {
|
|
278
|
+
const body = heredocBodyAt(command, bodyStart, header.delimiter, header.stripTabs);
|
|
279
|
+
ranges.push({ start: header.start, ...body });
|
|
280
|
+
if (body.end >= command.length) break;
|
|
281
|
+
bodyStart = body.end + 1;
|
|
282
|
+
}
|
|
283
|
+
return ranges;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Every heredoc in `command`, found with the same quote tracking the segment
|
|
288
|
+
* splitter uses.
|
|
289
|
+
*
|
|
290
|
+
* A `<<` inside a quoted span opens no heredoc. Scanning for `<<` without that
|
|
291
|
+
* check read a quoted `"<<"` as a header and masked the code that followed it.
|
|
292
|
+
*/
|
|
293
|
+
export function heredocRanges(command: string): HeredocRange[] {
|
|
294
|
+
const ranges: HeredocRange[] = [];
|
|
295
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
296
|
+
let escaped = false;
|
|
297
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
298
|
+
const char = command[i]!;
|
|
299
|
+
if (escaped) {
|
|
300
|
+
escaped = false;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
304
|
+
quote = "ansi";
|
|
305
|
+
i += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (char === "\\" && quote !== "'") {
|
|
309
|
+
escaped = true;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (quote === "ansi" && char === "'") {
|
|
313
|
+
quote = undefined;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (char === "'" || char === '"') {
|
|
317
|
+
if (!quote) quote = char;
|
|
318
|
+
else if (quote === char) quote = undefined;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (quote || char !== "<" || command[i + 1] !== "<") continue;
|
|
322
|
+
const run = heredocRunAt(command, i);
|
|
323
|
+
if (!run) continue;
|
|
324
|
+
ranges.push(...run);
|
|
325
|
+
// Lines inside a body are data, so a `<<` there opens nothing.
|
|
326
|
+
i = run[run.length - 1]!.end - 1;
|
|
327
|
+
}
|
|
328
|
+
return ranges;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The shells and interpreters that run a heredoc body as code.
|
|
333
|
+
*
|
|
334
|
+
* `source` and the `.` builtin execute a body they read from stdin, so their
|
|
335
|
+
* bodies are code too.
|
|
336
|
+
*/
|
|
337
|
+
const HEREDOC_EXECUTOR_RE = /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh|source)\b|^\s*\.\s/i;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Commands that read a heredoc body as data.
|
|
341
|
+
*
|
|
342
|
+
* The body of these is never executed, so its lines are prose. An unrecognized
|
|
343
|
+
* command is treated as an executor instead: guessing "data" there would let a
|
|
344
|
+
* body a shell reads as code escape the scan.
|
|
345
|
+
*
|
|
346
|
+
* `sed`, `awk`, and `sort` are deliberately absent. Each can run a command named
|
|
347
|
+
* inside its own arguments (`sed 'e'`, `awk '{system($0)}'`,
|
|
348
|
+
* `sort --compress-program`), so a body behind one of them is executed text.
|
|
349
|
+
*/
|
|
350
|
+
const DATA_HEREDOC_CONSUMERS = new Set([
|
|
351
|
+
"cat",
|
|
352
|
+
"tee",
|
|
353
|
+
"echo",
|
|
354
|
+
"printf",
|
|
355
|
+
"grep",
|
|
356
|
+
"egrep",
|
|
357
|
+
"fgrep",
|
|
358
|
+
"uniq",
|
|
359
|
+
"wc",
|
|
360
|
+
"head",
|
|
361
|
+
"tail",
|
|
362
|
+
"tr",
|
|
363
|
+
"cut",
|
|
364
|
+
"patch",
|
|
365
|
+
"diff",
|
|
366
|
+
"xxd",
|
|
367
|
+
"od",
|
|
368
|
+
"base64",
|
|
369
|
+
"sha1sum",
|
|
370
|
+
"sha256sum",
|
|
371
|
+
"md5sum",
|
|
372
|
+
"jq",
|
|
373
|
+
"column",
|
|
374
|
+
"less",
|
|
375
|
+
"more",
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
* A program that can run a command named in its own arguments is not a data
|
|
380
|
+
* consumer. Testing the whole header line matched a word inside a quoted
|
|
381
|
+
* argument, so `sed -n '1e echo hi'` read as data because of the `echo` in its
|
|
382
|
+
* script.
|
|
383
|
+
*/
|
|
384
|
+
function consumerWords(before: string): string[] {
|
|
385
|
+
// Only the command the heredoc belongs to reads the body, so take the last
|
|
386
|
+
// segment. Earlier commands on the same line never see it.
|
|
387
|
+
const segments = splitShellSegments(before);
|
|
388
|
+
return unwrapShellWords(segments[segments.length - 1] ?? "");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The program that reads the body, or `undefined` when no word names one. */
|
|
392
|
+
function consumerProgram(before: string): { head: string; sub?: string } | undefined {
|
|
393
|
+
const words = consumerWords(before);
|
|
394
|
+
const head = words[0]?.toLowerCase();
|
|
395
|
+
if (!head) return undefined;
|
|
396
|
+
return { head, sub: words[1]?.toLowerCase() };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Whether a program reads a heredoc body as data rather than executing it. */
|
|
400
|
+
function readsBodyAsData(program: { head: string; sub?: string }): boolean {
|
|
401
|
+
if (program.head === "git") return program.sub === "commit" || program.sub === "tag";
|
|
402
|
+
return DATA_HEREDOC_CONSUMERS.has(program.head);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The command word of every stage a header line pipes the body into.
|
|
407
|
+
*
|
|
408
|
+
* A pipeline hands the body to every stage, so `| grep x | sh` still ends in a
|
|
409
|
+
* shell. Only the first stage used to be read, and that masked the body. A `||`
|
|
410
|
+
* is a logical separator and runs nothing on success, so it is skipped, and a
|
|
411
|
+
* quoted `|` is an argument character rather than a pipe. `|&` is a real pipe
|
|
412
|
+
* that also redirects stderr, so only the first of its two characters is a pipe
|
|
413
|
+
* separator.
|
|
414
|
+
*/
|
|
415
|
+
function pipedStageHeads(text: string): string[] {
|
|
416
|
+
const heads: string[] = [];
|
|
417
|
+
let quote: "'" | '"' | undefined;
|
|
418
|
+
let escaped = false;
|
|
419
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
420
|
+
const char = text[i]!;
|
|
421
|
+
if (escaped) {
|
|
422
|
+
escaped = false;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (char === "\\" && quote !== "'") {
|
|
426
|
+
escaped = true;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (char === "'" || char === '"') {
|
|
430
|
+
if (!quote) quote = char;
|
|
431
|
+
else if (quote === char) quote = undefined;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (quote || char !== "|") continue;
|
|
435
|
+
// `||` runs the right side only on failure and hands it no body. `|&` is a
|
|
436
|
+
// real pipe that also redirects stderr, so it is read as one pipe stage.
|
|
437
|
+
if (text[i + 1] === "|") {
|
|
438
|
+
i += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const stageStart = text[i + 1] === "&" ? i + 2 : i + 1;
|
|
442
|
+
heads.push(shellWords(text.slice(stageStart))[0] ?? "");
|
|
443
|
+
}
|
|
444
|
+
return heads;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Whether the command on a heredoc's header line executes its body. */
|
|
448
|
+
export function heredocBodyIsCode(command: string, start: number): boolean {
|
|
449
|
+
const lineStart = command.lastIndexOf("\n", start - 1) + 1;
|
|
450
|
+
const lineEnd = command.indexOf("\n", start);
|
|
451
|
+
const line = command.slice(lineStart, lineEnd < 0 ? command.length : lineEnd);
|
|
452
|
+
const before = command.slice(lineStart, start);
|
|
453
|
+
if (HEREDOC_EXECUTOR_RE.test(before)) return true;
|
|
454
|
+
// Every piped stage is a consumer. Treating an unknown one as a filter let
|
|
455
|
+
// `cat <<EOF | ash` and `cat <<EOF | grep x | sh` run unread.
|
|
456
|
+
for (const head of pipedStageHeads(line.slice(start - lineStart))) {
|
|
457
|
+
if (!DATA_HEREDOC_CONSUMERS.has(head.toLowerCase())) return true;
|
|
458
|
+
}
|
|
459
|
+
const program = consumerProgram(before);
|
|
460
|
+
return !program || !readsBodyAsData(program);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* `command` with every heredoc body that no command executes replaced by
|
|
465
|
+
* spaces.
|
|
466
|
+
*
|
|
467
|
+
* A body feeding `cat` is prose, so a line in it that looks like a command must
|
|
468
|
+
* not be judged as one. Newlines are preserved so line-oriented callers still
|
|
469
|
+
* see the same line structure.
|
|
470
|
+
*/
|
|
471
|
+
export function maskDataHeredocBodies(command: string): string {
|
|
472
|
+
let out = "";
|
|
473
|
+
let cursor = 0;
|
|
474
|
+
for (const range of heredocRanges(command)) {
|
|
475
|
+
if (heredocBodyIsCode(command, range.start)) continue;
|
|
476
|
+
out += command.slice(cursor, range.bodyStart);
|
|
477
|
+
out += command.slice(range.bodyStart, range.bodyEnd).replace(/[^\n]/g, " ");
|
|
478
|
+
cursor = range.bodyEnd;
|
|
479
|
+
}
|
|
480
|
+
return out + command.slice(cursor);
|
|
481
|
+
}
|
|
482
|
+
|
|
196
483
|
export function splitShellSegments(command: string): string[] {
|
|
197
484
|
const segments: string[] = [];
|
|
198
485
|
let current = "";
|
|
@@ -227,6 +514,26 @@ export function splitShellSegments(command: string): string[] {
|
|
|
227
514
|
current += char;
|
|
228
515
|
continue;
|
|
229
516
|
}
|
|
517
|
+
// A heredoc body belongs to the segment that introduces it. Splitting it
|
|
518
|
+
// on its newlines made data read as code, so documentation quoting a
|
|
519
|
+
// blocked command shape was refused, and a code body's own lines looked
|
|
520
|
+
// like top-level commands.
|
|
521
|
+
//
|
|
522
|
+
// One header line can introduce several heredocs (`cat <<A <<B`), and the
|
|
523
|
+
// shell reads their bodies one after another. Consume the whole run here,
|
|
524
|
+
// so a later body is not read as top-level commands.
|
|
525
|
+
if (!quote && char === "<" && command[i + 1] === "<") {
|
|
526
|
+
const run = heredocRunAt(command, i);
|
|
527
|
+
if (run) {
|
|
528
|
+
let cursor = i;
|
|
529
|
+
for (const range of run) {
|
|
530
|
+
current += command.slice(cursor, range.end);
|
|
531
|
+
cursor = range.end;
|
|
532
|
+
}
|
|
533
|
+
i = cursor - 1;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
230
537
|
if (!quote && /[\n|;&]/.test(char)) {
|
|
231
538
|
if (current.trim()) segments.push(current.trim());
|
|
232
539
|
current = "";
|
|
@@ -238,7 +545,7 @@ export function splitShellSegments(command: string): string[] {
|
|
|
238
545
|
return segments;
|
|
239
546
|
}
|
|
240
547
|
|
|
241
|
-
function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
|
|
548
|
+
function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean; cwdTarget?: string; assignments: string[] } {
|
|
242
549
|
let cleanCmd = decodeShellEscapes(command.trim());
|
|
243
550
|
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
244
551
|
while (
|
|
@@ -252,6 +559,16 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
252
559
|
const words = shellWords(cleanCmd);
|
|
253
560
|
let i = 0;
|
|
254
561
|
let changesCwd = false;
|
|
562
|
+
// Directory a wrapper (`env -C`, `sudo -D`) runs its command in. Recorded
|
|
563
|
+
// so a caller can judge a nested command in the tree it actually runs in
|
|
564
|
+
// instead of the shell's initial workdir.
|
|
565
|
+
let cwdTarget: string | undefined;
|
|
566
|
+
// Environment assignments consumed while unwrapping wrappers. They apply to
|
|
567
|
+
// the command that follows, so callers must not read them out of the command
|
|
568
|
+
// arguments instead: `git commit -m GIT_DIR=/repo` is message text, not an
|
|
569
|
+
// assignment, and treating it as one would claim a repository that the
|
|
570
|
+
// invocation never named.
|
|
571
|
+
const assignments: string[] = [];
|
|
255
572
|
while (i < words.length) {
|
|
256
573
|
const rawWord = words[i]!;
|
|
257
574
|
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
@@ -337,7 +654,12 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
337
654
|
]);
|
|
338
655
|
while (i < words.length && words[i]!.startsWith("-")) {
|
|
339
656
|
const option = words[i++]!;
|
|
340
|
-
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir="))
|
|
657
|
+
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) {
|
|
658
|
+
changesCwd = true;
|
|
659
|
+
// The target is the next word unless it was attached (-Ddir).
|
|
660
|
+
if (option === "-D" || option === "--chdir") cwdTarget = words[i];
|
|
661
|
+
else cwdTarget = option.replace(/^-D|^--chdir=/, "");
|
|
662
|
+
}
|
|
341
663
|
if (valueOptions.has(option) && i < words.length) i++;
|
|
342
664
|
}
|
|
343
665
|
continue;
|
|
@@ -360,6 +682,7 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
360
682
|
break;
|
|
361
683
|
}
|
|
362
684
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
685
|
+
assignments.push(w);
|
|
363
686
|
i++;
|
|
364
687
|
continue;
|
|
365
688
|
}
|
|
@@ -369,7 +692,8 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
369
692
|
if (clustered?.[1] === "C") {
|
|
370
693
|
changesCwd = true;
|
|
371
694
|
i++;
|
|
372
|
-
if (
|
|
695
|
+
if (clustered[2]) cwdTarget = clustered[2];
|
|
696
|
+
else if (i < words.length) cwdTarget = words[i++];
|
|
373
697
|
continue;
|
|
374
698
|
}
|
|
375
699
|
if (clustered?.[1] === "S") {
|
|
@@ -397,17 +721,24 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
397
721
|
}
|
|
398
722
|
if (w.startsWith("-C") && w !== "-C") {
|
|
399
723
|
changesCwd = true;
|
|
724
|
+
cwdTarget = w.slice(2);
|
|
400
725
|
i++;
|
|
401
726
|
continue;
|
|
402
727
|
}
|
|
403
728
|
if (!w.startsWith("-")) break;
|
|
404
729
|
i++;
|
|
405
|
-
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir="))
|
|
730
|
+
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) {
|
|
731
|
+
changesCwd = true;
|
|
732
|
+
if (w === "--chdir") cwdTarget = words[i];
|
|
733
|
+
else if (w.startsWith("--chdir=")) cwdTarget = w.slice("--chdir=".length);
|
|
734
|
+
else cwdTarget = words[i];
|
|
735
|
+
}
|
|
406
736
|
if (valueOptions.has(w) && i < words.length) i++;
|
|
407
737
|
}
|
|
408
738
|
continue;
|
|
409
739
|
}
|
|
410
740
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
741
|
+
assignments.push(word);
|
|
411
742
|
i++;
|
|
412
743
|
continue;
|
|
413
744
|
}
|
|
@@ -416,13 +747,379 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
|
|
|
416
747
|
return { words: words
|
|
417
748
|
.slice(i)
|
|
418
749
|
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
419
|
-
.filter(Boolean), changesCwd };
|
|
750
|
+
.filter(Boolean), changesCwd, cwdTarget, assignments };
|
|
420
751
|
}
|
|
421
752
|
|
|
422
753
|
export function unwrapShellWords(command: string): string[] {
|
|
423
754
|
return unwrapShellWordsImpl(command).words;
|
|
424
755
|
}
|
|
425
756
|
|
|
757
|
+
/**
|
|
758
|
+
* The directory a wrapper (`env -C`, `sudo -D`) runs its command in, if it
|
|
759
|
+
* names one. The target is left unexpanded: the caller knows the directory it
|
|
760
|
+
* is relative to.
|
|
761
|
+
*/
|
|
762
|
+
export function shellWrapperCwd(command: string): string | undefined {
|
|
763
|
+
return unwrapShellWordsImpl(command).cwdTarget;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* One shell segment and the directory it runs in.
|
|
768
|
+
*
|
|
769
|
+
* `root` is `undefined` when the walk cannot determine it: an unresolvable
|
|
770
|
+
* `cd` target (`cd $DIR`, `cd -`), a `pushd` that swaps the directory stack,
|
|
771
|
+
* or a `popd` with no tracked push. Callers treat that as indeterminate
|
|
772
|
+
* instead of assuming the session's checkout, because that assumption is how a
|
|
773
|
+
* `cd` into another tree used to hide a mutation from the boundary policy.
|
|
774
|
+
*/
|
|
775
|
+
export interface ShellSegmentPlan {
|
|
776
|
+
segment: string;
|
|
777
|
+
root: string | undefined;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const CWD_WORDS = new Set(["cd", "pushd", "popd"]);
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The directory a git repository selector names.
|
|
784
|
+
*
|
|
785
|
+
* `git -C ~/repo` reaches a directory through the shell's own expansion, so a
|
|
786
|
+
* literal `~` must not be read as a workspace-relative path. Only the forms a
|
|
787
|
+
* shell expands deterministically are expanded here. Anything else keeps its
|
|
788
|
+
* literal text, so an unresolvable `$VARIABLE` still fails closed through
|
|
789
|
+
* `hasUnresolvableVariable`.
|
|
790
|
+
*/
|
|
791
|
+
export function resolveGitRepoSelector(baseDir: string, value: string): string {
|
|
792
|
+
return resolve(baseDir, expandCwdTarget(value) ?? value);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Expands only the directory forms a shell expands deterministically. */
|
|
796
|
+
export function expandCwdTarget(target: string): string | undefined {
|
|
797
|
+
const home = process.env.HOME || homedir();
|
|
798
|
+
if (target === "~") return home;
|
|
799
|
+
if (target.startsWith("~/")) return join(home, target.slice(2));
|
|
800
|
+
// `cd ~user` resolves to that user's home, never to a literal `~user`
|
|
801
|
+
// directory. Reading it as a relative path put a `cd ~csh` walk inside the
|
|
802
|
+
// workspace while the shell reached the real home. The path after the name
|
|
803
|
+
// is part of the target, so dropping it judged the wrong directory.
|
|
804
|
+
const named = /^~([^/\s]+)(\/.*)?$/.exec(target);
|
|
805
|
+
if (named) {
|
|
806
|
+
const user = named[1]!;
|
|
807
|
+
const tail = named[2] ?? "";
|
|
808
|
+
let base: string;
|
|
809
|
+
try {
|
|
810
|
+
// The walk cannot read another user's home portably, so it joins the name
|
|
811
|
+
// onto this home's parent. That is the same heuristic `expandShellTargetPath`
|
|
812
|
+
// uses, which keeps both scanners agreeing on one directory. A guess that
|
|
813
|
+
// lands inside the workspace arrives here only when the workspace already
|
|
814
|
+
// spans the home parent, the same exposure the boundary scan has.
|
|
815
|
+
base = user === userInfo().username ? home : join(dirname(home), user);
|
|
816
|
+
} catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
return tail === "" ? base : join(base, tail.slice(1));
|
|
820
|
+
}
|
|
821
|
+
if (/^\$(?:HOME|\{HOME\})(?:$|[/\\])/.test(target)) {
|
|
822
|
+
return target.replace(/^\$(?:HOME|\{HOME\})/, home);
|
|
823
|
+
}
|
|
824
|
+
if (target.includes("$")) return undefined;
|
|
825
|
+
return target;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Words that carry no directory of their own; a control-flow body follows them. */
|
|
829
|
+
const TRANSPARENT_PREFIX = new Set([
|
|
830
|
+
"builtin",
|
|
831
|
+
"command",
|
|
832
|
+
"then",
|
|
833
|
+
"else",
|
|
834
|
+
"elif",
|
|
835
|
+
"do",
|
|
836
|
+
"if",
|
|
837
|
+
"while",
|
|
838
|
+
"until",
|
|
839
|
+
"for",
|
|
840
|
+
"!",
|
|
841
|
+
"time",
|
|
842
|
+
]);
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* A `pushd` operand that rotates or only pushes the stack.
|
|
846
|
+
*
|
|
847
|
+
* `pushd +N`/`-N` swaps entries, and `-n` pushes without changing directory.
|
|
848
|
+
* The walk tracks a single saved directory, so it cannot model either. Both are
|
|
849
|
+
* reported as an unchecked change, which fails closed for a mutation.
|
|
850
|
+
*/
|
|
851
|
+
function pushdRotation(operand: string): boolean {
|
|
852
|
+
return /^[+-]\d+$/.test(operand) || operand === "-n" || /^-n./.test(operand);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function cwdChangeIn(segment: string, insideCase = false): { word: string; target?: string; unmodeled?: boolean } | undefined {
|
|
856
|
+
const words = unwrapShellWords(segment);
|
|
857
|
+
let i = 0;
|
|
858
|
+
if (insideCase) {
|
|
859
|
+
// `case x in a) cd /other;; esac` arrives as `a) cd /other`, and
|
|
860
|
+
// `unwrapShellWords` has already stripped the pattern's closing paren.
|
|
861
|
+
// The pattern word runs nothing, so the command after it is the body.
|
|
862
|
+
while (i < words.length && !CWD_WORDS.has(words[i]!) && !TRANSPARENT_PREFIX.has(words[i]!) && words[i] !== "case") i += 1;
|
|
863
|
+
}
|
|
864
|
+
while (i < words.length) {
|
|
865
|
+
const word = words[i]!;
|
|
866
|
+
if (TRANSPARENT_PREFIX.has(word)) {
|
|
867
|
+
i += 1;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
// `case` opens an arm wherever it appears, including after `then`, `do`,
|
|
871
|
+
// or `else`: the pattern is one word after `in`, and the body follows the
|
|
872
|
+
// pattern. Treating only a leading `case` as an arm hid `then case x in x)
|
|
873
|
+
// cd <other>`, so the `cd` was never seen.
|
|
874
|
+
if (word === "case") {
|
|
875
|
+
const inIndex = words.indexOf("in", i);
|
|
876
|
+
i = inIndex >= 0 ? inIndex + 2 : words.length;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (CWD_WORDS.has(word)) break;
|
|
880
|
+
// A case pattern closes with `)`, and the command after it runs here.
|
|
881
|
+
if (word.endsWith(")") || word === "}") {
|
|
882
|
+
i += 1;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
const word = words[i];
|
|
888
|
+
if (!word || !CWD_WORDS.has(word)) return undefined;
|
|
889
|
+
if (word === "popd") {
|
|
890
|
+
// `popd +N`/`-N` rotate the stack instead of popping it, which this walk
|
|
891
|
+
// does not model. A plain `popd` consumes the top entry.
|
|
892
|
+
return words.slice(i + 1).some((operand) => pushdRotation(operand)) ? { word, unmodeled: true } : { word };
|
|
893
|
+
}
|
|
894
|
+
let target: string | undefined;
|
|
895
|
+
let unmodeled = false;
|
|
896
|
+
for (let j = i + 1; j < words.length; j += 1) {
|
|
897
|
+
const candidate = words[j]!;
|
|
898
|
+
if (candidate === "--") continue;
|
|
899
|
+
if (word === "pushd" && pushdRotation(candidate)) {
|
|
900
|
+
unmodeled = true;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
// `cd -` moves to OLDPWD, which this walk does not track.
|
|
904
|
+
if (candidate === "-") return { word, target: undefined };
|
|
905
|
+
if (candidate.startsWith("-")) continue;
|
|
906
|
+
target = candidate;
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
if (unmodeled) return { word, unmodeled: true };
|
|
910
|
+
if (target !== undefined) return { word, target };
|
|
911
|
+
// A bare `cd` moves to $HOME, a known directory. A bare `pushd` swaps the
|
|
912
|
+
// top two entries of the directory stack, which is not tracked here.
|
|
913
|
+
return { word, target: word === "cd" ? "~" : undefined };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Unquoted parenthesis counts in a segment.
|
|
918
|
+
*
|
|
919
|
+
* `splitShellSegments` splits on `&&`/`;`/`|` but not on grouping, so a
|
|
920
|
+
* subshell arrives as segments that still carry its parens. A `cd` inside one
|
|
921
|
+
* must not move the directory of the segments after the subshell closes.
|
|
922
|
+
*
|
|
923
|
+
* Inside a `case` body a pattern's own `)` is not a grouping close, so it is
|
|
924
|
+
* not counted while a case construct is open.
|
|
925
|
+
*/
|
|
926
|
+
function groupParensIn(segment: string, insideCase = false): { opens: number; closes: number } {
|
|
927
|
+
// Which characters sit outside quotes, so a `)` inside a string is never a
|
|
928
|
+
// group or pattern terminator.
|
|
929
|
+
const unquoted: boolean[] = [];
|
|
930
|
+
let quote: "'" | '"' | undefined;
|
|
931
|
+
let escaped = false;
|
|
932
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
933
|
+
const char = segment[i]!;
|
|
934
|
+
let bare = false;
|
|
935
|
+
if (escaped) escaped = false;
|
|
936
|
+
else if (char === "\\" && quote !== "'") escaped = true;
|
|
937
|
+
else if (char === "'" || char === '"') {
|
|
938
|
+
if (!quote) quote = char;
|
|
939
|
+
else if (quote === char) quote = undefined;
|
|
940
|
+
} else if (!quote) bare = true;
|
|
941
|
+
unquoted.push(bare);
|
|
942
|
+
}
|
|
943
|
+
// A `case` arm pattern ends with `)`, which closes no group. An arm can open
|
|
944
|
+
// in this segment (`case x in x) …`) or arrive already open (`x) …`), so both
|
|
945
|
+
// forms are recognised. Missing the in-segment form let a pattern's paren pop
|
|
946
|
+
// a subshell frame, which then swallowed the real closing paren.
|
|
947
|
+
const patternCloses = new Set<number>();
|
|
948
|
+
const spelled = unquoted.map((bare, i) => (bare ? segment[i]! : " ")).join("");
|
|
949
|
+
if (/[^\s]/.test(spelled)) {
|
|
950
|
+
for (const head of spelled.matchAll(/\bcase\b[^;|&]*?\bin\b/g)) {
|
|
951
|
+
let from = (head.index ?? 0) + head[0].length;
|
|
952
|
+
while (from < spelled.length) {
|
|
953
|
+
const close = spelled.indexOf(")", from);
|
|
954
|
+
if (close < 0) break;
|
|
955
|
+
patternCloses.add(close);
|
|
956
|
+
const nextArm = spelled.indexOf(";;", close);
|
|
957
|
+
if (nextArm < 0) break;
|
|
958
|
+
from = nextArm + 2;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
let opens = 0;
|
|
963
|
+
let closes = 0;
|
|
964
|
+
// At segment start, `esac )` carries a real group close, not an arm pattern.
|
|
965
|
+
// Reading it as a pattern left the surrounding subshell open, so the segment
|
|
966
|
+
// after it inherited the arm's directory instead of the outer one.
|
|
967
|
+
const startsAtClose = /^\s*(?:esac|fi|done)\b/.test(segment);
|
|
968
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
969
|
+
if (!unquoted[i]) continue;
|
|
970
|
+
const char = segment[i]!;
|
|
971
|
+
if (char === "(") opens += 1;
|
|
972
|
+
else if (char === ")") {
|
|
973
|
+
if (patternCloses.has(i)) continue;
|
|
974
|
+
// An arm pattern that was already open when this segment began ends
|
|
975
|
+
// here. It closes no group.
|
|
976
|
+
if (insideCase && !startsAtClose && closes + 1 > opens) continue;
|
|
977
|
+
closes += 1;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return { opens, closes };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Which construct boundary a segment carries, if any.
|
|
985
|
+
*
|
|
986
|
+
* Only a keyword in command position counts: `echo done` and
|
|
987
|
+
* `git commit -m "if"` carry no construct boundary.
|
|
988
|
+
*/
|
|
989
|
+
function constructKeywordIn(segment: string): "open" | "close" | "case-open" | "case-close" | undefined {
|
|
990
|
+
const words = unwrapShellWords(segment);
|
|
991
|
+
let i = 0;
|
|
992
|
+
// Wrappers and negation can precede the keyword: `time if …`, `! case …`.
|
|
993
|
+
while (i < words.length && (words[i] === "builtin" || words[i] === "command" || words[i] === "time" || words[i] === "!")) i += 1;
|
|
994
|
+
// A body opener can precede a nested construct: `then case …`, `do case …`.
|
|
995
|
+
// Without this the nested `case` opened no frame, so its `esac` popped the
|
|
996
|
+
// enclosing `if`/`for` frame instead and the arm's `cd` was never recorded.
|
|
997
|
+
// `if`/`while`/`until`/`for` stay out of this list, because they are the
|
|
998
|
+
// openers this function reports.
|
|
999
|
+
while (i < words.length && (words[i] === "then" || words[i] === "else" || words[i] === "do" || words[i] === "elif")) i += 1;
|
|
1000
|
+
const word = words[i];
|
|
1001
|
+
if (word === "if" || word === "while" || word === "until" || word === "for") return "open";
|
|
1002
|
+
if (word === "case") return "case-open";
|
|
1003
|
+
if (word === "esac") return "case-close";
|
|
1004
|
+
if (word === "fi" || word === "done") return "close";
|
|
1005
|
+
return undefined;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Whether a segment runs `eval` as its command word.
|
|
1010
|
+
*
|
|
1011
|
+
* Only a leading `eval` runs its body in this shell. A prose mention
|
|
1012
|
+
* (`Use eval "…" to show the bypass`) names no command, and forwarding it made
|
|
1013
|
+
* a documentation heredoc look like a directory change. The wrappers that do not
|
|
1014
|
+
* fork (`builtin`, `command`, `time`) and a leading assignment (`A=1 eval …`)
|
|
1015
|
+
* both still reach `eval`, so they are skipped here.
|
|
1016
|
+
*/
|
|
1017
|
+
export function isLeadingEvalSegment(segment: string): boolean {
|
|
1018
|
+
const words = shellWords(segment);
|
|
1019
|
+
let i = 0;
|
|
1020
|
+
while (i < words.length) {
|
|
1021
|
+
const word = words[i]!;
|
|
1022
|
+
if (word === "builtin" || word === "command" || word === "time") {
|
|
1023
|
+
i += 1;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
1027
|
+
i += 1;
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
return words[i] === "eval";
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Walk a multi-segment command and resolve the directory each segment runs in.
|
|
1037
|
+
*
|
|
1038
|
+
* Later segments run in the directory an earlier `cd`/`pushd` selected, and a
|
|
1039
|
+
* wrapper `-C`/`-D` moves only its own command. Judging every segment against
|
|
1040
|
+
* the shell's initial `workdir` let `cd <other-tree> && git commit` be checked
|
|
1041
|
+
* as if it happened in the session's own checkout.
|
|
1042
|
+
*
|
|
1043
|
+
* A `cd` inside a grouping subshell is scoped to it, so the segments after the
|
|
1044
|
+
* closing paren keep the outer directory. Without that, `( cd /other && ls )
|
|
1045
|
+
* && rm -rf dist` would read the removal as happening in `/other`.
|
|
1046
|
+
*
|
|
1047
|
+
* A `cd` inside a conditional or loop body is NOT scoped: that body runs in the
|
|
1048
|
+
* current shell. Which body runs depends on a condition this walk cannot
|
|
1049
|
+
* evaluate, so a construct whose bodies change directory leaves the later
|
|
1050
|
+
* directory undetermined rather than letting the last body parsed win.
|
|
1051
|
+
*/
|
|
1052
|
+
export function planShellSegments(command: string, baseDir: string | undefined, evalBody?: (segment: string) => string | undefined): ShellSegmentPlan[] {
|
|
1053
|
+
const plan: ShellSegmentPlan[] = [];
|
|
1054
|
+
let cwd = baseDir;
|
|
1055
|
+
const dirStack: Array<string | undefined> = [];
|
|
1056
|
+
// Each open grouping paren saves the outer directory and the outer stack.
|
|
1057
|
+
// A `pushd` inside `( … )` must not leave an entry a later outer `popd`
|
|
1058
|
+
// consumes: bash discards the subshell's stack with it.
|
|
1059
|
+
const groupStack: Array<{ cwd: string | undefined; dirStack: Array<string | undefined> }> = [];
|
|
1060
|
+
// One frame per open `if`/`while`/`until`/`for`/`case`, recording whether any
|
|
1061
|
+
// body changed directory.
|
|
1062
|
+
const constructStack: Array<{ changed: boolean }> = [];
|
|
1063
|
+
let caseDepth = 0;
|
|
1064
|
+
for (const segment of splitShellSegments(command)) {
|
|
1065
|
+
const { opens, closes } = groupParensIn(segment, caseDepth > 0);
|
|
1066
|
+
for (let i = 0; i < opens; i += 1) groupStack.push({ cwd, dirStack: [...dirStack] });
|
|
1067
|
+
const wrapperCwd = shellWrapperCwd(segment);
|
|
1068
|
+
let root = cwd;
|
|
1069
|
+
if (wrapperCwd !== undefined) {
|
|
1070
|
+
const expanded = expandCwdTarget(wrapperCwd);
|
|
1071
|
+
root = expanded === undefined || cwd === undefined ? undefined : resolve(cwd, expanded);
|
|
1072
|
+
}
|
|
1073
|
+
plan.push({ segment, root });
|
|
1074
|
+
const keyword = constructKeywordIn(segment);
|
|
1075
|
+
if (keyword === "open") constructStack.push({ changed: false });
|
|
1076
|
+
if (keyword === "case-open") {
|
|
1077
|
+
constructStack.push({ changed: false });
|
|
1078
|
+
caseDepth += 1;
|
|
1079
|
+
}
|
|
1080
|
+
// `eval` runs its body in the current shell, so a `cd` inside it moves the
|
|
1081
|
+
// directory of later segments. The body is supplied by the caller because
|
|
1082
|
+
// extracting it lives in the interpreter policy, not here.
|
|
1083
|
+
const evaluated = evalBody?.(segment);
|
|
1084
|
+
const change = cwdChangeIn(segment, caseDepth > 0) ?? (evaluated === undefined ? undefined : cwdChangeIn(evaluated, caseDepth > 0));
|
|
1085
|
+
if (change) {
|
|
1086
|
+
for (const frame of constructStack) frame.changed = true;
|
|
1087
|
+
if (change.unmodeled) {
|
|
1088
|
+
cwd = undefined;
|
|
1089
|
+
} else if (change.word === "popd") {
|
|
1090
|
+
// `popd` with nothing to pop fails in bash and leaves the
|
|
1091
|
+
// directory alone, so it must not clear a tracked directory.
|
|
1092
|
+
cwd = dirStack.length > 0 ? dirStack.pop() : cwd;
|
|
1093
|
+
} else {
|
|
1094
|
+
if (change.word === "pushd") dirStack.push(cwd);
|
|
1095
|
+
const expanded = change.target === undefined ? undefined : expandCwdTarget(change.target);
|
|
1096
|
+
cwd = cwd === undefined || expanded === undefined ? undefined : resolve(cwd, expanded);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (keyword === "case-close") caseDepth = Math.max(0, caseDepth - 1);
|
|
1100
|
+
if (keyword === "close" || keyword === "case-close") {
|
|
1101
|
+
const frame = constructStack.pop();
|
|
1102
|
+
// A body that changed directory only sometimes leaves the later
|
|
1103
|
+
// directory dependent on a condition, which fails closed.
|
|
1104
|
+
if (frame?.changed) cwd = undefined;
|
|
1105
|
+
}
|
|
1106
|
+
for (let i = 0; i < closes; i += 1) {
|
|
1107
|
+
const outer = groupStack.pop();
|
|
1108
|
+
if (outer) {
|
|
1109
|
+
cwd = outer.cwd;
|
|
1110
|
+
dirStack.length = 0;
|
|
1111
|
+
dirStack.push(...outer.dirStack);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return plan;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Environment assignments that precede the command word, after unwrapping. */
|
|
1119
|
+
export function shellPrefixAssignments(command: string): string[] {
|
|
1120
|
+
return unwrapShellWordsImpl(command).assignments;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
426
1123
|
export function shellWrappersChangeCwd(command: string): boolean {
|
|
427
1124
|
return unwrapShellWordsImpl(command).changesCwd;
|
|
428
1125
|
}
|