trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! The command bodies a Bash command's substitutions run (#2745, #8756).
//!
//! Why: a rule that reads a segment's argv sees `echo "$(pm2 jlist)"` as
//! `echo` plus one quoted word, so the `pm2 jlist` the shell runs first never
//! reaches it. The forbidden-verb guard already decomposed substitutions
//! (`classify_command_substitutions`); the secret rules did not (#8756 round
//! 2). One scanner now serves both, so the two cannot disagree on which bytes
//! are a substitution body.
//! What: [`segment_substitutions`] lists the top-level `$( … )`, `<( … )`,
//! `>( … )` and backtick bodies of one segment that are live shell syntax.
//! [`command_substitutions`] does the same for a whole command: every segment
//! [`split_shell_segments`] yields (so an `sh -c` string is read too), plus
//! the `$( … )` and backticks of every unquoted-delimiter here-document body,
//! which the shell expands whoever runs it (#9155). Callers recurse into each
//! body themselves, which is what reaches a nested substitution.
//! Test: `substitutions_list_each_live_body_once`,
//! `substitutions_read_an_expanding_heredoc_body_only`,
//! `inert_heredoc_bodies_leave_the_argv_text`; the forbidden-verb callers in
//! `evaluate_bash_command_*`.

use std::borrow::Cow;
use std::ops::Range;

use super::heredoc::{HeredocBodies, blank_spans};
use super::heredoc_line::{line_names, line_reads_as_data};
use super::shell_lex::QuoteScan;
use super::{is_evaluator, split_shell_segments};

/// Maximum substitution recursion depth before a caller stops decomposing.
///
/// Why: adversarial deep nesting (`$($($(…`) would otherwise recurse without
/// bound and could exhaust the stack of the short-lived `tm hook --pm-guard`
/// process. The cap is generous — real commands nest a handful of levels.
/// What: the forbidden-verb guard denies past it; the #8756 secret rules read
/// the remaining text flattened instead.
pub(crate) const MAX_SUBSTITUTION_DEPTH: usize = 32;

/// One substitution body, as the shell would run it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Substitution {
    /// A balanced body: the text between the opener and its close.
    Closed(String),
    /// An opener with no close: every byte after it. Nothing later in the
    /// text is scanned, because the opener's extent is unknown.
    Unclosed(String),
}

impl Substitution {
    /// The body text, closed or not.
    pub(crate) fn text(&self) -> &str {
        match self {
            Self::Closed(text) | Self::Unclosed(text) => text,
        }
    }
}

/// Whether a paren-delimited substitution opens at byte `i`, and whether it is
/// live there given the segment's quote map.
///
/// Why (#2745): `$(…)`, `<(…)` and `>(…)` are one CLASS — bash executes the
/// body of each. They differ in one respect: double quotes suppress process
/// substitution (`echo "<(x)"` is literal text) but NOT command substitution.
/// What: `Some(true)` when a substitution opens at `i` and is live, `Some(false)`
/// when one opens but is quoted into literal text, `None` when none opens. On
/// unbalanced quotes every opener reads as live — the conservative direction.
/// Test: `evaluate_bash_command_denies_process_substitution_edit`,
/// `evaluate_bash_command_allows_quoted_process_substitution_prose`,
/// `evaluate_bash_command_allows_quoted_substitution_prose`.
pub(super) fn paren_substitution_live_at(scan: &QuoteScan, bytes: &[u8], i: usize) -> Option<bool> {
    if bytes.get(i + 1).copied() != Some(b'(') {
        return None;
    }
    match bytes[i] {
        b'$' => Some(!scan.balanced || scan.allows_substitution(i)),
        b'<' | b'>' => Some(!scan.balanced || scan.is_unquoted(i)),
        _ => None,
    }
}

/// The top-level substitution bodies of one segment, in order.
///
/// What: paren openers match their `)` by depth counting; a backtick matches
/// the next backtick. A quoted opener is skipped. An opener with no close ends
/// the scan with a [`Substitution::Unclosed`].
/// Test: `substitutions_list_each_live_body_once`,
/// `evaluate_bash_command_denies_unbalanced_substitution`.
pub(super) fn segment_substitutions(segment: &str) -> Vec<Substitution> {
    segment_substitution_spans(segment)
        .into_iter()
        .map(|(_, body)| body)
        .collect()
}

/// [`segment_substitutions`] with the byte range each substitution occupies
/// in `segment`, opener and close included (#8931: a caller that must know
/// which argv word a substitution lands in).
/// Test: `substitution_spans_cover_opener_and_close_8931`.
pub(crate) fn segment_substitution_spans(segment: &str) -> Vec<(Range<usize>, Substitution)> {
    let scan = QuoteScan::new(segment);
    scan_bodies(
        segment,
        scan.balanced,
        |bytes, i| paren_substitution_live_at(&scan, bytes, i),
        |i| !scan.balanced || scan.allows_substitution(i),
    )
}

/// The substitution walk shared by argv text and here-document bodies.
///
/// What: with `honour_escapes`, an opener behind an odd run of backslashes
/// is literal text (`\$(x)`), as the shell reads it (#8756).
fn scan_bodies(
    text: &str,
    honour_escapes: bool,
    paren_live: impl Fn(&[u8], usize) -> Option<bool>,
    backtick_live: impl Fn(usize) -> bool,
) -> Vec<(Range<usize>, Substitution)> {
    let bytes = text.as_bytes();
    let mut found = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        // Only an opener byte is checked, so each backslash run is counted once.
        if honour_escapes && matches!(bytes[i], b'$' | b'<' | b'>' | b'`') && escaped(bytes, i) {
            i += 1;
            continue;
        }
        if let Some(live) = paren_live(bytes, i) {
            if !live {
                i += 1;
                continue;
            }
            let (mut paren, mut j) = (1usize, i + 2);
            while j < bytes.len() && paren > 0 {
                match bytes[j] {
                    b'(' => paren += 1,
                    b')' => paren -= 1,
                    _ => {}
                }
                j += 1;
            }
            if paren != 0 {
                found.push((
                    i..text.len(),
                    Substitution::Unclosed(text[i + 2..].to_string()),
                ));
                return found;
            }
            found.push((i..j, Substitution::Closed(text[i + 2..j - 1].to_string())));
            i = j;
            continue;
        }
        if bytes[i] == b'`' && backtick_live(i) {
            let Some(len) = text[i + 1..].find('`') else {
                found.push((
                    i..text.len(),
                    Substitution::Unclosed(text[i + 1..].to_string()),
                ));
                return found;
            };
            let body = Substitution::Closed(text[i + 1..i + 1 + len].to_string());
            found.push((i..i + len + 2, body));
            i += len + 2;
            continue;
        }
        i += 1;
    }
    found
}

/// Whether byte `i` follows an odd run of backslashes, so it is escaped.
fn escaped(bytes: &[u8], i: usize) -> bool {
    bytes[..i].iter().rev().take_while(|b| **b == b'\\').count() % 2 == 1
}

/// Every top-level substitution body the shell runs while running `command`
/// (#8756).
///
/// What: data here-document bodies leave the argv text first, so a
/// quoted-delimiter body — literal text — contributes nothing; each remaining
/// segment contributes its [`segment_substitutions`]; every unquoted-delimiter
/// body, a shell-run one included (#9155), contributes every `$(` and
/// backtick in it, quotes included, since the body's quotes are literal
/// characters. A body found twice is listed once.
/// Test: `substitutions_read_an_expanding_heredoc_body_only`,
/// `substitutions_read_a_shell_run_expanding_heredoc_body_9155`.
pub(crate) fn command_substitutions(command: &str) -> Vec<Substitution> {
    let heredocs = HeredocBodies::scan(command);
    let spans: Vec<(usize, usize)> = heredocs.data().iter().map(|b| b.span).collect();
    let argv_text = blank_spans(command, &spans);
    let found: Vec<Substitution> = split_shell_segments(&argv_text)
        .iter()
        .flat_map(|segment| segment_substitutions(segment.trim()))
        .collect();
    with_expanded(found, command, heredocs.expanding())
}

/// `found` extended with the `$( … )` and backtick bodies each `expanding`
/// here-document body runs, read with the body's quotes as literal text, and
/// any body already listed dropped (#9155).
///
/// Why: a body that stays in the argv text (one a program runs as code) is
/// also read by the argv scan, so without the drop each of its substitutions
/// would be judged twice per level, which nested bodies multiply.
fn with_expanded(
    mut found: Vec<Substitution>,
    command: &str,
    expanding: &[(usize, usize)],
) -> Vec<Substitution> {
    for &(start, end) in expanding {
        // #9155: bash drops `\`+newline in the body before expanding it.
        let text = join_continuations(&command[start..end]);
        let bodies = scan_bodies(
            &text,
            true,
            |bytes, i| (bytes[i] == b'$' && bytes.get(i + 1) == Some(&b'(')).then_some(true),
            |_| true,
        );
        for (_, body) in bodies {
            if !found.contains(&body) {
                found.push(body);
            }
        }
    }
    found
}

/// `body` with each line continuation removed, as bash reads an unquoted
/// here-document body before expanding it (#9155).
///
/// Why: `$\`, newline, `(rm -rf /)` is `$(rm -rf /)` to bash, and the
/// literal scan found no opener.
/// What: drops a `\` and the newline after it when that `\` ends a run of
/// odd length; in an even run (`\\`, newline) the backslashes escape each
/// other and the newline stays.
/// Test: `substitutions_join_a_line_continuation_in_an_expanding_body_9155`.
fn join_continuations(body: &str) -> Cow<'_, str> {
    if !body.contains("\\\n") {
        return Cow::Borrowed(body);
    }
    let bytes = body.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut run = 0usize;
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'\\' {
            run += 1;
            if run % 2 == 1 && bytes.get(i + 1) == Some(&b'\n') {
                run = 0;
                i += 2;
                continue;
            }
        } else {
            run = 0;
        }
        out.push(bytes[i]);
        i += 1;
    }
    // Only ASCII `\` and `\n` bytes were removed, so the text stays UTF-8.
    Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}

/// `body`, an unquoted here-document body, as the shell hands it to the
/// program that reads it: `\\`, `\$` and `\`` lose their backslash and a
/// line continuation is removed (#9155). Every other `\` stays.
/// Test: `shell_run_heredoc_bodies_are_read_as_whole_commands_9155`.
fn unescape_expanding_body(body: &str) -> String {
    let mut out = String::with_capacity(body.len());
    let mut chars = body.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.peek() {
                Some('\\' | '$' | '`') => {
                    out.extend(chars.next());
                    continue;
                }
                Some('\n') => {
                    chars.next();
                    continue;
                }
                _ => {}
            }
        }
        out.push(c);
    }
    out
}

/// Programs whose here-document body a shell runs (`sudo -s`, `su`,
/// `parallel`), so the body is read as a command (#9155).
pub(crate) const HEREDOC_RUNNERS: &[&str] = &["parallel", "sudo", "doas", "su", "runuser"];

/// Whether a here-document operator line names one of `runners`; #9180: read
/// with the operator-line tokenizer, so `(sudo -s<<O` names `sudo`.
fn names_a_runner(line: &str, runners: &[&str]) -> bool {
    line_names(line, |word| runners.contains(&word))
}

/// The text of each here-document body a shell may run, as that shell
/// receives it (#9155).
///
/// Why: such a body is a command of its own, but the segment splitter cuts it
/// at every newline, so a here-document nested in it (`bash <<'O'` around
/// `bash <<I` around `echo '$(rm -rf /)'`) was never read whole and its
/// single-quoted substitution never expanded.
/// What: each body [`HeredocBodies::bodies`] lists whose operator line does
/// not read it as data ([`line_reads_as_data`]); an unquoted-delimiter body
/// is first unescaped as the outer shell does ([`unescape_expanding_body`]).
/// #9180: the list holds a body the scan claimed nothing for — one with no
/// terminator, read to the end of input, or one in a command whose quotes do
/// not balance — so a here-document nested in it is still read.
/// #9180 critic: fail-closed — a body is shell-run unless every program on
/// its operator line is a literal data reader, so `$(printf bas)h`, `b$X`, a
/// function, `.`, `source`, `eval` and `tcsh` run theirs.
/// Test: `shell_run_heredoc_bodies_are_read_as_whole_commands_9155`,
/// `shell_run_heredoc_bodies_include_an_unterminated_body_9180`,
/// `continuation_tests::the_floor_reads_every_unnamed_program_body_9180`.
pub(crate) fn shell_run_heredoc_bodies(command: &str) -> Vec<String> {
    HeredocBodies::scan(command)
        .bodies()
        .iter()
        // #9180: unknown programs run their body.
        .filter(|body| !line_reads_as_data(&command[body.operator_line.0..body.operator_line.1]))
        .map(|body| {
            let text = &command[body.span.0..body.span.1];
            if body.expands {
                unescape_expanding_body(text)
            } else {
                text.to_string()
            }
        })
        .collect()
}

/// Every `$( … )` and backtick body the unquoted here-documents of `command`
/// expand, a here-document nested in a shell-run body included (#9180).
///
/// Why: the forbidden-verb guard judged only substitutions its quote map called
/// live, and a here-document body's quotes are literal text, so
/// `cat <<X` around `'$(curl …)'` ran `curl` unjudged. The rm-root floor has
/// read these since #9155; this is the same reading for the other rules.
/// What: [`with_expanded`] over the command's expanding bodies, then the same
/// for each [`shell_run_heredoc_bodies`] body, recursively. `None` past
/// [`MAX_SUBSTITUTION_DEPTH`] levels, which a caller must deny.
/// Test: `heredoc_expansions_reach_a_nested_body_9180`.
pub(crate) fn heredoc_expansions(command: &str) -> Option<Vec<Substitution>> {
    let mut found = Vec::new();
    collect_expansions(command, 0, &mut found).then_some(found)
}

/// The walk behind [`heredoc_expansions`]; `false` past the depth cap.
fn collect_expansions(command: &str, depth: usize, found: &mut Vec<Substitution>) -> bool {
    if depth > MAX_SUBSTITUTION_DEPTH {
        return false;
    }
    let heredocs = HeredocBodies::scan(command);
    for body in with_expanded(Vec::new(), command, heredocs.expanding()) {
        if !found.contains(&body) {
            found.push(body);
        }
    }
    shell_run_heredoc_bodies(command)
        .iter()
        .all(|body| collect_expansions(body, depth + 1, found))
}

/// `command` with each here-document body that is stdin text blanked, and the
/// substitutions those blanked bodies still run (#8735 round 2).
///
/// Why: the delete floor read a backtick in a commit message
/// (`git commit -F - <<'EOF'`) as a live substitution and denied it. A body is
/// kept when its operator line runs it as code ([`runs_as_code`]) or names one
/// of `runners` — a program that runs its stdin as shell text.
/// What: blanks the other data bodies ([`blank_spans`]). Every
/// unquoted-delimiter body's `$( … )` and backticks are returned, read with
/// the body's quotes as literal text, whether the body was blanked or kept:
/// the shell expands them before any program reads the body (#9155). A kept
/// body stays in the argv text too, so the verb scan still sees what runs.
/// Test: `allows_a_backtick_in_a_quoted_heredoc_body`,
/// `blank_inert_heredocs_returns_every_expanding_body_9155`.
pub(crate) fn blank_inert_heredocs(command: &str, runners: &[&str]) -> (String, Vec<Substitution>) {
    let heredocs = HeredocBodies::scan(command);
    let spans: Vec<(usize, usize)> = heredocs
        .data()
        .iter()
        .filter(|b| {
            let line = &command[b.operator_line.0..b.operator_line.1];
            !runs_as_code(line) && !names_a_runner(line, runners)
        })
        .map(|b| b.span)
        .collect();
    // #9155: every expanding body, not only the blanked ones, reaches the floor.
    (
        blank_spans(command, &spans),
        with_expanded(Vec::new(), command, heredocs.expanding()),
    )
}

/// `command` with each here-document body that no program runs as code
/// blanked out (#8756).
///
/// Why: a commit message or PR body naming `pm2 jlist` is stdin text to `git`
/// or `cat`; read as argv, it made the dump rules refuse the command. A body
/// handed to an evaluator (`ssh host <<'EOF'`, `python3 <<'EOF'`) or a shell is
/// code, and stays.
/// What: blanks each data body whose operator line names no evaluator,
/// keeping every byte offset and newline.
/// Test: `inert_heredoc_bodies_leave_the_argv_text`.
pub(crate) fn without_inert_heredoc_bodies(command: &str) -> String {
    let inert: Vec<(usize, usize)> = HeredocBodies::scan(command)
        .data()
        .iter()
        .filter(|b| !runs_as_code(&command[b.operator_line.0..b.operator_line.1]))
        .map(|b| b.span)
        .collect();
    blank_spans(command, &inert)
}

/// Whether any word of an operator line is a program that runs its input;
/// #9180: read with the operator-line tokenizer, so `(python3 -<<PY` counts.
fn runs_as_code(line: &str) -> bool {
    line_names(line, |word| is_evaluator(&word.to_ascii_lowercase()))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn closed(texts: &[&str]) -> Vec<Substitution> {
        texts
            .iter()
            .map(|t| Substitution::Closed((*t).to_string()))
            .collect()
    }

    /// Each live form is one body; a nested one stays inside its parent; a
    /// single-quoted or double-quoted process substitution is text.
    #[test]
    fn substitutions_list_each_live_body_once() {
        let cases: &[(&str, Vec<Substitution>)] = &[
            ("echo \"$(pm2 jlist)\"", closed(&["pm2 jlist"])),
            ("echo `date` $(a $(b))", closed(&["date", "a $(b)"])),
            ("diff <(a) >(b)", closed(&["a", "b"])),
            ("echo '$(a)' \"<(b)\"", Vec::new()),
            ("echo $(a", vec![Substitution::Unclosed("a".to_string())]),
            // #8756: an escaped opener is literal; an escaped backslash is not.
            (r#"echo \$(a) "\`b\`" \\$(c)"#, closed(&["c"])),
        ];
        for (segment, want) in cases {
            assert_eq!(&segment_substitutions(segment), want, "{segment:?}");
        }
    }

    /// #8931: each span runs from the opener to its close, so the caller can
    /// map a substitution back onto the argv word it sits in.
    #[test]
    fn substitution_spans_cover_opener_and_close_8931() {
        let segment = "cat x$(a b)/y `c` \"$(d)\" $(e";
        let spans: Vec<&str> = segment_substitution_spans(segment)
            .into_iter()
            .map(|(range, _)| &segment[range])
            .collect();
        assert_eq!(spans, ["$(a b)", "`c`", "$(d)", "$(e"]);
    }

    /// An unquoted-delimiter body expands; a quoted one is literal.
    #[test]
    fn substitutions_read_an_expanding_heredoc_body_only() {
        assert_eq!(
            command_substitutions("cat <<EOF\nit's '$(a)' `b`\nEOF"),
            closed(&["a", "b"])
        );
        assert!(command_substitutions("cat <<'EOF'\n$(a)\nEOF").is_empty());
        assert_eq!(command_substitutions("bash -c 'echo $(a)'"), closed(&["a"]));
    }

    /// #9155: a body a shell or interpreter runs still expands, so its
    /// single-quoted `$(…)` and backtick are listed, each once; a quoted
    /// delimiter lists nothing.
    #[test]
    fn substitutions_read_a_shell_run_expanding_heredoc_body_9155() {
        assert_eq!(
            command_substitutions("bash <<X\necho '$(a)' `b` $(c)\nX"),
            closed(&["b", "c", "a"])
        );
        assert!(command_substitutions("bash <<'X'\necho '$(a)'\nX").is_empty());
    }

    /// #9155: the kept (code-run) body stays in the argv text, and its
    /// single-quoted substitution is returned beside it; a quoted delimiter
    /// returns nothing; an inert body is blanked and still returns its own.
    #[test]
    fn blank_inert_heredocs_returns_every_expanding_body_9155() {
        for kept in [
            "python3 - <<PY\nprint('$(a)')\nPY",
            "bash <<X\necho '$(a)'\nX",
        ] {
            let (argv, found) = blank_inert_heredocs(kept, &[]);
            assert_eq!(argv, kept);
            assert_eq!(found, closed(&["a"]), "{kept:?}");
        }
        let (_, found) = blank_inert_heredocs("python3 - <<'PY'\nprint('$(a)')\nPY", &[]);
        assert!(found.is_empty());
        let (argv, found) = blank_inert_heredocs("cat <<EOF\n'$(a)'\nEOF", &[]);
        assert!(!argv.contains("$(a)"));
        assert_eq!(found, closed(&["a"]));
    }

    /// #9155: `\`+newline inside an expanding body is removed before the
    /// scan, so `$\`, newline, `(a)` opens a substitution; an even backslash
    /// run keeps its newline, and a benign continuation lists nothing.
    #[test]
    fn substitutions_join_a_line_continuation_in_an_expanding_body_9155() {
        for command in [
            "cat <<X\n'$\\\n(a)'\nX",
            "python3 - <<PY\nprint('$\\\n(a)')\nPY",
        ] {
            assert_eq!(
                command_substitutions(command),
                closed(&["a"]),
                "{command:?}"
            );
        }
        for command in [
            "cat <<X\n'$\\\\\n(a)'\nX",
            "python3 - <<PY\nprint('a' \\\n  'b')\nPY",
            "cat <<'X'\n'$\\\n(a)'\nX",
        ] {
            assert!(command_substitutions(command).is_empty(), "{command:?}");
        }
    }

    /// #9155: a body a shell or runner runs is returned whole, an unquoted one
    /// unescaped as the outer shell does; a data body is not.
    #[test]
    fn shell_run_heredoc_bodies_are_read_as_whole_commands_9155() {
        assert_eq!(
            shell_run_heredoc_bodies("bash <<'O'\nbash <<I\nx\nI\nO"),
            ["bash <<I\nx\nI\n"]
        );
        assert_eq!(
            shell_run_heredoc_bodies("sudo -s <<O\necho \\$(a) \\\\ \\q\nO"),
            ["echo $(a) \\ \\q\n"]
        );
        assert!(shell_run_heredoc_bodies("cat <<'O'\nbash <<I\nx\nI\nO").is_empty());
    }

    /// #9180: an unterminated shell-run body is returned to the end of input,
    /// and a shell glued to its operator still runs its body.
    #[test]
    fn shell_run_heredoc_bodies_include_an_unterminated_body_9180() {
        assert_eq!(
            shell_run_heredoc_bodies("bash <<'O'\ncat <<I\nx\nI"),
            ["cat <<I\nx\nI"]
        );
        assert_eq!(shell_run_heredoc_bodies("(sudo -s<<'O'\nx\nO\n)"), ["x\n"]);
        assert!(shell_run_heredoc_bodies("cat <<'O'\nbash <<I\nx\nI").is_empty());
    }

    /// #9180: the expansions of a here-document nested in a shell-run body
    /// are listed; a quoted nested body lists none.
    #[test]
    fn heredoc_expansions_reach_a_nested_body_9180() {
        assert_eq!(
            heredoc_expansions("bash <<'O'\ncat <<I\n'$(a)'\nI\nO"),
            Some(closed(&["a"]))
        );
        assert_eq!(
            heredoc_expansions("bash <<'O'\ncat <<'I'\n'$(a)'\nI\nO"),
            Some(Vec::new())
        );
    }

    /// A body `cat` or `git` reads is blanked; one `ssh` or a shell runs stays.
    #[test]
    fn inert_heredoc_bodies_leave_the_argv_text() {
        let inert = "git commit -F - <<'EOF'\npm2 jlist\nEOF";
        assert!(!without_inert_heredoc_bodies(inert).contains("pm2"));
        for kept in [
            "ssh host <<'EOF'\npm2 jlist\nEOF",
            "/usr/bin/python3 - <<'EOF'\npm2 jlist\nEOF",
            "bash <<'EOF'\npm2 jlist\nEOF",
        ] {
            assert_eq!(without_inert_heredoc_bodies(kept), kept);
        }
    }
}