@scopebond/hook 0.4.0 → 0.6.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/dist/shell.js CHANGED
@@ -6,10 +6,17 @@
6
6
  // one of them is out of policy.
7
7
  //
8
8
  // It is best-effort and fail-safe: anything it cannot parse with confidence is
9
- // returned as a single `opaque` command, which the caller denies (strict) or
10
- // observes (non-strict) rather than trusting. Every scan is linear in the input
9
+ // returned as a single `opaque` command (as is a command whose program is only known
10
+ // at run time), which the mapper records with an empty program so the starter policy
11
+ // denies it rather than trusting it. Every scan is linear in the input
11
12
  // length with no backtracking, so a hostile command cannot stall the hot path.
12
- const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "busybox"]);
13
+ /** The canonical program name used for every decision: basename, lower-cased, with
14
+ * a Windows executable suffix removed. `RM.EXE`, `/bin/rm` and `rm` are one program
15
+ * — Windows and macOS resolve them case-insensitively, so matching the literal
16
+ * spelling would let a case or suffix variant through. */
17
+ export const canonProgram = (t) => t.replace(/^.*[\\/]/, "").toLowerCase().replace(/\.(?:exe|cmd|bat|com|ps1)$/, "");
18
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
19
+ const POWERSHELLS = new Set(["powershell", "pwsh"]);
13
20
  const MAX_DEPTH = 6;
14
21
  /** Split a command line into top-level simple-command strings at unquoted
15
22
  * separators (`;`, newline, `&&`, `||`, `|`, `&`), leaving quotes, `$( )`,
@@ -76,6 +83,23 @@ function splitTopLevel(src) {
76
83
  cur += c;
77
84
  continue;
78
85
  }
86
+ const prev = src[i - 1];
87
+ // An unquoted `#` at the start of a word begins a comment that runs to the end of
88
+ // the line (`rm -rf x # don't` must not read as an unbalanced quote).
89
+ if (c === "#" && (prev === undefined || /[\s;&|()]/.test(prev))) {
90
+ while (i + 1 < src.length && src[i + 1] !== "\n")
91
+ i++;
92
+ continue;
93
+ }
94
+ // `&>file`, `2>&1`, `<&3` and `>|file` are redirections, not separators.
95
+ if (c === "&" && (n === ">" || prev === ">" || prev === "<")) {
96
+ cur += c;
97
+ continue;
98
+ }
99
+ if (c === "|" && prev === ">") {
100
+ cur += c;
101
+ continue;
102
+ }
79
103
  if (c === ";" || c === "\n") {
80
104
  flush();
81
105
  continue;
@@ -99,34 +123,40 @@ function splitTopLevel(src) {
99
123
  flush();
100
124
  return { segments, unbalanced: quote !== null || paren !== 0 || backtick };
101
125
  }
126
+ /** What a command substitution leaves behind in the outer command: a parameter
127
+ * expansion, so the word it sat in is treated as unresolved (its value is only known
128
+ * at run time) — as a program name it is dynamic, as a path it is glob-tested. */
129
+ const SUBST = "${__sb_subst}";
102
130
  /** Pull the contents of command substitutions and subshell groups out of one
103
- * segment — `$( … )`, `` ` … ` ``, and a leading `( … )` — for recursion, and
104
- * return the segment with those regions blanked so the outer command tokenizes
105
- * cleanly. */
131
+ * segment — `$( … )` and `` ` … ` `` (also inside double quotes, where the shell
132
+ * still runs them) and `( … )` groups — for recursion. A substitution is replaced
133
+ * by a placeholder expansion; a group is blanked, except an empty `()` (a function
134
+ * definition), which is kept. */
106
135
  function extractSubstitutions(seg) {
107
136
  const inner = [];
108
137
  let outer = "";
109
138
  let quote = null;
139
+ const group = (start) => {
140
+ let depth = 1;
141
+ let j = start;
142
+ for (; j < seg.length && depth > 0; j++) {
143
+ if (seg[j] === "(")
144
+ depth++;
145
+ else if (seg[j] === ")")
146
+ depth--;
147
+ }
148
+ inner.push(seg.slice(start, depth === 0 ? j - 1 : j));
149
+ return j - 1;
150
+ };
110
151
  for (let i = 0; i < seg.length; i++) {
111
152
  const c = seg[i];
112
153
  const n = seg[i + 1];
113
- if (quote) {
154
+ if (quote === "'") {
114
155
  outer += c;
115
- if (quote === "'") {
116
- if (c === "'")
117
- quote = null;
118
- }
119
- else if (c === "\\" && n !== undefined)
120
- outer += seg[++i];
121
- else if (c === '"')
156
+ if (c === "'")
122
157
  quote = null;
123
158
  continue;
124
159
  }
125
- if (c === "'" || c === '"') {
126
- quote = c;
127
- outer += c;
128
- continue;
129
- }
130
160
  if (c === "`") {
131
161
  const end = seg.indexOf("`", i + 1);
132
162
  if (end === -1) {
@@ -134,33 +164,59 @@ function extractSubstitutions(seg) {
134
164
  continue;
135
165
  }
136
166
  inner.push(seg.slice(i + 1, end));
167
+ outer += SUBST;
137
168
  i = end;
138
169
  continue;
139
170
  }
140
- if ((c === "$" && n === "(") || c === "(") {
141
- const start = c === "(" ? i + 1 : i + 2;
142
- let depth = 1;
143
- let j = start;
144
- for (; j < seg.length && depth > 0; j++) {
145
- if (seg[j] === "(")
146
- depth++;
147
- else if (seg[j] === ")")
148
- depth--;
149
- }
150
- inner.push(seg.slice(start, depth === 0 ? j - 1 : j));
151
- i = j - 1;
171
+ if (c === "$" && n === "(") {
172
+ i = group(i + 2);
173
+ outer += SUBST;
174
+ continue;
175
+ }
176
+ if (quote === '"') {
177
+ outer += c;
178
+ if (c === "\\" && n !== undefined)
179
+ outer += seg[++i];
180
+ else if (c === '"')
181
+ quote = null;
182
+ continue;
183
+ }
184
+ if (c === "'" || c === '"') {
185
+ quote = c;
186
+ outer += c;
187
+ continue;
188
+ }
189
+ if (c === "\\" && n !== undefined) {
190
+ outer += c + seg[++i];
191
+ continue;
192
+ }
193
+ if (c === "(" && n === ")") {
194
+ outer += "()";
195
+ i++;
196
+ continue;
197
+ }
198
+ if (c === "(") {
199
+ i = group(i + 1);
152
200
  continue;
153
201
  }
154
202
  outer += c;
155
203
  }
156
204
  return { outer, inner };
157
205
  }
158
- /** Tokenize a simple command into words, honoring single and double quotes. */
206
+ /** Tokenize a simple command into words, honoring single and double quotes.
207
+ * Unquoted redirection operators (`>`, `>>`, `2>`, `&>`, `>|`, `<`, `<<`, `>&`)
208
+ * become their own operator tokens even when written without spaces (`x>file`),
209
+ * so a redirection target can never hide inside a word. */
159
210
  function tokenize(s) {
160
211
  const out = [];
161
212
  let cur = "";
162
213
  let has = false;
163
214
  let quote = null;
215
+ const push = () => { if (has) {
216
+ out.push({ t: cur, op: false });
217
+ cur = "";
218
+ has = false;
219
+ } };
164
220
  for (let i = 0; i < s.length; i++) {
165
221
  const c = s[i];
166
222
  const n = s[i + 1];
@@ -173,9 +229,11 @@ function tokenize(s) {
173
229
  continue;
174
230
  }
175
231
  if (quote === '"') {
232
+ // As in bash: inside double quotes a backslash escapes only $ ` " \ and newline;
233
+ // before anything else it is a literal character (so "a\b" keeps its backslash).
176
234
  if (c === '"')
177
235
  quote = null;
178
- else if (c === "\\" && n !== undefined)
236
+ else if (c === "\\" && n !== undefined && /[$`"\\\n]/.test(n))
179
237
  cur += s[++i];
180
238
  else
181
239
  cur += c;
@@ -193,49 +251,262 @@ function tokenize(s) {
193
251
  continue;
194
252
  }
195
253
  if (/\s/.test(c)) {
196
- if (has) {
197
- out.push(cur);
254
+ push();
255
+ continue;
256
+ }
257
+ if (c === ">" || c === "<" || (c === "&" && n === ">")) {
258
+ // A pure file-descriptor number (or `&`) directly before the operator is part of it.
259
+ let op = "";
260
+ if (has && /^\d+$/.test(cur)) {
198
261
  cur = "";
199
262
  has = false;
200
263
  }
264
+ else
265
+ push();
266
+ if (c === "&") {
267
+ op = "&";
268
+ i++;
269
+ }
270
+ op += s[i];
271
+ const next = s[i + 1];
272
+ if (next === s[i] && next !== undefined) {
273
+ op += next;
274
+ i++;
275
+ if (op.endsWith("<<") && s[i + 1] === "<") {
276
+ op += "<";
277
+ i++;
278
+ }
279
+ }
280
+ else if (next === "|" || next === "&" || (s[i] === "<" && next === ">")) {
281
+ op += next;
282
+ i++;
283
+ }
284
+ out.push({ t: op, op: true });
201
285
  continue;
202
286
  }
203
287
  cur += c;
204
288
  has = true;
205
289
  }
206
- if (has)
207
- out.push(cur);
290
+ push();
208
291
  return out;
209
292
  }
293
+ /** Separate redirections from the command's words. `>`/`>>`/`>|`/`&>` targets are
294
+ * writes, `<` a read, `<>` both; here-doc/here-string operands and fd duplications
295
+ * (`2>&1`) are dropped. */
296
+ function splitRedirects(tokens) {
297
+ const words = [];
298
+ const redirects = [];
299
+ for (let i = 0; i < tokens.length; i++) {
300
+ const tok = tokens[i];
301
+ if (!tok.op) {
302
+ words.push(tok.t);
303
+ continue;
304
+ }
305
+ const target = tokens[i + 1] && !tokens[i + 1].op ? tokens[i + 1].t : undefined;
306
+ if (target !== undefined)
307
+ i++;
308
+ const op = tok.t.replace(/^&/, "");
309
+ if (op.startsWith("<<") || target === undefined)
310
+ continue; // here-doc delimiter / here-string text
311
+ if ((op === ">&" || op === "<&") && /^(?:\d+|-)$/.test(target))
312
+ continue; // 2>&1, <&-
313
+ redirects.push({ op: op === ">&" ? ">" : op === "<&" ? "<" : op, target });
314
+ }
315
+ return { words, redirects };
316
+ }
210
317
  const basename = (t) => t.replace(/^.*[\\/]/, "");
211
318
  const isAssignment = (t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
212
- /** Strip a leading run of `sudo`, `env`, `command`, `nohup`, `nice`, `time`,
213
- * `xargs`, `exec` wrappers and `NAME=value` assignments, so the program is the
214
- * real one about to run. `env`/`xargs` consume their own following words as the
215
- * command, which is handled by simply dropping the wrapper token. */
216
- function stripPrefixes(tokens) {
217
- const wrappers = new Set(["sudo", "env", "command", "nohup", "nice", "time", "exec", "stdbuf", "setsid", "xargs"]);
319
+ const DURATION = /^[\d.]+[smhd]?$/i;
320
+ const WRAPPERS = new Map([
321
+ ["sudo", { short: "ugCDhpRrTtU", long: ["--user", "--group", "--close-from", "--chdir", "--prompt", "--chroot", "--role", "--type", "--command-timeout", "--other-user", "--host"] }],
322
+ ["doas", { short: "uCa" }],
323
+ ["pkexec", { long: ["--user"] }],
324
+ ["env", { short: "uCSPa", long: ["--unset", "--chdir", "--split-string", "--argv0"] }],
325
+ ["command", {}],
326
+ ["builtin", {}],
327
+ ["nohup", {}],
328
+ ["nice", { short: "n", long: ["--adjustment"] }],
329
+ ["time", { short: "fo", long: ["--format", "--output"], write: ["-o", "--output"] }],
330
+ ["exec", { short: "a" }],
331
+ ["stdbuf", { short: "ioe", long: ["--input", "--output", "--error"] }],
332
+ ["setsid", {}],
333
+ ["xargs", { short: "ILnPsdEa", optional: "iel", long: ["--arg-file", "--delimiter", "--max-args", "--max-procs", "--max-chars", "--process-slot-var"], read: ["-a", "--arg-file"] }],
334
+ ["busybox", {}],
335
+ ["timeout", { short: "sk", long: ["--signal", "--kill-after"], lead: DURATION }],
336
+ ["gtimeout", { short: "sk", long: ["--signal", "--kill-after"], lead: DURATION }],
337
+ ["ionice", { short: "cnp", long: ["--class", "--classdata", "--pid"] }],
338
+ ["chrt", { short: "TPD", long: ["--sched-runtime", "--sched-period", "--sched-deadline"], lead: /^\d+$/ }],
339
+ ["taskset", { lead: /^(?:0x[0-9a-f]+|[\d,-]+)$/i }],
340
+ ["caffeinate", { short: "wt" }],
341
+ ["strace", { short: "abeEIoOpPsSuX", long: ["--output", "--trace", "--signal", "--status", "--attach", "--string-limit", "--user", "--env"], write: ["-o", "--output"] }],
342
+ ["ltrace", { short: "aADeFlnopsuxw", long: ["--output", "--library"], write: ["-o", "--output"] }],
343
+ ["chroot", { long: ["--userspec", "--groups"], lead: /^[^-]/ }],
344
+ ["unbuffer", {}],
345
+ ["catchsegv", {}],
346
+ ["torsocks", { short: "uapP" }],
347
+ ["proxychains", { short: "f" }],
348
+ ["proxychains4", { short: "f" }],
349
+ ]);
350
+ // Shell reserved words and grouping in command position. They run nothing themselves;
351
+ // the command after them is the real one (`if rm …`, `then rm …`, `{ rm …`, `! rm …`).
352
+ const KEYWORDS = new Set(["if", "then", "else", "elif", "fi", "do", "done", "while", "until", "{", "}", "!", "coproc", "esac"]);
353
+ /** Words of a string as the shell would split it (quotes honored, operators dropped). */
354
+ const words = (s) => tokenize(s).filter((t) => !t.op).map((t) => t.t);
355
+ /** Strip the leading shell keywords, `NAME=value` assignments and wrappers (`sudo`,
356
+ * `env`, `time`, `xargs`, `timeout`, `strace` …, each with its own option arity) so
357
+ * the program is the real one about to run. `env -S "…"` splits its string into the
358
+ * command. Returns the remaining words and the wrappers passed on the way. */
359
+ function stripPrefixes(input) {
360
+ let tokens = input;
361
+ const wrappers = [];
218
362
  let i = 0;
219
- while (i < tokens.length) {
363
+ for (let guard = 0; i < tokens.length && guard < 1000; guard++) {
220
364
  const t = tokens[i];
221
365
  if (isAssignment(t)) {
222
366
  i++;
223
367
  continue;
224
368
  }
225
- if (wrappers.has(basename(t))) {
369
+ if (KEYWORDS.has(t)) {
370
+ i++;
371
+ continue;
372
+ }
373
+ // `for x in …` / `select x in …`: the header runs nothing (its substitutions were
374
+ // already extracted); the body follows `do`.
375
+ if (t === "for" || t === "select")
376
+ return { tokens: [], wrappers };
377
+ // `case WORD in PATTERN) cmd`: skip to the command after the pattern.
378
+ if (t === "case") {
379
+ const at = tokens.indexOf("in", i + 1);
380
+ if (at < 0)
381
+ return { tokens: [], wrappers };
382
+ i = at + 1;
383
+ continue;
384
+ }
385
+ // A case pattern opening a segment (`b) cmd`), or a function definition
386
+ // (`f() { cmd`, `function f { cmd`).
387
+ if (/^[^()]*\)$/.test(t) && !t.startsWith("$")) {
388
+ i++;
389
+ continue;
390
+ }
391
+ if (t.length > 2 && t.endsWith("()")) {
226
392
  i++;
227
- // `sudo -u user`, `nice -n 10`, `env -i` … skip option words and their args.
228
- while (i < tokens.length && tokens[i].startsWith("-")) {
229
- const opt = tokens[i];
393
+ if (tokens[i] === "{")
230
394
  i++;
231
- if (/^-[unC]$/.test(opt) && i < tokens.length && !tokens[i].startsWith("-"))
232
- i++;
395
+ continue;
396
+ }
397
+ if (t === "function" && i + 1 < tokens.length) {
398
+ i += 2;
399
+ if (tokens[i] === "()")
400
+ i++;
401
+ if (tokens[i] === "{")
402
+ i++;
403
+ continue;
404
+ }
405
+ const w = canonProgram(t);
406
+ const spec = WRAPPERS.get(w);
407
+ if (!spec)
408
+ break;
409
+ // `command -v rm` / `command -V rm` only describes rm; it runs nothing.
410
+ if (w === "command") {
411
+ let query = false;
412
+ for (let j = i + 1; j < tokens.length && tokens[j].startsWith("-"); j++)
413
+ if (/^-[A-Za-z]*[vV]/.test(tokens[j]))
414
+ query = true;
415
+ if (query)
416
+ break;
417
+ }
418
+ const redirects = [];
419
+ wrappers.push({ name: w, raw: t, redirects });
420
+ i++;
421
+ let split;
422
+ const value = (name, attached) => {
423
+ let v = attached;
424
+ if (v === undefined && i < tokens.length)
425
+ v = tokens[i++];
426
+ if (v === undefined)
427
+ return;
428
+ if (spec.write?.includes(name))
429
+ redirects.push({ op: ">", target: v });
430
+ if (spec.read?.includes(name))
431
+ redirects.push({ op: "<", target: v });
432
+ if (w === "env" && (name === "-S" || name === "--split-string"))
433
+ split = v;
434
+ };
435
+ while (i < tokens.length) {
436
+ const o = tokens[i];
437
+ if (o === "--") {
438
+ i++;
439
+ break;
440
+ }
441
+ if (!o.startsWith("-") || o === "-")
442
+ break;
443
+ i++;
444
+ if (o.startsWith("--")) {
445
+ const eq = o.indexOf("=");
446
+ if (eq > 0)
447
+ value(o.slice(0, eq), o.slice(eq + 1));
448
+ else if (spec.long?.includes(o))
449
+ value(o, undefined);
450
+ continue;
233
451
  }
452
+ for (let k = 1; k < o.length; k++) {
453
+ if (spec.optional?.includes(o[k]))
454
+ break;
455
+ if (spec.short?.includes(o[k])) {
456
+ value("-" + o[k], o.slice(k + 1) || undefined);
457
+ break;
458
+ }
459
+ }
460
+ }
461
+ if (split !== undefined) {
462
+ tokens = [...words(split), ...tokens.slice(i)];
463
+ i = 0;
234
464
  continue;
235
465
  }
236
- break;
466
+ if (spec.lead && i < tokens.length && spec.lead.test(tokens[i]))
467
+ i++;
468
+ }
469
+ return { tokens: tokens.slice(i), wrappers };
470
+ }
471
+ /** A program word whose name is only known at run time: a variable (`$r`), an
472
+ * ANSI-C string (`$'\x72m'`) or a command substitution in its last path segment. */
473
+ const isDynamicProgram = (word) => /[$`]/.test(word.replace(/^.*\//, ""));
474
+ /** The script run by `su -c …`, `script -qc …`, `runuser … -c`, `flock -c …`: the
475
+ * value of `-c`/`--command` (also as the last letter of a short-option cluster). */
476
+ function commandOption(argv) {
477
+ for (let i = 0; i < argv.length; i++) {
478
+ const a = argv[i];
479
+ if (a.startsWith("--command=") || a.startsWith("--session-command="))
480
+ return a.slice(a.indexOf("=") + 1);
481
+ if (a === "-c" || a === "--command" || a === "--session-command" || /^-[A-Za-z]+c$/.test(a))
482
+ return argv[i + 1] ?? null;
483
+ }
484
+ return null;
485
+ }
486
+ /** The command words `parallel` runs: everything before the first `:::`/`::::`
487
+ * argument separator, after its own options. */
488
+ function parallelCommand(argv) {
489
+ const valued = new Set(["-j", "-P", "-S", "-a", "-d", "-I", "-E", "-C", "-N", "-n", "-L", "--jobs", "--sshlogin", "--sshloginfile", "--arg-file", "--delimiter", "--colsep", "--results", "--tmpdir", "--workdir", "--wd", "--joblog", "--halt", "--delay", "--timeout", "--retries", "--max-args", "--max-lines", "--env", "--basefile", "--return", "--transferfile"]);
490
+ let i = 0;
491
+ while (i < argv.length && argv[i].startsWith("-") && !argv[i].startsWith(":::")) {
492
+ i += valued.has(argv[i]) ? 2 : 1;
493
+ }
494
+ const cmd = [];
495
+ for (; i < argv.length && !/^::::?\+?$/.test(argv[i]); i++)
496
+ cmd.push(argv[i]);
497
+ return cmd.length ? cmd.join(" ") : null;
498
+ }
499
+ /** The command `watch` re-runs: its operands joined (watch passes them to `sh -c`). */
500
+ function watchCommand(argv) {
501
+ let i = 0;
502
+ while (i < argv.length && argv[i].startsWith("-")) {
503
+ const o = argv[i++];
504
+ if (o === "--")
505
+ break;
506
+ if (o === "-n" || o === "-q" || o === "--interval" || o === "--equexit")
507
+ i++;
237
508
  }
238
- return tokens.slice(i);
509
+ return i < argv.length ? argv.slice(i).join(" ") : null;
239
510
  }
240
511
  /** Find the script argument of a shell invoked with `-c`, including combined short
241
512
  * flags where `c` is last (`-lc`, `-xec`). The script is the next non-option word. */
@@ -249,48 +520,349 @@ function shellScriptArg(argv) {
249
520
  }
250
521
  return null;
251
522
  }
523
+ /** `cmd /c …` / `cmd /k …`: everything after the switch is the script. */
524
+ function cmdScriptArg(argv) {
525
+ const at = argv.findIndex((a) => /^\/[ck]$/i.test(a));
526
+ return at >= 0 && at + 1 < argv.length ? argv.slice(at + 1).join(" ") : null;
527
+ }
528
+ /** `pwsh -Command …` / `-c …` (rest of argv is the script) or `-EncodedCommand <b64>`
529
+ * (base64 UTF-16LE, decoded here so an encoded command cannot hide its program).
530
+ * Returns undefined when there is no inline script, null when it cannot be decoded. */
531
+ function powershellScriptArg(argv) {
532
+ for (let i = 0; i < argv.length; i++) {
533
+ const a = argv[i].toLowerCase();
534
+ if (/^[-/](?:c|command|com|comm|comma|comman)$/.test(a))
535
+ return argv.slice(i + 1).join(" ") || null;
536
+ if (/^[-/](?:e|ec|en|enc|enco|encod|encode|encoded|encodedc\w*)$/.test(a)) {
537
+ const b64 = argv[i + 1];
538
+ if (!b64 || !/^[A-Za-z0-9+/=]+$/.test(b64))
539
+ return null;
540
+ const text = Buffer.from(b64, "base64").toString("utf16le");
541
+ return /[\u0000-\u0008�]/.test(text) ? null : text;
542
+ }
543
+ }
544
+ return undefined;
545
+ }
546
+ /** `find … -exec cmd {} ;` / `-execdir` / `-ok`: the embedded command runs per match. */
547
+ function findExecCommands(argv) {
548
+ const out = [];
549
+ for (let i = 0; i < argv.length; i++) {
550
+ if (!/^-(?:exec|execdir|ok|okdir)$/.test(argv[i]))
551
+ continue;
552
+ const cmd = [];
553
+ let j = i + 1;
554
+ for (; j < argv.length && argv[j] !== ";" && argv[j] !== "+" && argv[j] !== "\\;"; j++)
555
+ cmd.push(argv[j]);
556
+ if (cmd.length)
557
+ out.push(cmd.map((w) => (/[\s'"]/.test(w) ? `'${w.replace(/'/g, "")}'` : w)).join(" "));
558
+ i = j;
559
+ }
560
+ return out;
561
+ }
562
+ /** Find here-document openers (`<<word`, `<<-word`, `<< 'word'`, `<<\word`) in the
563
+ * whole command, honoring quotes and command-substitution nesting so a `<<` inside
564
+ * `"$(cat <<EOF …)"` is seen but a `<<` inside a quoted string, a here-string
565
+ * (`<<<`) or arithmetic is not. Each opener records its delimiter, the source line it
566
+ * sits on, and whether the command that owns it runs a shell (its body is a script)
567
+ * rather than consuming the body as data. `words`/`stripPrefixes` resolve the owning
568
+ * program past wrappers (`sudo bash <<EOF`). */
569
+ function findHeredocs(src) {
570
+ const found = [];
571
+ const stack = []; // saved quote state at each substitution entry
572
+ let quote = null;
573
+ let cmdStart = 0;
574
+ let line = 0;
575
+ for (let i = 0; i < src.length; i++) {
576
+ const c = src[i];
577
+ const n = src[i + 1];
578
+ if (c === "\n") {
579
+ line++;
580
+ if (!quote)
581
+ cmdStart = i + 1;
582
+ continue;
583
+ }
584
+ if (quote === "'") {
585
+ if (c === "'")
586
+ quote = null;
587
+ continue;
588
+ }
589
+ if (quote === '"') {
590
+ if (c === "\\" && n !== undefined) {
591
+ i++;
592
+ continue;
593
+ }
594
+ if (c === '"') {
595
+ quote = null;
596
+ continue;
597
+ }
598
+ // Command substitutions run inside double quotes; a here-doc can open there.
599
+ if (c === "$" && n === "(") {
600
+ stack.push(quote);
601
+ quote = null;
602
+ cmdStart = i + 2;
603
+ i++;
604
+ continue;
605
+ }
606
+ if (c === "`") {
607
+ stack.push(quote);
608
+ quote = null;
609
+ cmdStart = i + 1;
610
+ continue;
611
+ }
612
+ continue;
613
+ }
614
+ // unquoted
615
+ if (c === "\\" && n !== undefined) {
616
+ i++;
617
+ continue;
618
+ }
619
+ if (c === "'" || c === '"') {
620
+ quote = c;
621
+ continue;
622
+ }
623
+ if (c === "$" && n === "(") {
624
+ stack.push(null);
625
+ cmdStart = i + 2;
626
+ i++;
627
+ continue;
628
+ }
629
+ if (c === "`") {
630
+ stack.push(null);
631
+ cmdStart = i + 1;
632
+ continue;
633
+ }
634
+ if (c === "(") {
635
+ stack.push(null);
636
+ cmdStart = i + 1;
637
+ continue;
638
+ }
639
+ if (c === ")") {
640
+ if (stack.length)
641
+ quote = stack.pop() ?? null;
642
+ cmdStart = i + 1;
643
+ continue;
644
+ }
645
+ if (c === ";" || c === "&" || c === "|") {
646
+ cmdStart = i + 1;
647
+ continue;
648
+ }
649
+ if (c === "<" && n === "<") {
650
+ if (src[i + 2] === "<") {
651
+ i += 2;
652
+ continue;
653
+ } // here-string, not a here-doc
654
+ let j = i + 2;
655
+ let dashed = false;
656
+ if (src[j] === "-") {
657
+ dashed = true;
658
+ j++;
659
+ }
660
+ while (src[j] === " " || src[j] === "\t")
661
+ j++;
662
+ let delim = "";
663
+ const q = src[j];
664
+ if (q === "'" || q === '"') {
665
+ const end = src.indexOf(q, j + 1);
666
+ if (end === -1 || src.slice(j + 1, end).includes("\n")) {
667
+ i = j;
668
+ continue;
669
+ }
670
+ delim = src.slice(j + 1, end);
671
+ j = end + 1;
672
+ }
673
+ else {
674
+ if (src[j] === "\\")
675
+ j++;
676
+ while (j < src.length && /[A-Za-z0-9_./-]/.test(src[j]))
677
+ delim += src[j++];
678
+ }
679
+ if (delim) {
680
+ const cmdText = src.slice(cmdStart, i);
681
+ const { tokens } = stripPrefixes(words(cmdText));
682
+ const prog = tokens.length ? canonProgram(tokens[0]) : "";
683
+ found.push({ line, delim, dashed, ownerIsShell: SHELLS.has(prog) });
684
+ }
685
+ i = j - 1;
686
+ continue;
687
+ }
688
+ }
689
+ return found;
690
+ }
691
+ /** Remove here-document bodies before the line-splitter runs, so their lines are
692
+ * never parsed as commands. A body is inert data — a commit message, an HTTP
693
+ * payload, a note written with `cat <<EOF > f` (the redirection still records the
694
+ * write) — except a body fed to a shell (`bash <<EOF …`), which runs and is returned
695
+ * for its own decomposition. A `<<` whose delimiter never recurs on a later line is
696
+ * left untouched (it was arithmetic or otherwise not a here-doc). */
697
+ function stripHeredocs(src) {
698
+ if (!src.includes("<<"))
699
+ return { text: src, scripts: [] };
700
+ const openers = findHeredocs(src);
701
+ if (openers.length === 0)
702
+ return { text: src, scripts: [] };
703
+ const strip = (s) => s.replace(/^\t+/, "");
704
+ const byLine = new Map();
705
+ for (const op of openers) {
706
+ const l = byLine.get(op.line) ?? [];
707
+ l.push(op);
708
+ byLine.set(op.line, l);
709
+ }
710
+ const lines = src.split("\n");
711
+ const out = [];
712
+ const scripts = [];
713
+ for (let i = 0; i < lines.length; i++) {
714
+ out.push(lines[i]);
715
+ const ops = byLine.get(i);
716
+ if (!ops)
717
+ continue;
718
+ for (const op of ops) {
719
+ // Only consume a body when the terminator is actually present below.
720
+ let end = -1;
721
+ for (let j = i + 1; j < lines.length; j++) {
722
+ if ((op.dashed ? strip(lines[j]) : lines[j]) === op.delim) {
723
+ end = j;
724
+ break;
725
+ }
726
+ }
727
+ if (end === -1)
728
+ continue;
729
+ const body = lines.slice(i + 1, end).map((l) => (op.dashed ? strip(l) : l));
730
+ if (op.ownerIsShell)
731
+ scripts.push(body.join("\n"));
732
+ i = end; // skip the body and the terminator line
733
+ }
734
+ }
735
+ return { text: out.join("\n"), scripts };
736
+ }
252
737
  /** Decompose a command line into the simple commands it will run. Recurses into
253
738
  * `-c` scripts and command substitutions up to a bounded depth. */
254
739
  export function decomposeShell(command, depth = 0) {
255
740
  const src = command.trim();
256
741
  if (!src)
257
742
  return [];
258
- const opaque = () => [{ program: "", programRaw: "", argv: [], raw: src, opaque: true }];
743
+ const opaque = (raw = src) => [{ program: "", programRaw: "", argv: [], redirects: [], raw, opaque: true }];
259
744
  if (depth > MAX_DEPTH)
260
745
  return opaque();
261
- const { segments, unbalanced } = splitTopLevel(src);
746
+ const { text, scripts } = stripHeredocs(src);
747
+ const fromHeredocs = scripts.flatMap((s) => decomposeShell(s, depth + 1));
748
+ const { segments, unbalanced } = splitTopLevel(text);
262
749
  if (unbalanced)
263
- return opaque();
750
+ return [...opaque(), ...fromHeredocs];
264
751
  const out = [];
265
752
  for (const seg of segments) {
266
753
  const { outer, inner } = extractSubstitutions(seg);
267
754
  for (const sub of inner)
268
755
  out.push(...decomposeShell(sub, depth + 1));
269
- const tokens = stripPrefixes(tokenize(outer));
270
- if (tokens.length === 0)
756
+ const split = splitRedirects(tokenize(outer));
757
+ const { tokens, wrappers } = stripPrefixes(split.words);
758
+ const redirects = split.redirects;
759
+ // Each wrapper is recorded as a command of its own (`sudo` is a program a policy
760
+ // may deny), carrying any file its options name (`time -o f`, `xargs -a f`).
761
+ for (const w of wrappers)
762
+ out.push({ program: basename(w.raw), programRaw: w.raw, argv: [], redirects: w.redirects, raw: seg, opaque: false });
763
+ if (tokens.length === 0) {
764
+ // A bare redirection (`> file`) still writes its target.
765
+ if (redirects.length)
766
+ out.push({ program: "", programRaw: "", argv: [], redirects, raw: seg, opaque: false });
271
767
  continue; // pure substitution/subshell — inner already handled
768
+ }
769
+ // A program named by a variable, an ANSI-C string or a substitution is only known
770
+ // at run time: it cannot be judged by name, so it is opaque (fails closed).
771
+ if (isDynamicProgram(tokens[0])) {
772
+ out.push(...opaque(seg));
773
+ continue;
774
+ }
272
775
  const program = basename(tokens[0]);
273
776
  const argv = tokens.slice(1);
274
- out.push({ program, programRaw: tokens[0], argv, raw: seg, opaque: false });
275
- if (SHELLS.has(program)) {
276
- const script = shellScriptArg(argv);
777
+ out.push({ program, programRaw: tokens[0], argv, redirects, raw: seg, opaque: false });
778
+ const canon = canonProgram(program);
779
+ const nested = (script) => { if (script)
780
+ out.push(...decomposeShell(script, depth + 1)); };
781
+ if (SHELLS.has(canon)) {
782
+ nested(shellScriptArg(argv));
783
+ }
784
+ else if (canon === "eval") {
785
+ nested(argv.join(" "));
786
+ }
787
+ else if (canon === "trap") {
788
+ if (argv.length >= 2 && !argv[0].startsWith("-"))
789
+ nested(argv[0]);
790
+ }
791
+ else if (canon === "su" || canon === "runuser" || canon === "script" || canon === "flock" || canon === "sg") {
792
+ const script = commandOption(argv);
793
+ if (script)
794
+ nested(script);
795
+ else if (canon === "sg" || canon === "flock") {
796
+ // `sg group cmd …` / `flock [-w n] lockfile cmd …`: the words after the first operand.
797
+ const ops = [];
798
+ for (let k = 0; k < argv.length; k++) {
799
+ if (argv[k].startsWith("-") && ops.length === 0) {
800
+ if (/^-[wEn]$/.test(argv[k]))
801
+ k++;
802
+ continue;
803
+ }
804
+ ops.push(argv[k]);
805
+ }
806
+ nested(ops.slice(1).join(" "));
807
+ }
808
+ }
809
+ else if (canon === "watch") {
810
+ nested(watchCommand(argv));
811
+ }
812
+ else if (canon === "parallel") {
813
+ nested(parallelCommand(argv));
814
+ }
815
+ else if (canon === "cmd") {
816
+ const script = cmdScriptArg(argv);
277
817
  if (script)
278
818
  out.push(...decomposeShell(script, depth + 1));
279
819
  }
820
+ else if (POWERSHELLS.has(canon)) {
821
+ const script = powershellScriptArg(argv);
822
+ if (script === null)
823
+ out.push(...opaque(seg));
824
+ else if (script)
825
+ out.push(...decomposeShell(script, depth + 1));
826
+ }
827
+ else if (canon === "find") {
828
+ for (const script of findExecCommands(argv))
829
+ out.push(...decomposeShell(script, depth + 1));
830
+ }
280
831
  }
832
+ out.push(...fromHeredocs);
281
833
  return out.length ? out : opaque();
282
834
  }
283
- /** Parse a `git … push …` simple command into git.push params, or null if it is
284
- * not a push. Handles global options that take an argument (`-C dir`, `-c k=v`),
285
- * `--force`/`-f`/`--force-with-lease`, and a leading `+` force refspec. */
286
- export function parseGitPush(cmd) {
287
- if (cmd.program !== "git")
835
+ /** Canonicalize a push destination to the short branch name a policy names:
836
+ * `refs/heads/main` → `main`, `heads/main` → `main`. `HEAD` and `@` mean the
837
+ * current branch and resolve to undefined (the runtime fills the real branch in). */
838
+ export function canonRef(dst) {
839
+ const d = dst.trim();
840
+ if (!d || d === "HEAD" || d === "@")
841
+ return undefined;
842
+ return d.replace(/^refs\/heads\//, "").replace(/^heads\//, "");
843
+ }
844
+ /** A git invocation split into its global `-c` config values, its subcommand and
845
+ * the subcommand's arguments, or null if the program is not git. Global options
846
+ * that take a value (`-C dir`, `-c k=v`, `--git-dir d` …) are skipped with it. */
847
+ export function gitArgs(cmd) {
848
+ if (canonProgram(cmd.program) !== "git")
288
849
  return null;
289
850
  const a = cmd.argv;
851
+ const configs = [];
290
852
  let i = 0;
291
853
  while (i < a.length) {
292
854
  const t = a[i];
293
- if (t === "-C" || t === "-c" || t === "--git-dir" || t === "--work-tree" || t === "--namespace") {
855
+ if (t === "-c" || t === "--config-env") {
856
+ configs.push(a[i + 1] ?? "");
857
+ i += 2;
858
+ continue;
859
+ }
860
+ if (t.startsWith("--config-env=")) {
861
+ configs.push(t.slice("--config-env=".length));
862
+ i++;
863
+ continue;
864
+ }
865
+ if (t === "-C" || t === "--git-dir" || t === "--work-tree" || t === "--namespace" || t === "--super-prefix") {
294
866
  i += 2;
295
867
  continue;
296
868
  }
@@ -300,32 +872,116 @@ export function parseGitPush(cmd) {
300
872
  }
301
873
  break;
302
874
  }
303
- if (a[i] !== "push")
875
+ return { sub: a[i], args: a.slice(i + 1), configs };
876
+ }
877
+ /** The ref recorded for a push whose destination cannot be read from the command
878
+ * line: an alias (`git -c alias.ship=push ship`), a configured push refspec
879
+ * (`-c remote.origin.push=…`, `-c push.default=matching`) or a lower-level push
880
+ * (`git send-pack`, `git http-push`, `git subtree push`). It starts with `-`, which
881
+ * the starter branch guard refuses, so an unreadable push fails closed. */
882
+ export const UNKNOWN_REF = "--unknown";
883
+ /** Parse a `git … push …` simple command, or null if it is not a push. Handles
884
+ * global options that take an argument (`-C dir`, `-c k=v`), push options that take
885
+ * one (`-o`, `--repo`, `--receive-pack`), every force spelling (`--force`, `-f`,
886
+ * `-uf`, `--force-with-lease`, `--force-if-includes`, a `+` refspec), `src:dst` and
887
+ * fully qualified refspecs, and multiple refspecs — each destination is returned,
888
+ * so `git push origin feature refs/heads/main` cannot hide `main` in second place.
889
+ * `--all`, `--mirror` and `--branches` push every branch: they return the literal
890
+ * flag as the ref, which the starter policy denies; `--tags` alone pushes only tags
891
+ * and returns `--tags`, which it allows. `ref`/`force` mirror the first target for
892
+ * callers that expect a single push. */
893
+ export function parseGitPush(cmd) {
894
+ const g = gitArgs(cmd);
895
+ if (!g)
304
896
  return null;
305
- const rest = a.slice(i + 1);
897
+ const aliased = g.configs.some((c) => /^alias\./i.test(c));
898
+ const configuredPush = g.configs.some((c) => /^(?:remote\..*\.push(?:url)?|push\.default|remote\.pushdefault)(?:=|$)/i.test(c));
899
+ const lowLevel = g.sub === "send-pack" || g.sub === "http-push" || (g.sub === "subtree" && g.args.includes("push"));
900
+ if (aliased || lowLevel || (g.sub === "push" && configuredPush)) {
901
+ return { force: false, ref: UNKNOWN_REF, targets: [{ ref: UNKNOWN_REF, force: false }] };
902
+ }
903
+ if (g.sub !== "push")
904
+ return null;
905
+ const rest = g.args;
306
906
  let force = false;
907
+ let everything;
908
+ let tags = false;
909
+ let del = false;
910
+ let repo;
307
911
  const positional = [];
308
- for (const t of rest) {
309
- if (t === "--force" || t === "-f" || t === "--force-with-lease" || t.startsWith("--force-with-lease=")) {
912
+ for (let k = 0; k < rest.length; k++) {
913
+ const t = rest[k];
914
+ if (t === "--force" || t === "--force-with-lease" || t.startsWith("--force-with-lease=") || t === "--force-if-includes") {
310
915
  force = true;
311
916
  continue;
312
917
  }
918
+ if (/^-[A-Za-z]+$/.test(t) && t.includes("f")) {
919
+ force = true;
920
+ continue;
921
+ } // -f, -uf, -fu
922
+ if (t === "--delete" || (/^-[A-Za-z]+$/.test(t) && t.includes("d"))) {
923
+ del = true;
924
+ continue;
925
+ } // --delete, -d, -df
926
+ if (t === "--all" || t === "--mirror" || t === "--branches") {
927
+ everything = t;
928
+ if (t === "--mirror")
929
+ force = true;
930
+ continue;
931
+ }
932
+ if (t === "--tags") {
933
+ tags = true;
934
+ continue;
935
+ }
936
+ if (t === "--repo") {
937
+ repo = rest[++k];
938
+ continue;
939
+ }
940
+ if (t.startsWith("--repo=")) {
941
+ repo = t.slice("--repo=".length);
942
+ continue;
943
+ }
944
+ if (t === "-o" || t === "--push-option" || t === "--receive-pack" || t === "--exec") {
945
+ k++;
946
+ continue;
947
+ }
948
+ if (t === "--")
949
+ continue;
313
950
  if (t.startsWith("-"))
314
951
  continue;
315
952
  positional.push(t);
316
953
  }
317
- const params = { force };
318
- if (positional[0])
319
- params.remote = positional[0];
320
- if (positional[1]) {
321
- let refspec = positional[1];
322
- if (refspec.startsWith("+")) {
323
- force = true;
324
- params.force = true;
325
- refspec = refspec.slice(1);
954
+ // `--repo <r>` names the repository. git itself still reads a first positional as
955
+ // the repository when one is given, but the option's documentation reads as if the
956
+ // positionals were then all refspecs — so with `--repo`, every positional is checked
957
+ // as a refspec AND, when at most one is given, the current branch is too. Either
958
+ // reading of `git push --repo origin HEAD:main` is covered.
959
+ const remote = repo ?? positional[0];
960
+ const specs = repo !== undefined ? positional : positional.slice(1);
961
+ const targets = [];
962
+ // `--all`/`--mirror`/`--branches` reach every branch, so they hit any protected ref;
963
+ // `--mirror` also prunes and force-updates. The pseudo-ref keeps the starter policy's
964
+ // ref allowlist denying it; `all` is the signal a force_push_guard clause reads.
965
+ if (everything)
966
+ targets.push({ ref: everything, force, all: true, ...(everything === "--mirror" ? { del: true } : {}) });
967
+ for (const spec of specs) {
968
+ let s = spec;
969
+ let f = force;
970
+ if (s.startsWith("+")) {
971
+ f = true;
972
+ s = s.slice(1);
326
973
  }
327
- params.ref = refspec.replace(/^[^:]*:\+?/, ""); // src:dst → dst
974
+ const colon = s.indexOf(":");
975
+ // `src:dst` pushes to dst; `:dst` deletes dst; `src:` has no destination, so src.
976
+ const isDelete = del || (colon >= 0 && s.slice(0, colon) === "");
977
+ const dst = colon >= 0 ? (s.slice(colon + 1) || s.slice(0, colon)) : s;
978
+ targets.push({ ref: canonRef(dst.replace(/^\+/, "")), force: f || dst.startsWith("+"), ...(isDelete ? { del: true } : {}) });
328
979
  }
329
- return params;
980
+ if (repo !== undefined && positional.length <= 1 && !everything && !tags)
981
+ targets.push({ ref: undefined, force, ...(del ? { del: true } : {}) });
982
+ if (targets.length === 0)
983
+ targets.push(tags ? { ref: "--tags", force } : { ref: undefined, force, ...(del ? { del: true } : {}) });
984
+ const first = targets[0];
985
+ return { force: targets.some((t) => t.force), ...(remote !== undefined ? { remote } : {}), ...(first.ref !== undefined ? { ref: first.ref } : {}), targets };
330
986
  }
331
987
  //# sourceMappingURL=shell.js.map