Repository navigation
Expand file tree
/
Copy pathblueprint-allow.js
More file actions
2983 lines (2814 loc) · 121 KB
/
Copy pathblueprint-allow.js
File metadata and controls
2983 lines (2814 loc) · 121 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// blueprint-allow.js — PreToolUse hook (matcher: Bash)
//
// PURPOSE
// Plugin skills, agents and rules ship fenced `bash` blocks the model runs
// VERBATIM. Claude Code's prefix allow-matcher cannot express most of them: a
// command containing `$(...)` fails closed ("Contains expansion"), and a
// prefix rule matches only the first token, so `VAR=$(...)` and `IFS= read -r`
// can never match. This hook replaces the shape-based trust statement with a
// provenance one — "this exact text exists in a reviewed, versioned plugin
// file" — by hashing the incoming command with the same normalization pipeline
// `bin/build_blueprint_manifest.py` used to build `blueprint-manifest.json`,
// and auto-allowing an exact digest hit. Any deviation from the blueprint text
// misses and falls through (exit 0, no output) to the normal prompt, so custom
// or adapted code stays gated.
//
// SECURITY MODEL — why the allow is safe
// Like sentinel-read-allow.js and unlike rtk-rewrite.js we emit NO
// updatedInput: the ORIGINAL command string is allowed unchanged, so
// settings.json deny rules keep matching it (deny is evaluated regardless of a
// hook allow decision). Three layers must all hold before the allow fires:
//
// 1. PROVENANCE — hash match. The normalization pipeline here is a
// line-for-line port of the one documented in
// `bin/build_blueprint_manifest.py`'s module docstring, which is the
// single source of truth. A hook that normalizes MORE aggressively than
// that generator is the one real bug class: it lets a crafted command
// collide with a manifest digest. Every rstrip / strip-truthiness /
// token-split here therefore uses PY_WS, the exact character set Python's
// `str.isspace()` reports — JavaScript's `\s` and `String.trim()` disagree
// with it in BOTH directions (`` is JS-only; `\x1c`-`\x1f` and
// `\x85` are Python-only). The incoming command is never `.trim()`ed
// before hashing, for the same reason. The shared vector fixture
// `tests/fixtures/blueprint_normalization_vectors.json` is executed by
// both languages' suites so the two implementations cannot drift apart.
//
// 2. COMPOSITION — never allow by recombining fragments. A whole-command
// digest hit allows directly. Otherwise the command is split into logical
// commands (top-level newlines only — never `;`/`&&`/`|`, since a
// fragment lifted out of a compound command changes meaning) and EVERY
// resulting command must independently be a manifest entry. Partial
// coverage is a miss. Splicing halves of two entries together therefore
// never allows; running two COMPLETE blueprint commands in sequence does,
// which grants nothing — each line was independently reviewed and would
// be allowed on its own. Per-command splitting is skipped entirely when
// the generator's bail-out condition holds (a heredoc marker or an
// unterminated quote), matching the generator, which emits only a
// whole-block entry for such blocks.
//
// 3. DEFENCE IN DEPTH — independent danger re-check. The generator already
// drops destructive entries, so the manifest should never contain one.
// The hook does not trust that: the full `is_dangerous` port is re-run
// and a hit refuses regardless of a digest match. This is what a generator
// bug, a stale manifest, or a tampered `blueprint-manifest.json` runs into.
// The re-check runs on the NORMALIZED text — exactly the text that gets
// hashed and looked up, so the layer judges what the layer above matched.
// Raw text would be judged with comments still attached, and a benign
// blessed command carrying a trailing `# ...check before you push` would be
// refused over a word bash never executes. Normalizing loses no danger:
// the pipeline only strips comments, right-strips, and folds blank lines —
// it never removes a token bash would run.
//
// The manifest record's `src` is reported in the allow reason as provenance.
// When several logical commands matched, the FIRST match's `src` is used —
// the reason line is a human audit pointer, not a machine contract.
//
// Every ambiguity resolves toward passthrough: a missing, unreadable or
// malformed manifest, an empty command, a non-Bash tool, or any parse failure
// yields silent passthrough rather than an allow.
//
// Module note: the stdin driver is guarded by `require.main === module`, and
// the pure functions are exported, so the test suite can exercise the shared
// normalization vectors directly instead of only through allow/miss outcomes.
// Requiring this file attaches no stdin listeners.
//
// EXIT CODES
// 0 always — passthrough (no output) or allow (JSON to stdout)
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const MANIFEST_NAME = "blueprint-manifest.json";
// ── Python whitespace parity ──────────────────────────────────────────────────
// Exactly the characters Python's `str.isspace()` reports, so `rstrip`,
// truthiness-after-strip, and token splitting behave identically on both sides.
const PY_WS =
"\\t\\n\\v\\f\\r\\x1c\\x1d\\x1e\\x1f \\x85\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
const RSTRIP_RE = new RegExp("[" + PY_WS + "]+$");
const LSTRIP_RE = new RegExp("^[" + PY_WS + "]*");
const WS_SPLIT_RE = new RegExp("[" + PY_WS + "]+");
const BLANK_RE = new RegExp("^[" + PY_WS + "]*$");
/** Python `str.rstrip()`. */
function rstrip(text) {
return text.replace(RSTRIP_RE, "");
}
/** Python `bool(text.strip())`. */
function hasContent(text) {
return !BLANK_RE.test(text);
}
/** Strip every leading and trailing character present in `chars` (Python `str.strip(chars)`). */
function stripChars(text, chars) {
let start = 0;
let end = text.length;
while (start < end && chars.indexOf(text[start]) !== -1) start++;
while (end > start && chars.indexOf(text[end - 1]) !== -1) end--;
return text.slice(start, end);
}
// ── Normalization ─────────────────────────────────────────────────────────────
/**
* Remove a word-start `#` comment from one line, tracking quote state.
* Port of build_blueprint_manifest.strip_line_comment.
* @returns {[string, string|null]} cleaned line and the quote open at its end.
*/
function stripLineComment(line, quote) {
let index = 0;
const length = line.length;
while (index < length) {
const char = line[index];
if (quote !== null) {
if (char === "\\" && quote === '"') {
index += 2;
continue;
}
if (char === quote) quote = null;
index += 1;
continue;
}
if (char === "\\") {
index += 2;
continue;
}
if (char === "'" || char === '"') {
quote = char;
index += 1;
continue;
}
if (char === "#" && (index === 0 || line[index - 1] === " " || line[index - 1] === "\t")) {
return [rstrip(line.slice(0, index)), quote];
}
index += 1;
}
return [rstrip(line), quote];
}
/** Drop leading/trailing blank lines and collapse internal blank runs to one. */
function collapseBlankLines(lines) {
const out = [];
for (const line of lines) {
if (line) out.push(line);
else if (out.length && out[out.length - 1]) out.push("");
}
while (out.length && !out[out.length - 1]) out.pop();
return out;
}
/** Apply the full normalization pipeline to a bash block or command. */
function normalize(text) {
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
let quote = null;
const stripped = [];
for (const line of lines) {
const result = stripLineComment(rstrip(line), quote);
stripped.push(result[0]);
quote = result[1];
}
return collapseBlankLines(stripped).join("\n");
}
/** Lowercase SHA-256 hex digest of `text` encoded as UTF-8. */
function sha256Text(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
// ── Splitting ─────────────────────────────────────────────────────────────────
/** Count the backslashes ending `line`. */
function trailingBackslashes(line) {
let count = 0;
while (count < line.length && line[line.length - 1 - count] === "\\") count++;
return count;
}
// An unescaped backslash, then a blank, then `#`: the shell keeps `\ #…` as word text, normalization drops it.
const ESCAPED_BLANK_BEFORE_COMMENT = /(?<!\\)(?:\\\\)*\\[ \t]#/;
/**
* True when normalization would erase a blank the shell reads as escaped: a line ending in an odd backslash run plus
* blanks (the shell ends the command there, normalization sees a continuation), or an escaped blank before `#` (the
* shell passes it as an argument, normalization drops it as a comment). Raw text and normalized form are then different
* commands sharing one digest, so the raw text is refused before any lookup.
* Matches build_blueprint_manifest.aliases_escaped_blank, including raw CR and non-shell trailing whitespace refusals.
*/
function aliasesEscapedBlank(text) {
if (text.includes("\r")) return true;
let quote = null;
for (const line of text.split("\n")) {
const trimmed = rstrip(line);
const [uncommented, nextQuote] = stripLineComment(trimmed, quote);
quote = nextQuote;
const discarded = line.slice(uncommented.length).match(LSTRIP_RE)[0];
if (/[^ \t]/.test(line.slice(trimmed.length)) || /[^ \t]/.test(discarded)) return true;
if (trimmed.length < line.length && trailingBackslashes(trimmed) % 2 === 1) return true;
if (ESCAPED_BLANK_BEFORE_COMMENT.test(line)) return true;
}
return false;
}
/** Split normalized text into logical commands on top-level newlines only. */
function splitLogicalCommands(normalized) {
const commands = [];
let buffer = [];
for (const line of normalized.split("\n")) {
buffer.push(line);
if (trailingBackslashes(line) % 2 === 1) continue;
const joined = buffer.join("\n");
if (hasContent(joined)) commands.push(joined);
buffer = [];
}
if (buffer.length && hasContent(buffer.join("\n"))) commands.push(buffer.join("\n"));
return commands;
}
/** True when per-command extraction is unsound (heredoc marker or unterminated quote). */
function needsBailout(normalized) {
if (normalized.indexOf("<<") !== -1) return true;
let quote = null;
for (const line of normalized.split("\n")) {
quote = stripLineComment(line, quote)[1];
if (quote !== null) return true;
}
return false;
}
/**
* Read a nesting-aware delimited run beginning at `start`. A `(…)` body is shell code, so its quoted strings are
* read whole: a parenthesis inside `'…'`, `"…"` or `$'…'` neither opens nor closes the body.
* @returns {[string, number]} inner text and the index just past the closing delimiter.
*/
function readDelimited(text, start, openChar, closeChar) {
let depth = 1;
let index = start;
while (index < text.length) {
const char = text[index];
if (char === "\\") {
index += 2;
continue;
}
if (openChar === "(" && (char === "'" || char === '"' || ansiCBody(text, index) !== -1)) {
index = skipQuoted(text, index);
continue;
}
if (openChar !== null && char === openChar) {
depth += 1;
} else if (char === closeChar) {
depth -= 1;
if (depth === 0) return [text.slice(start, index), index + 1];
}
index += 1;
}
return [text.slice(start), text.length];
}
/**
* Where the body of a `$'…'` string opening at `index` starts, or -1 when none opens there. The shell removes a
* backslash-newline before it reads quotes, so `$` and `'` split across a line continuation still open `$'…'`.
*/
function ansiCBody(text, index) {
if (text[index] !== "$") return -1;
let next = index + 1;
while (text.startsWith("\\\n", next)) next += 2;
return text[next] === "'" ? next + 1 : -1;
}
/** Index just past the `'…'`, `"…"` or `$'…'` string opening at `index`. */
function skipQuoted(text, index) {
if (text[index] === "'") {
const end = text.indexOf("'", index + 1);
return end < 0 ? text.length : end + 1;
}
const body = ansiCBody(text, index);
if (body !== -1) return readAnsiC(text, body)[1];
return readDoubleQuoted(text, index + 1)[1];
}
const SEGMENT_SEPARATORS = ";&|\n";
/** True when the separator at `index` is the `&` of `>&`, `<&` or `&>`, not a command boundary. */
function isRedirectionAmpersand(text, index) {
if (text[index] !== "&") return false;
const before = index > 0 ? text[index - 1] : "";
const after = index + 1 < text.length ? text[index + 1] : "";
return before === "<" || before === ">" || after === ">";
}
/**
* Read a `$(...)` or backtick command substitution opening at `index`.
* Both forms are expanded by bash inside double quotes as well as at top level, so this
* is called from both scanning states.
* @returns {[string, number]|null} inner text and next index, or null when none opens here.
*/
function readSubstitution(text, index) {
if (text.startsWith("$(", index)) return readDelimited(text, index + 2, "(", ")");
if (text[index] === "`") return readDelimited(text, index + 1, null, "`");
return null;
}
/**
* Read a `<(...)`, `>(...)` or zsh `=(...)` process substitution opening at `index`.
* Only the two-character opener triggers this; a bare `<`/`>` redirection is left alone
* and is not a segment separator. zsh runs the body of `=(...)` only when the opener starts
* a word, so `x=(a b)` stays an array.
* @returns {[string, number]|null} inner text and next index, or null when none opens here.
*/
function readProcessSubstitution(text, index) {
const char = text[index];
if ((char === "<" || char === ">") && text.startsWith("(", index + 1)) {
return readDelimited(text, index + 2, "(", ")");
}
const wordStart = index === 0 || WS_CHAR_RE.test(text[index - 1]) || ";&|(".includes(text[index - 1]);
if (wordStart && text.startsWith("=(", index)) return readDelimited(text, index + 2, "(", ")");
return null;
}
/**
* Locate unquoted segment separators and expanded substitution bodies.
* Command substitutions are read at top level and inside double quotes — bash expands
* them in both — while process substitutions are read at top level only. Nothing is read
* inside single quotes, where bash expands nothing. A `$'…'` string honours its backslash
* escapes, and the `&` of a redirection (`2>&1`, `&>/dev/null`) is not a separator.
* @returns {[number[], string[], boolean]} separator indices, substitution inner texts, and
* whether the text ends inside an unclosed quote or holds a `$'…'` string with an escape
* bash and zsh read differently.
*/
function scanTopLevel(text, substitutionSpans = null) {
const separators = [];
const substitutions = [];
let quote = null;
let unsettled = false;
let index = 0;
while (index < text.length) {
const char = text[index];
if (quote !== null) {
if (char === "\\" && quote !== "'") {
index += 2;
continue;
}
const read = quote === '"' ? readSubstitution(text, index) : null;
if (read !== null) {
substitutions.push(read[0]);
if (substitutionSpans !== null) substitutionSpans.push([read[0], index + (char === "`" ? 1 : 2)]);
index = read[1];
continue;
}
if (char === quote[quote.length - 1]) quote = null;
index += 1;
continue;
}
if (char === "\\") {
index += 2;
continue;
}
const body = ansiCBody(text, index);
if (char === "'" || char === '"' || body !== -1) {
quote = body === -1 ? char : "$'";
unsettled = unsettled || (body !== -1 && readAnsiC(text, body)[0].indexOf("\u0000") !== -1);
index = body === -1 ? index + 1 : body;
continue;
}
const read = readSubstitution(text, index) || readProcessSubstitution(text, index);
if (read !== null) {
substitutions.push(read[0]);
if (substitutionSpans !== null) substitutionSpans.push([read[0], index + (char === "`" ? 1 : 2)]);
index = read[1];
continue;
}
if (SEGMENT_SEPARATORS.indexOf(char) !== -1 && !isRedirectionAmpersand(text, index)) separators.push(index);
index += 1;
}
return [separators, substitutions, quote !== null || unsettled];
}
/** True when `command` or a substitution body in it ends inside an unclosed quote or has an unsettled escape. */
function quotingUnsettled(command) {
const scanned = scanTopLevel(command);
return scanned[2] || scanned[1].some(quotingUnsettled);
}
/**
* True when a `$(...)` body is really a `$(( ... ))` arithmetic expansion: the paren it
* opens closes at the very end of the body (`$((echo a); echo b)` is a command substitution).
*/
function isArithmetic(inner) {
if (!(inner.startsWith("(") && inner.endsWith(")"))) return false;
const read = readDelimited(inner, 1, "(", ")");
return read[1] === inner.length && read[0] === inner.slice(1, -1);
}
/**
* Position in `edges` of the separator closing the segment that starts after `edges[index]`.
* Inside `[[ ... ]]` the `&&` / `||` operators join conditions, not commands, so a `[[` test
* stays one segment; a `[[` never closed by a `]]` word leaves the segment unmerged.
*/
function conditionalEnd(command, edges, index) {
const end = index + 1;
const opener = commandWords(command.slice(edges[index] + 1, edges[end]));
if (opener.length === 0 || opener[0] !== "[[") return end;
for (let candidate = end; candidate < edges.length; candidate++) {
const words = command.slice(edges[index] + 1, edges[candidate]).split(WS_SPLIT_RE);
if (words.indexOf("]]") !== -1) return candidate;
}
return end;
}
/** Split a command into unquoted segments, including substitution bodies (recursively). */
function splitSegments(command) {
const scanned = scanTopLevel(command);
const edges = [-1].concat(scanned[0], [command.length]);
const segments = [];
let index = 0;
while (index < edges.length - 1) {
const end = conditionalEnd(command, edges, index);
const segment = command.slice(edges[index] + 1, edges[end]);
if (hasContent(segment)) segments.push(segment);
index = end;
}
for (const inner of scanned[1]) {
const bodies = isArithmetic(inner) ? scanTopLevel(inner)[1] : [inner];
for (const body of bodies) {
for (const segment of splitSegments(body)) segments.push(segment);
}
}
return segments;
}
// ── Danger filter ─────────────────────────────────────────────────────────────
const DANGER_COMMANDS = new Set(["rm", "dd", "chmod", "chown", "mkfs", "shutdown", "kill", "pkill"]);
// Commands that change what a command name runs, reach a builtin behind a prefix, fill a variable or array named on
// the command line, or run text built at run time. No shipped block runs one, so each is denied by name.
const REBIND_COMMANDS = new Set([
"hash",
"builtin",
"enable",
"disable",
"alias",
"unalias",
"functions",
"autoload",
"zmodload",
"emulate",
"mapfile",
"readarray",
"vared",
"zparseopts",
"getln",
"eval",
"source",
]);
// Deferring commands that can run a builtin, so `command . file` sources a file like `. file`. `.` is checked only as
// the first operand of one of these: elsewhere it is a directory (`find . -name x`).
const BUILTIN_RUNNERS = new Set(["command", "noglob", "nocorrect", "-", "time"]);
// `noglob`, `nocorrect` and `-` are zsh precommand modifiers: `noglob rm -rf x` runs `rm`.
const DEFERRING_COMMANDS = new Set([
"noglob",
"nocorrect",
"-",
"xargs",
"sudo",
"doas",
"env",
"nohup",
"time",
"timeout",
"nice",
"ionice",
"command",
"exec",
"watch",
"trap",
"find",
"uv",
"poetry",
"bash",
"sh",
"zsh",
"dash",
"ksh",
]);
const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
const SHELL_RESERVED_WORDS = new Set(["if", "then", "elif", "else", "while", "until", "do", "!"]);
const GIT_DANGER_TOKENS = new Set(["push", "commit", "reset", "revert", "restore", "clean"]);
const GIT_FORCE_TOKENS = new Set(["--force", "--force-with-lease", "-f"]);
const GIT_BRANCH_DELETE_TOKENS = new Set(["-D", "-d", "--delete"]);
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
/** Return a segment's command token (assignments skipped, directory dropped) and its arguments. */
function commandHead(tokens) {
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (ASSIGNMENT_RE.test(token)) continue;
return [token.slice(token.lastIndexOf("/") + 1), tokens.slice(index + 1)];
}
return ["", []];
}
/** True when a `git` segment mutates history, a remote, or the working tree. */
function gitIsDangerous(tokens) {
for (const token of tokens) {
if (GIT_DANGER_TOKENS.has(token) || GIT_FORCE_TOKENS.has(token)) return true;
}
const has = (word) => tokens.indexOf(word) !== -1;
if (has("worktree") && has("remove")) return true;
if (has("checkout") && (has("--") || has("."))) return true;
if (has("branch") && tokens.some((token) => GIT_BRANCH_DELETE_TOKENS.has(token))) return true;
return has("stash") && (has("drop") || has("clear"));
}
/** True when any token is a short-option cluster carrying `letter` (`-i`, `-ni`, `-i.bak`). */
function hasShortFlag(tokens, letter) {
const pattern = new RegExp("^-[A-Za-z]*" + letter);
return tokens.some((token) => pattern.test(token));
}
/** True when a `sed` segment rewrites its input files in place. */
function sedEditsInPlace(tokens) {
return hasShortFlag(tokens, "i") || tokens.some((token) => token.startsWith("--in-place"));
}
/** A command word as the shell runs it: backslashes and quotes removed, directory dropped (`\rm`, `r""m` → `rm`). */
function bareWord(token) {
const bare = token.replace(/[\\'"]/g, "");
return bare.slice(bare.lastIndexOf("/") + 1);
}
/**
* Option words after a segment's command word, and its operands. An option can take its argument as the next word
* (`set -o noglob -A …`, `read -t 5 -a …`), so every word before `--` starting with `-` or `+` counts as an option.
*/
function splitOptions(tokens) {
const rest = commandHead(tokens)[1];
const options = [];
const operands = [];
for (let index = 0; index < rest.length; index++) {
const word = rest[index];
if (word === "--") return [options, operands.concat(rest.slice(index + 1))];
((word.startsWith("-") || word.startsWith("+")) && word.length > 1 ? options : operands).push(word);
}
return [options, operands];
}
/** True when a `set` segment assigns an array (zsh `set -A name …` / `set +A name …`), whatever the name. */
function setAssignsArray(tokens) {
return splitOptions(tokens)[0].some((word) => word.includes("A"));
}
/**
* True when a `read` segment fills an array (`-a`/`-A`) or a variable whose name comes from an expansion. The tokens
* come from `shellWords`, which has already dropped redirections (`read -r v < "$f"` arrives as `read -r v`).
*/
function readAssignsByName(tokens) {
const [options, operands] = splitOptions(tokens);
if (options.some((word) => word.includes("a") || word.includes("A"))) return true;
return operands.some((word) => word.includes("$") || word.includes("`"));
}
/**
* True when a `printf`/`print` writes a variable through `-v`. Only leading options count for `-v`;
* a zsh `print` value option (`-u 2`) consumes the next word. This callback also covers resolved wrapped commands.
*/
function printAssignsVariable(tokens) {
const [head, rest] = commandHead(tokens);
const valueLetters = bareWord(head) === "print" ? "ufCxX" : "";
let index = 0;
while (index < rest.length) {
const word = rest[index];
if (word === "--" || !word.startsWith("-") || word.length < 2) return false;
if (word.includes("v")) return true;
index += valueLetters.includes(word[word.length - 1]) ? 2 : 1;
}
return false;
}
/** True when a declaring builtin makes a name reference (`-n`) or declares a name taken from an expansion. */
function declaresByName(tokens) {
const [options, operands] = splitOptions(tokens);
if (options.some((word) => word.startsWith("-") && word.includes("n"))) return true;
return operands.some((word) => {
const name = word.split("=", 1)[0];
return name.includes("$") || name.includes("`");
});
}
// Any mention of a table that maps a name to what it runs (zsh `commands`, `functions`, `aliases` and siblings,
// `fpath`/`FPATH`, bash `BASH_CMDS`/`BASH_ALIASES`), an attached option prefix included (`set -Acommands`).
const COMMAND_TABLE_USE_RE =
/(?<![A-Za-z0-9_./-])(?:[-+][A-Za-z]*)?(?:commands|functions|aliases|galiases|saliases|dis_[a-z_]+|fpath|FPATH|BASH_CMDS|BASH_ALIASES)(?![A-Za-z0-9_./-])/;
// An expansion that treats a variable's value as code or as a name: `${(P)n::=…}` and `${!n:=…}` assign through a
// held name; zsh `${(e)x}`, `${(%)x}`, `${(#)x}` and bash `${x@P}` evaluate the value; zsh `${~x}`/`$~x` globs it, so a
// `(e:…:)` qualifier in the value runs. Stored text is inert to the lexer, so each form is refused.
const INDIRECT_EXPANSION_RE = /\$\{(?:\([^)]*[Pe%#]|!)|\$\{[^}]*@P|\$\{?~/;
// Variables that decide which program a command name runs or which library it loads, which file a shell runs at
// start-up, text a shell expands as code later, how words split (`IFS`) or which options are on (zsh `options`).
// `sentinel-read-allow.js`'s SENSITIVE_VARS holds every other name here; `IFS` has its own check there.
const LOOKUP_VARIABLES =
"PATH|path|CDPATH|cdpath|BASH_ENV|ENV|ZDOTDIR|SHELLOPTS|BASHOPTS|GLOBIGNORE|PROMPT_COMMAND" +
"|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_[A-Z_]+|PS[0-4]|PROMPT[0-4]?|RPROMPT2?|RPS[12]|SPROMPT" +
"|(?:precmd|preexec|chpwd|periodic|zshexit|zshaddhistory)_functions|IFS|options";
// A word assigning a lookup variable (`PATH=…`, `path+=(…)`, `path[1]=…`), wherever it stands in a segment.
const LOOKUP_VARIABLE_ASSIGNMENT_RE = new RegExp("^(?:" + LOOKUP_VARIABLES + ")(?:\\[[^\\]]*\\])?\\+?=[\\s\\S]*$");
// A raw write is refused in every operand and quote state, before the existing position checks.
const LOOKUP_WRITE_TOKEN_RE = new RegExp(
"(?<![A-Za-z0-9_])(?:(?:\\+\\+|--)(?:" +
LOOKUP_VARIABLES +
")(?![A-Za-z0-9_])|(?:" +
LOOKUP_VARIABLES +
")(?:\\[[^\\]]*\\])?(?:\\+\\+|--|[ \\t\\n\\r\\v\\f]*(?:\\*\\*=|<<=|>>=|&&=|\\|\\|=|\\^\\^=|[+*/%&|^\\-]=|=(?!=))))",
"g",
);
const LOOKUP_WRITE_FLAG_RE = new RegExp("^(?:\\$?'|\\\")?--?(?:" + LOOKUP_VARIABLES + ")=(?!=)");
// Closed format language: string widths, literal percent signs and three text escapes only.
const PRINTF_TEXT_FORMAT_RE = /^(?:[^%\\]|%%|%(?:-?[0-9]+)?s|\\[nt\\])*$/;
// Closed inert-argument allowlist, mirrored in the generator and the shape lane.
const LOOKUP_TEXT_PROGRAMS = new Set([
"python",
"python3",
"node",
"git",
"gh",
"grep",
"rg",
"sed",
"awk",
"jq",
"cat",
"ls",
"find",
"mkdir",
"cp",
"mv",
"date",
"wc",
"sort",
"head",
"tail",
"tr",
"cut",
"diff",
"uv",
"pytest",
"pre-commit",
]);
// A word naming a lookup variable on its own, which assigns it only as an operand of a name-assigning command.
const LOOKUP_VARIABLE_NAME_RE = new RegExp("^(?:-[A-Za-z]*)?(?:" + LOOKUP_VARIABLES + ")(?:\\[[^\\]]*\\])?$");
// A lookup variable named inside arithmetic, read (`$PATH`) or assigned (`PATH`).
const LOOKUP_ARITHMETIC_NAME_RE = new RegExp("^\\$?(?:" + LOOKUP_VARIABLES + ")$");
// Commands that assign or unset a variable named by a bare operand; elsewhere a bare `path` is an ordinary argument.
// `print`/`printf` assign only through `-v` and `set` only through `-A`, which their own checks refuse; a loop
// keyword assigns only its loop variable.
const NAME_ASSIGNERS = new Set([
"read",
"getopts",
"unset",
"vared",
"zparseopts",
"zstyle",
"zformat",
"typeset",
"declare",
"local",
"export",
"readonly",
"integer",
"float",
"let",
]);
// Loop keywords whose next word is the variable each pass assigns.
const LOOP_KEYWORDS = new Set(["for", "foreach", "select"]);
// Commands that run the command word after them, past their own options (`command read PATH`).
const COMMAND_RUNNERS = new Set(["command", "builtin", "exec", "noglob", "nocorrect", "-", "time"]);
// The option letters a runner may carry, none taking an operand; any other (`exec -a NAME`) leaves the command word
// unknown, so the segment is refused.
const RUNNER_OPTION_LETTERS = { command: "pvV", exec: "cl", time: "p" };
// Commands whose operands are only text to print or match: an operand spelled like an assignment assigns nothing
// there. Any other command may read `NAME=value` operands as assignments (`env`, `export`, `make`).
const TEXT_COMMANDS = new Set(["echo", "printf", "print", "grep", "egrep", "fgrep", "rg"]);
// Commands that make every later assignment to a name arithmetic, and those that do so through an `-i` option.
const ARITHMETIC_COMMANDS = new Set(["integer", "float", "let"]);
const ATTRIBUTE_DECLARERS = new Set(["typeset", "declare", "local", "export", "readonly"]);
// A word opening an array assignment (`NAME=(`, `NAME+=(`) as written.
const ARRAY_ASSIGNMENT_WORD_RE = /^[A-Za-z_][A-Za-z0-9_]*\+?=\(/;
// A subscripted name (`a[…]`) and the subscripts that hold no arithmetic able to assign or run anything.
const SUBSCRIPT_RE = /[A-Za-z_][A-Za-z0-9_]*\[([^\]]*)\]/g;
const PLAIN_SUBSCRIPT_RE = /^(?:[0-9]+|[@*]|\$?[A-Za-z_][A-Za-z0-9_]*)$/;
// Shell options a block may switch, by exact unquoted name; any other (`GLOB_SUBST` in any spelling) is refused.
const SAFE_SHELL_OPTIONS = new Set(["pipefail", "errexit", "nounset", "nomatch"]);
const OPTION_SETTERS = new Set(["setopt", "unsetopt", "shopt"]);
// A `set` cluster limited to `e`/`u`/`x`/`a`/`o`; a shell cluster taking an option name next (`-o`, `-xo`, `-O`).
const SET_FLAG_CLUSTER_RE = /^[-+][euxao]+$/;
const SHELL_OPTION_CLUSTER_RE = /^[-+][A-Za-z]*[oO][A-Za-z]*$/;
const SHELL_LONG_OPTIONS = new Set(["--", "--norc", "--noprofile", "--login"]);
// The first character of a word's value (quotes and escapes resolved; expansions, tilde, globs and braces kept as
// written) that ends a shell's options: a literal letter, digit, `_`, `.` or `/`. Every word the shell builds from it
// starts with that character, so none can be a flag; any other lead (`'-o'`, `"$F"`, `~` with `HOME=-o`) may.
const OPTION_OPERAND_LEAD_RE = /^[A-Za-z0-9_./]/;
// An opener of command substitution or bash `$[…]` arithmetic, whose text runs when an expansion evaluates it.
const CODE_OPENER_RE = /\$[(\[]|`/;
// A heredoc operator in the only forms the reader models: `<<`/`<<-`, blanks, then an identifier delimiter, bare
// or wholly in one pair of quotes, ending the word. `<< \EOF`, `<<$'EOF'`, `<<EOF.` are refused.
const HEREDOC_OPENING_RE =
/<<(-?)[ \t]*(?:([A-Za-z_][A-Za-z0-9_]*)|'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)")(?=[ \t;&|<>)\n]|$)/y;
// The parameter a `${…}` expansion reads, its length form, its reading operators and a digit substring offset.
const PARAMETER_NAME_RE = /(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[?#@*$-])(?:\[(?:@|\*|[0-9]+)\])?/y;
const PARAMETER_LENGTH_RE = new RegExp("^#(?:" + PARAMETER_NAME_RE.source + ")$");
const PARAMETER_OPERATOR_RE = /^(?::-|:\+|-|\+|##|#|%%|%|\/\/|\/)/;
const PARAMETER_OFFSET_RE = /^:[0-9]+(?::[0-9]+)?$/;
// Arithmetic text that assigns, shifts or indexes, or spans lines; an operand (a number, name, `$NAME` or numeric
// special parameter `$?` `$#` `$$` `$!` `$-`, which `exit $?` already reads) and a binary operator.
const ARITHMETIC_REFUSED_RE = /\+\+|--|<<|>>|(?<![=!<>])=(?!=)|[\n,[]/;
const ARITHMETIC_OPERAND_RE = /[0-9]+|\$?[A-Za-z_][A-Za-z0-9_]*|\$[?#$!*@-]/y;
const ARITHMETIC_BINARY_RE = /&&|\|\||<=|>=|==|!=|[-+*/%<>?:]/y;
// Commands whose count operand zsh evaluates as arithmetic (bash reads a plain number; the Bash tool's shell is zsh
// on macOS): `shift`/`break`/`continue`/`return`/`exit` (zshbuiltins) and `repeat` (zshmisc).
const ARITHMETIC_COUNT_COMMANDS = new Set(["shift", "break", "continue", "return", "exit", "repeat"]);
// Comparison operators of `[[ … ]]` whose operands undergo arithmetic evaluation ("as if enclosed in $((…))"); `test`
// and `[` take integers only, so they are excluded.
const CONDITIONAL_ARITHMETIC_OPS = new Set(["-eq", "-ne", "-lt", "-le", "-gt", "-ge"]);
// Special parameters zsh integer-attributes by default (`typeset +i`), so a plain `NAME=value` assignment evaluates
// value as arithmetic (zshparam). bash integer-attributes only EUID/UID/PPID, so this is the zsh superset.
const INTEGER_PARAMETERS =
"ARGC|COLUMNS|EGID|ERRNO|EUID|FUNCNEST|GID|HISTCMD|HISTSIZE|KEYTIMEOUT|LINENO|LINES|LISTMAX|MAILCHECK" +
"|OPTIND|PPID|RANDOM|SAVEHIST|SECONDS|SHLVL|TRY_BLOCK_ERROR|TRY_BLOCK_INTERRUPT|TTYIDLE|UID|ZLE_RPROMPT_INDENT|ZSH_SUBSHELL";
const INTEGER_ASSIGNMENT_RE = new RegExp("^(?:" + INTEGER_PARAMETERS + ")\\+?=");
// An array-literal element key (`a=([key]=value)`), evaluated in arithmetic context.
const ARRAY_ELEMENT_KEY_RE = /\[([^\]]*)\]\+?=/g;
// A zsh `$name[…]` subscript, live inside double quotes too. Its arithmetic text is admitted only when made of names,
// numbers, `@`/`*`, reading operators, `.` and the `[` of a bracket class (shipped `"$PLUGIN[^/]*"`); increments and
// a lookup variable (LOOKUP_ARITHMETIC_NAME_RE) are refused within it.
const ZSH_SUBSCRIPTED_RE = /\$[A-Za-z_][A-Za-z0-9_]*\[([^\]]*)/y;
// What may follow a `$` in live text besides the `${`/`$(`/backtick openers read on their own: a name or digit, a
// special parameter, or a character that opens no expansion in bash or zsh. A quote also counts inside double quotes
// and heredoc bodies. Every other form (zsh `$~x` `$=x` `$^x`, `$[…]`) is outside the grammar. Mirrors `_DOLLAR_FOLLOW`.
const DOLLAR_FOLLOW_RE = /[A-Za-z0-9_?#@*$!-]|[ \t\n/.,:;|&)<>\\%}\]]|$/y;
const SUBSCRIPT_TEXT_RE = /^[A-Za-z0-9_@*^/!,.:?<>%+[-]*$/;
const SUBSCRIPT_REFUSED_RE = new RegExp("\\+\\+|--|(?<![A-Za-z0-9_])(?:" + LOOKUP_VARIABLES + ")(?![A-Za-z0-9_])");
// A word that a following `()` makes a function definition.
const FUNCTION_WORD_RE = /^[A-Za-z0-9_.:-]+$/;
// Reserved words after which a `(` still opens a subshell (`if (…)`, `! (…)`, `{ (…)`), as written.
const GROUP_OPENERS = new Set([...SHELL_RESERVED_WORDS, "{"]);
/** Hide ordinary single quotes while retaining double-quoted and ANSI-C expansion spellings. */
function maskSingleQuoted(text) {
const out = text.split("");
let index = 0;
let double = false;
while (index < text.length) {
if (text[index] === "\\") {
index += 2;
continue;
}
if (text[index] === '"') double = !double;
if (!double && text.startsWith("$'", index)) {
index = readAnsiC(text, index + 2)[1];
continue;
}
if (!double && text[index] === "'") {
const close = text.indexOf("'", index + 1);
const end = close < 0 ? text.length : close + 1;
out.fill(" ", index, end);
index = end;
continue;
}
index++;
}
return out.join("");
}
/** Detect command-table rebinding and indirect expansion outside ordinary single quotes. */
function writesCommandTable(command) {
const [text, live, unterminated] = splitHeredocs(command);
if (unterminated) return true;
const visible = maskSingleQuoted(text);
const unquoted = visible.replace(/[\\'"]/g, "");
return (
COMMAND_TABLE_USE_RE.test(visible) ||
COMMAND_TABLE_USE_RE.test(unquoted) ||
INDIRECT_EXPANSION_RE.test(visible) ||
live.some(writesCommandTable)
);
}
/** True when a `gh api` segment issues an HTTP DELETE. */
function ghApiDeletes(tokens) {
if (tokens.indexOf("api") === -1) return false;
const methods = [];
tokens.forEach((token, index) => {
if (token === "-X" || token === "--method") {
if (index + 1 < tokens.length) methods.push(tokens[index + 1]);
} else if (token.startsWith("--method=")) {
methods.push(token.slice("--method=".length));
} else if (token.startsWith("-X")) {
methods.push(token.slice(2).replace(/^=+/, ""));
}
});
return methods.some((method) => method.toUpperCase() === "DELETE");
}
/** True when a shell invocation runs the script piped into it (`curl … | sh`). */
function readsScriptFromStdin(args) {
if (hasShortFlag(args, "c")) return false;
const operands = args.filter((token) => !token.startsWith("-"));
return operands.length === 0 || hasShortFlag(args, "s") || args.indexOf("-") !== -1;
}
/** True when `unset` removes a function (`-f`, zsh `-F`) or pattern-matched names (zsh `-m`), like `unalias`. */
function unsetRemovesFunction(tokens) {
for (const word of tokens.slice(1)) {
if (word === "--" || !word.startsWith("-")) return false;
if (/^-[A-Za-z]*[fFm]/.test(word)) return true;
}
return false;
}
/** Command-specific destructive-use checks, keyed by command word and fed the segment's tokens. */
const TOOL_CHECKS = new Map([
["unset", unsetRemovesFunction],
["git", gitIsDangerous],
["sed", sedEditsInPlace],
["gh", ghApiDeletes],
["set", setAssignsArray],
["read", readAssignsByName],
["printf", printAssignsVariable],
["print", printAssignsVariable],
...["declare", "typeset", "local", "export", "readonly", "integer", "float"].map((name) => [name, declaresByName]),
]);
/**
* Find the word after a `NAME=value` assignment whose value may span several words.
* An unterminated quote, `(` or `{` falls back to skipping the assignment word alone.
*/
function assignmentEnd(words, start) {
let quote = "";
let depth = 0;
for (let index = start; index < words.length; index++) {
const word = words[index];
for (let at = 0; at < word.length; at++) {
const char = word[at];
if (char === "\\") {
at += 1;
} else if (quote) {
if (char === quote) quote = "";
} else if (char === "'" || char === '"' || char === "`") {
quote = char;
} else if (char === "(" || char === "{") {
depth += 1;
} else if ((char === ")" || char === "}") && depth) {
depth -= 1;
}
}
if (!quote && !depth) return index + 1;
}
return start + 1;
}
/** Whitespace words of a segment from its command word on (reserved words and assignments skipped). */
function commandWords(segment) {
const words = segment.split(WS_SPLIT_RE).filter((word) => word);
let index = 0;
while (index < words.length) {
const bare = stripChars(words[index], "(){}");
if (!bare || SHELL_RESERVED_WORDS.has(bare)) {
index += 1;
} else if (ASSIGNMENT_RE.test(bare)) {
index = assignmentEnd(words, index);
} else {
break;
}
}
return words.slice(index);
}
/** True when the arguments of a command that merely carries another command hold a destructive one. */
function deferredIsDangerous(head, rest) {
if (rest.indexOf("-delete") !== -1) return true;
if (SHELL_INTERPRETERS.has(head) && readsScriptFromStdin(rest)) return true;
for (const token of rest) {
const bare = bareWord(token);
if (DANGER_COMMANDS.has(bare) || REBIND_COMMANDS.has(bare)) return true;
}
if (BUILTIN_RUNNERS.has(head) && rest.find((token) => !token.startsWith("-")) === ".") return true;
// A command-specific check gets the words from the carried command word on (`timeout 5 set -A …` → `set -A …`).
for (let index = 0; index < rest.length; index++) {
const check = TOOL_CHECKS.get(bareWord(rest[index]));
if (check && check(rest.slice(index))) return true;
}
return false;
}
// ── Shell words ───────────────────────────────────────────────────────────────
// Mirrors the Python generator's `shell_words` lexer: each word's text as written (`raw`), the value the shell
// builds from it after quote removal (`value`), whether quoting took part (`quoted`), and whether an unquoted
// `*`, `?`, `[` or `{` can expand it (`pattern`). Redirections and their targets are dropped.
const WS_CHAR_RE = new RegExp("[" + PY_WS + "]");
// Shell word boundaries are narrower than normalization whitespace; unusual name characters must remain visible.
const SHELL_WORD_BLANK_RE = /[ \t\n]/;
// A redirection operator with its optional file-descriptor number: `2>`, `>>`, `<<<`, `&>`, `>&`, `<>`. The
// descriptor-allocating `{name}>` form is no prefix here: it assigns `name` a number, so the grammar scan refuses it
// (FD_VARIABLE_PREFIX_RE) and this reader keeps `{name}` as a word.
const REDIRECTION_OPERATOR_RE = /(?:[0-9]+|&)?(?:<<<|<<-?|<>|<&|>&|>>|>\||<|>)/y;
// A word written right against a redirection operator as a brace group (`{name}>`, `{IFS}<`): bash and zsh allocate a
// descriptor and assign its number to the name, a variable write no `=` shows. Only a digit descriptor is admitted.
const FD_VARIABLE_PREFIX_RE = /^\{[^}]*\}$/;
// A word read so far that opens an array assignment when `(` follows (`ARGS=(`), and a leading assignment word.
const ARRAY_ASSIGNMENT_OPEN_RE = /^[A-Za-z_][A-Za-z0-9_]*\+?=$/;
const ASSIGNMENT_WORD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/;
const VARIABLE_WORD_RE = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/;
const ANSI_C_ESCAPES = new Map(
Object.entries({ a: "\x07", b: "\b", e: "\x1b", E: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" }).concat(
Object.entries({ "\\": "\\", "'": "'", '"': '"', "?": "?" }),
),
);
// Only the hex escape is numeric in both shells alike; `\u…` is missing from bash 3.2.
const ANSI_C_NUMERIC = new Map([["x", [/^[0-9A-Fa-f]{1,2}/, 16]]]);
/** Body of a `$'…'` string from its first inner character, escapes resolved, and the index past its close. */
function readAnsiC(text, index) {
let out = "";
while (index < text.length) {
const char = text[index];
if (char === "'") return [out, index + 1];
if (char !== "\\") {
out += char;
index += 1;
continue;
}
const escape = text.slice(index + 1, index + 2);
index += 2;
if (ANSI_C_ESCAPES.has(escape)) {
out += ANSI_C_ESCAPES.get(escape);
} else if (ANSI_C_NUMERIC.has(escape) || (escape && "01234567".includes(escape))) {
const [pattern, base] = ANSI_C_NUMERIC.get(escape) || [/^[0-7]{0,2}/, 8];
const digits = pattern.exec(text.slice(index));
const number = (base === 8 ? escape : "") + (digits ? digits[0] : "");
out += number && parseInt(number, base) <= 0o377 ? String.fromCharCode(parseInt(number, base)) : "\u0000";
index += digits ? digits[0].length : 0;
} else {
// an escape bash and zsh decode differently (`\m`, `\u…`, `\c…`) reads as NUL, which is refused
out += "\u0000";
if (escape === "c" && index < text.length) index += text.codePointAt(index) > 0xffff ? 2 : 1;
}
}
return [out, text.length];
}
/** Index just past a `$(…)`, backtick, `${…}`, `<(…)` or `>(…)` opening at `index`, or null when none opens there. */
function readExpansion(text, index) {
if (text.startsWith("${", index)) return readDelimited(text, index + 2, "{", "}")[1];
const read = readSubstitution(text, index) || readProcessSubstitution(text, index);
return read === null ? null : read[1];
}
/** Body of a `"…"` string from its first inner character; a backslash escapes only `$`, backtick, `"`, `\`, newline. */
function readDoubleQuoted(text, index) {
let out = "";
while (index < text.length) {
const char = text[index];
if (char === '"') return [out, index + 1];
if (char === "\\") {
const escaped = text.slice(index + 1, index + 2);
out += '$`"\\'.includes(escaped) ? escaped : escaped === "\n" ? "" : char + escaped;
index += 2;
continue;
}
const end = readExpansion(text, index);
out += end !== null ? text.slice(index, end) : char;
index = end !== null ? end : index + 1;
}
return [out, text.length];
}
/** One quoted or escaped piece of a word as [value, next index], or null when none opens at `index`. */
function readQuoted(text, index) {
const char = text[index];
if (char === "\\") {
const escaped = text.slice(index + 1, index + 2);
return [escaped === "\n" ? "" : escaped, index + 2];
}
if (char === "'") {
let end = text.indexOf("'", index + 1);