Skip to main content

vtcode_core/tools/tool_intent/
activity.rs

1//! Shell activity classification for progress accounting and output previews.
2//!
3//! Mutation safety remains owned by [`super::classify_tool_intent`]. This
4//! module adds the narrower distinction between repository inspection and
5//! verification without duplicating that safety decision in binary consumers.
6
7use std::path::Path;
8
9use serde_json::Value;
10use vtcode_commons::validation::NonEmptySlice;
11
12use crate::tools::command_args::shell_command_text_with_args;
13
14use super::readonly::{
15    command_words_are_readonly, static_shell_command_words, static_shell_command_words_with_output_plumbing,
16};
17
18/// Progress semantics for a command invocation.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum ShellActivity {
21    /// Read-only repository or environment inspection.
22    Inspection,
23    /// A build, test, lint, or compile command that verifies work.
24    Verification,
25    /// A command that may mutate state and is not primarily verification.
26    Mutation,
27}
28
29fn make_like_verification_targets(words: &[String]) -> bool {
30    let mut saw_target = false;
31    for word in words.iter().skip(1) {
32        let lower = word.to_ascii_lowercase();
33        if lower.starts_with('-') || lower.contains('=') {
34            continue;
35        }
36        if !matches!(lower.as_str(), "test" | "tests" | "check" | "checks" | "lint" | "verify" | "validate") {
37            return false;
38        }
39        saw_target = true;
40    }
41    saw_target
42}
43
44fn python_verification_arguments(arguments: &[String]) -> bool {
45    // Python executes the first target, not later verifier-like tokens.
46    let mut arguments = arguments.iter();
47    let target = arguments.find(|word| {
48        !matches!(word.as_str(), "-I" | "-E" | "-s" | "-S" | "-B" | "-O" | "-OO" | "-q" | "-u" | "-b" | "-bb" | "-v")
49    });
50    match target.map(String::as_str) {
51        Some("-m") => arguments.next().is_some_and(|module| module == "pytest"),
52        Some(script) => {
53            ["scripts/check_markdown.py", "scripts/check_docs_links.py"]
54                .iter()
55                .any(|known| Path::new(script).ends_with(known))
56                && arguments.next().is_none()
57        }
58        None => false,
59    }
60}
61
62fn is_verification_invocation(words: &[String]) -> bool {
63    // Verifiers may emit build artifacts; reject source-repair modes without
64    // applying the stricter inspection-only output restrictions to compilers.
65    if crate::tools::command_args::command_uses_source_repair(words)
66        || crate::tools::command_args::environment_prefix_has_injection_keys(words)
67    {
68        return false;
69    }
70    let command_words = crate::tools::command_args::command_words_after_environment_prefix(words);
71    let first = command_words.first().map(String::as_str).unwrap_or_default();
72    let second = command_words.get(1).map(|word| word.to_ascii_lowercase());
73    let third = command_words.get(2).map(|word| word.to_ascii_lowercase());
74    let program = Path::new(first)
75        .file_name()
76        .and_then(|name| name.to_str())
77        .unwrap_or(first)
78        .to_ascii_lowercase();
79
80    match program.as_str() {
81        "markdownlint-cli2" => command_words.get(1..).is_some_and(markdownlint_arguments_verify),
82        "npx" => {
83            let package_index = command_words
84                .iter()
85                .enumerate()
86                .skip(1)
87                .find(|(_, word)| {
88                    !matches!(word.as_str(), "--yes" | "-y" | "--no-install")
89                        && !word.strip_prefix("--loglevel=").is_some_and(|level| {
90                            matches!(
91                                level,
92                                "silent" | "error" | "warn" | "notice" | "http" | "info" | "verbose" | "silly"
93                            )
94                        })
95                })
96                .map(|(index, _)| index);
97            package_index.is_some_and(|index| {
98                let Some(package) = command_words.get(index) else {
99                    return false;
100                };
101                let is_markdownlint = package == "markdownlint-cli2"
102                    || package.strip_prefix("markdownlint-cli2@").is_some_and(|version| {
103                        version.split('.').count() == 3
104                            && version
105                                .split('.')
106                                .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
107                    });
108                is_markdownlint && command_words.get(index + 1..).is_some_and(markdownlint_arguments_verify)
109            })
110        }
111        "cargo" => {
112            if second.as_deref() == Some("fmt") {
113                // `cargo fmt` without `--check` reformats the worktree (mutation).
114                // With `--check` it is a read-only lint verification.
115                return words.iter().any(|word| word == "--check");
116            }
117            matches!(second.as_deref(), Some("check" | "build" | "clippy" | "test"))
118                || (second.as_deref() == Some("nextest") && third.as_deref() == Some("run"))
119        }
120        "go" => matches!(second.as_deref(), Some("test" | "build")),
121        "npm" | "pnpm" | "yarn" => {
122            matches!(second.as_deref(), Some("test" | "build" | "lint" | "check"))
123                || (second.as_deref() == Some("run")
124                    && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
125        }
126        "bun" | "bunx" => {
127            matches!(second.as_deref(), Some("test"))
128                || (second.as_deref() == Some("run")
129                    && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
130        }
131        "deno" => matches!(second.as_deref(), Some("test" | "lint" | "check")),
132        "make" | "gmake" | "just" => make_like_verification_targets(command_words),
133        "uv" => {
134            let Some(arguments) = crate::tools::command_args::uv_run_command_words(command_words) else {
135                return false;
136            };
137            let mut arguments = arguments.iter();
138            let target = arguments.next();
139            match target.map(String::as_str) {
140                Some("pytest") => true,
141                Some("ruff") => arguments.next().is_some_and(|argument| argument == "check"),
142                Some("python" | "python3") => python_verification_arguments(arguments.as_slice()),
143                _ => false,
144            }
145        }
146        "ruff" => {
147            if second.as_deref() == Some("format") {
148                return words.iter().any(|word| word == "--check");
149            }
150            matches!(second.as_deref(), Some("check" | "lint"))
151        }
152        "tsc" => {
153            // Bare `tsc` emits output (mutation). Only `--noEmit` type-checks.
154            words.iter().any(|word| word == "--noEmit")
155        }
156        "eslint" => {
157            // `eslint --fix` rewrites the worktree (mutation).
158            !words.iter().any(|word| word == "--fix" || word == "--fix-dry-run")
159        }
160        "rustc" | "pytest" | "xcodebuild" | "gradle" | "gradlew" => true,
161        "python" | "python3" => python_verification_arguments(&command_words[1..]),
162        _ if first.ends_with("/scripts/check.sh") || first.ends_with("/scripts/check-dev.sh") => true,
163        _ => false,
164    }
165}
166
167fn markdownlint_arguments_verify(arguments: &[String]) -> bool {
168    !arguments.is_empty()
169        && !arguments.iter().any(|argument| {
170            matches!(argument.as_str(), "--fix" | "-f" | "--help" | "-h" | "--version" | "-v")
171                || argument.starts_with("--fix=")
172        })
173}
174
175fn contains_verification_invocation(command: &str) -> bool {
176    static_shell_command_words(command)
177        .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
178}
179
180/// Recognize a verifier for diagnostic feedback, including status-masking tails
181/// such as `; echo $?` that cannot pass static verification admission.
182/// This heuristic grants no permissions, verification credit, or repair edits.
183pub fn shell_command_contains_verifier(args: &Value) -> bool {
184    let Some(command) = shell_command_text_with_args(args) else {
185        return false;
186    };
187    super::readonly::parse_shell_command_words(&command)
188        .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
189}
190
191/// Whether a command inspects search results that normally produce output.
192///
193/// Used only for progress accounting: an empty read-only search pipeline can
194/// exit successfully when its final filter masks the search's no-match status.
195/// Quiet/existence probes intentionally return no text and are excluded. This
196/// predicate grants no execution permissions and never verifies mutations.
197pub fn shell_command_is_output_search(args: &Value) -> bool {
198    let Some(command) = shell_command_text_with_args(args) else {
199        return false;
200    };
201    let Some(segments) = static_shell_command_words(&command) else {
202        return false;
203    };
204    let mut saw_search = false;
205    for words in &segments {
206        if !command_words_are_readonly(words) || is_verification_invocation(words) {
207            return false;
208        }
209        let words = crate::tools::command_args::command_words_after_environment_prefix(words);
210        if command_words_are_grep_search(words) {
211            if grep_words_request_quiet_output(words) {
212                return false;
213            }
214            saw_search = true;
215        }
216    }
217    saw_search
218}
219
220fn grep_words_request_quiet_output(words: &[String]) -> bool {
221    let is_rg = words
222        .first()
223        .and_then(|word| Path::new(word).file_name())
224        .is_some_and(|name| name == "rg");
225    let mut arguments = words.iter().skip(1);
226    let mut quiet = false;
227    while let Some(argument) = arguments.next() {
228        match argument.as_str() {
229            "--" => break,
230            "--quiet" | "--silent" => quiet = true,
231            "--no-quiet" if is_rg => quiet = false,
232            _ if argument.starts_with("--") => {
233                // Values can look like options, including patterns such as `-query`.
234                let takes_value = matches!(
235                    argument.as_str(),
236                    "--regexp"
237                        | "--file"
238                        | "--after-context"
239                        | "--before-context"
240                        | "--context"
241                        | "--max-count"
242                        | "--include"
243                        | "--exclude"
244                        | "--exclude-from"
245                        | "--exclude-dir"
246                        | "--label"
247                        | "--directories"
248                        | "--devices"
249                ) || (is_rg
250                    && matches!(
251                        argument.as_str(),
252                        "--glob"
253                            | "--iglob"
254                            | "--ignore-file"
255                            | "--pre-glob"
256                            | "--type"
257                            | "--type-not"
258                            | "--type-add"
259                            | "--type-clear"
260                            | "--replace"
261                            | "--encoding"
262                            | "--engine"
263                            | "--color"
264                            | "--colors"
265                            | "--threads"
266                            | "--max-depth"
267                            | "--max-columns"
268                            | "--max-filesize"
269                            | "--dfa-size-limit"
270                            | "--regex-size-limit"
271                            | "--context-separator"
272                            | "--field-context-separator"
273                            | "--field-match-separator"
274                            | "--hyperlink-format"
275                            | "--path-separator"
276                            | "--sort"
277                            | "--sortr"
278                            | "--generate"
279                    ));
280                if takes_value {
281                    arguments.next();
282                }
283            }
284            _ if argument.starts_with('-') => {
285                let mut flags = argument.chars().skip(1).peekable();
286                while let Some(flag) = flags.next() {
287                    if flag == 'q' {
288                        quiet = true;
289                    }
290                    let takes_value = matches!(flag, 'e' | 'f' | 'A' | 'B' | 'C' | 'm' | 'd')
291                        || (is_rg && matches!(flag, 'g' | 't' | 'T' | 'r' | 'E' | 'j' | 'M'))
292                        || (!is_rg && flag == 'D');
293                    if takes_value {
294                        if flags.peek().is_none() {
295                            arguments.next();
296                        }
297                        break;
298                    }
299                }
300            }
301            _ => {}
302        }
303    }
304    quiet
305}
306
307/// Whether exit code 1 belongs to a standalone grep-style search.
308/// Compound commands, shell wrappers and redirected diagnostics cannot prove
309/// the search's no-match status, even when their visible output is empty.
310pub fn shell_command_is_standalone_grep_search(args: &Value) -> bool {
311    let Some(command) = shell_command_text_with_args(args) else {
312        return false;
313    };
314    let Some(segments) = static_shell_command_words(&command) else {
315        return false;
316    };
317    let [words] = segments.as_slice() else {
318        return false;
319    };
320    shell_words::split(&command).is_ok_and(|raw_words| raw_words == *words)
321        && command_words_are_readonly(words)
322        && command_words_are_grep_search(words)
323}
324
325fn command_words_are_grep_search(words: &[String]) -> bool {
326    let words = crate::tools::command_args::command_words_after_environment_prefix(words);
327    let program = words
328        .first()
329        .and_then(|word| Path::new(word).file_name())
330        .and_then(|name| name.to_str());
331    matches!(program, Some("rg" | "grep" | "egrep" | "fgrep"))
332}
333
334/// Project markers checked in priority order before the Node tier: each
335/// entry names the files that select its verifier. First tier with any
336/// marker present wins, matching the previous sequential `if` chain.
337const PRE_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
338    (&["Cargo.toml"], "cargo check --locked"),
339    (&["go.mod"], "go test ./..."),
340    (&["deno.json", "deno.jsonc"], "deno test"),
341    (&["bun.lockb", "bun.lock"], "bun test"),
342];
343
344/// Project markers checked after the Node tier (`package.json` is resolved
345/// separately at its priority position for script sniffing).
346const POST_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
347    (&["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"], "pytest -q"),
348    (&["Makefile", "makefile"], "make test"),
349    (&["justfile", "Justfile"], "just test"),
350];
351
352/// First verifier whose tier has any marker file under `workspace_root`.
353fn verifier_for_markers(workspace_root: &Path, markers: &[(&[&str], &str)]) -> Option<String> {
354    markers.iter().find_map(|(files, verifier)| {
355        files
356            .iter()
357            .any(|file| workspace_root.join(file).is_file())
358            .then(|| (*verifier).to_string())
359    })
360}
361
362/// Node verifier with `package.json` script sniffing: `test` runs directly,
363/// other known scripts run via `npm run`, and a missing/unreadable script
364/// map falls back to `npm test` so the directive never names a command that
365/// does not exist. Returns `None` when there is no `package.json`.
366fn default_verifier_for_node_workspace(workspace_root: &Path) -> Option<String> {
367    let package_json = workspace_root.join("package.json");
368    if !package_json.is_file() {
369        return None;
370    }
371    if let Ok(content) = std::fs::read_to_string(&package_json)
372        && let Ok(parsed) = serde_json::from_str::<Value>(&content)
373        && let Some(scripts) = parsed.get("scripts").and_then(Value::as_object)
374    {
375        for (script, command) in [
376            ("test", "npm test"),
377            ("check", "npm run check"),
378            ("lint", "npm run lint"),
379            ("build", "npm run build"),
380        ] {
381            if scripts.contains_key(script) {
382                return Some(command.to_string());
383            }
384        }
385    }
386    Some("npm test".to_string())
387}
388
389/// Detect a project-appropriate default verifier for autonomous recovery.
390///
391/// Inspects well-known project markers under `workspace_root` and returns a
392/// concrete standalone verification command the agent can run via
393/// `exec_command` (no pipes, no `;`/`||` joins). Priority follows the
394/// existing `is_verification_invocation` coverage: Rust → Go → Deno/Bun →
395/// Node → Python → Make/Just. Returns `None` when no marker is found so
396/// callers can fall back to the generic verifier examples.
397///
398/// This is intentionally synchronous and allocation-light (existence checks
399/// plus one small `package.json` read): it runs on the turn hot path when
400/// the anti-blind-editing gate needs an actionable recovery directive.
401/// Codex-style harness-managed verification (Stop-hook test gates,
402/// auto-review) shows that naming the exact command — rather than listing
403/// examples — is what unblocks long-running autonomous work.
404pub fn default_verifier_for_workspace(workspace_root: &Path) -> Option<String> {
405    if let Some(verifier) = verifier_for_markers(workspace_root, PRE_NODE_VERIFIER_MARKERS) {
406        return Some(verifier);
407    }
408    if let Some(verifier) = default_verifier_for_node_workspace(workspace_root) {
409        return Some(verifier);
410    }
411    // No recognised project marker; callers fall back to generic examples.
412    verifier_for_markers(workspace_root, POST_NODE_VERIFIER_MARKERS)
413}
414
415/// Which shell forms of a verifier clear the anti-blind-editing gate. Shared
416/// by the recovery directive and the blocked-mutation `next_action` so the
417/// two surfaces cannot drift.
418pub const VERIFIER_SHELL_FORM_NOTE: &str = "Cap output with `max_output_tokens`. Pure `head`/`tail` verifier tails run standalone; static read-only filtering pipelines run with fail-closed `pipefail`. Only terminal exit 0 clears the gate; `;`, `||`, dynamic syntax, and mutating tails do not qualify.";
419
420/// Stand-in for the verifier when no project command was detected or
421/// configured. It lists examples across ecosystems instead of naming one
422/// command, because a single concrete fallback (such as a Cargo command in a
423/// Go or Node workspace) would direct the model to a verifier that does not
424/// exist.
425pub const GENERIC_VERIFIER_DESCRIPTION: &str =
426    "your project's build/test/lint command (e.g. `cargo check --locked`, `go test ./...`, `npm test`, or `pytest -q`)";
427
428/// How harness text names the verifier to run: the resolved command in
429/// backticks, or [`GENERIC_VERIFIER_DESCRIPTION`] when none was resolved.
430/// `default_verifier` should come from [`default_verifier_for_workspace`] or
431/// the harness override resolution built on it.
432pub fn verifier_reference(default_verifier: Option<&str>) -> String {
433    match default_verifier.map(str::trim).filter(|command| !command.is_empty()) {
434        Some(command) => format!("`{command}`"),
435        None => GENERIC_VERIFIER_DESCRIPTION.to_string(),
436    }
437}
438
439/// Build the actionable verification-recovery directive with a concrete
440/// command. `default_verifier` should come from
441/// [`default_verifier_for_workspace`]; when `None`, the generic examples are
442/// kept so the directive never names a command that does not exist.
443pub fn verification_recovery_directive(default_verifier: Option<&str>, attempt: u8, max_attempts: u8) -> String {
444    let verifier = verifier_reference(default_verifier);
445    format!(
446        "Verification recovery ({attempt}/{max_attempts}): pending edits have not been verified, so further mutations are blocked \
447        and the turn ends blocked unless a verifier exits 0. Run {verifier} with `exec_command`, standalone or as a pure `&&` chain of verifiers. \
448        {VERIFIER_SHELL_FORM_NOTE} A failed verifier grants a bounded number of fix-up edits before the next verification is required."
449    )
450}
451
452/// Return whether a shell tool call is an admitted truncation-only verification
453/// attempt while the anti-blind-editing gate is pending.
454///
455/// Piped verifiers (e.g. `cargo check 2>&1 | head -c 4000`) must be allowed to
456/// run so the model can see the failure; otherwise the generic "cap output
457/// with `| head`" guidance deadlocks on `Mutation blocked until verification`.
458/// Only a truthful successful verifier clears the gate; this helper only
459/// decides admission, never clearance.
460///
461/// Fail-closed smuggling guard: every parsed shell segment must be a
462/// verification invocation or an allow-listed readonly command. A chained
463/// mutation such as `cargo check && rm -rf target` therefore stays blocked
464/// instead of riding through on the verifier prefix. Unparseable (dynamic)
465/// shell syntax also stays blocked.
466pub fn shell_command_is_admitted_verification_attempt(args: &Value) -> bool {
467    let Some(command) = shell_command_text_with_args(args) else {
468        return false;
469    };
470    if crate::tools::command_args::contains_dynamic_shell_syntax(&command) {
471        return false;
472    }
473    if command.starts_with(VERIFIER_PIPEFAIL_PREFIX) {
474        return pipefail_verifier_pipeline(&command).is_some();
475    }
476    let segments =
477        static_shell_command_words(&command).or_else(|| static_shell_command_words_with_output_plumbing(&command));
478    let Some(segments) = segments else {
479        return false;
480    };
481    if segments.is_empty() {
482        return false;
483    }
484    let mut saw_verification = false;
485    for words in &segments {
486        if is_verification_invocation(words) {
487            saw_verification = true;
488        } else if !command_words_are_readonly(words) {
489            return false;
490        }
491    }
492    saw_verification
493}
494
495fn has_logical_sequencing(words: &[String]) -> bool {
496    words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
497}
498
499/// Split a command on top-level `|` operators, tracking single/double quotes
500/// and backslash escapes with the same discipline as
501/// [`shell_uses_only_and_chaining`]. Returns the pipe-separated stages in
502/// order, or `None` when no top-level pipe exists. `||` is not special-cased:
503/// it yields an empty stage, which stage validation rejects — fail-closed
504/// without a second operator table to keep in sync.
505fn split_top_level_pipes(command: &str) -> Option<Vec<&str>> {
506    if !command.contains('|') {
507        return None;
508    }
509    let mut stages = Vec::new();
510    let mut start = 0;
511    let mut saw_pipe = false;
512    let mut in_single_quote = false;
513    let mut in_double_quote = false;
514    let mut chars = command.char_indices().peekable();
515    while let Some((index, character)) = chars.next() {
516        // Outside single quotes a backslash escapes the next character for the
517        // shell, so `\|` is a literal pipe, not a stage separator. Consume the
518        // pair to stay aligned with the shell (mirrors the `&&`-chain scanner).
519        if character == '\\' && !in_single_quote {
520            chars.next();
521            continue;
522        }
523        if character == '\'' && !in_double_quote {
524            in_single_quote = !in_single_quote;
525            continue;
526        }
527        if character == '"' && !in_single_quote {
528            in_double_quote = !in_double_quote;
529            continue;
530        }
531        if in_single_quote || in_double_quote {
532            continue;
533        }
534        if character == '|' {
535            saw_pipe = true;
536            stages.push(&command[start..index]);
537            start = index + character.len_utf8();
538        }
539    }
540    if !saw_pipe {
541        return None;
542    }
543    stages.push(&command[start..]);
544    Some(stages)
545}
546
547/// Whether a pipeline tail stage is a pure output truncator: a bare `head` or
548/// `tail` invocation (any flags) with no shell operators of its own. Anything
549/// else (`grep`, `wc`, `sort`, redirects, backgrounding, chaining) keeps its
550/// pipeline semantics and must not be elided — dropping it would discard work
551/// the caller asked for or change what the exit status means.
552fn is_pure_truncation_stage(stage: &str) -> bool {
553    let trimmed = stage.trim();
554    if trimmed.is_empty() {
555        return false;
556    }
557    // A truncator stage is one simple command: reject any operator that would
558    // indicate chaining, backgrounding, redirection, or nesting. The scan is
559    // quote-aware so quoted flag values (e.g. `--sep=';'`) do not false-reject;
560    // an unaware scan could only over-reject, which stays fail-closed.
561    let mut in_single_quote = false;
562    let mut in_double_quote = false;
563    let mut chars = trimmed.chars().peekable();
564    while let Some(character) = chars.next() {
565        if character == '\\' && !in_single_quote {
566            chars.next();
567            continue;
568        }
569        if character == '\'' && !in_double_quote {
570            in_single_quote = !in_single_quote;
571            continue;
572        }
573        if character == '"' && !in_single_quote {
574            in_double_quote = !in_double_quote;
575            continue;
576        }
577        if in_single_quote || in_double_quote {
578            continue;
579        }
580        if matches!(character, ';' | '&' | '<' | '>' | '\n' | '|') {
581            return false;
582        }
583    }
584    let words = match shell_words::split(trimmed) {
585        Ok(words) => words,
586        Err(_) => return false,
587    };
588    let Some(parsed) = NonEmptySlice::from_slice(&words) else {
589        return false;
590    };
591    let program = Path::new(parsed.first())
592        .file_name()
593        .and_then(|name| name.to_str())
594        .unwrap_or(parsed.first().as_str())
595        .to_ascii_lowercase();
596    matches!(program.as_str(), "head" | "tail")
597}
598
599/// Rewrite a truncation-only piped verifier (`cargo check 2>&1 | head -c 4000`)
600/// to its standalone verifier prefix so the observed exit status is the
601/// verifier's, not the truncator's.
602///
603/// A pipeline's status belongs to its tail: running `cargo check | tail`
604/// reports `tail`'s success even when the build fails, and the model reads
605/// that exit 0 as "verified" while the anti-blind gate (correctly) stays
606/// pending — a deadlock manufactured by shell exit-status semantics, observed
607/// in real sessions. Eliding the truncator (output stays capped via
608/// `max_output_tokens`) makes the status truthful in one round trip.
609///
610/// Fail-closed gates, all required:
611/// - the full command is an admitted verification attempt (no dynamic
612///   syntax; every segment independently verification-or-readonly), so shapes
613///   like `cargo check | tail; rm …` can never reach the rewrite;
614/// - the head classifies as [`ShellActivity::Verification`] on its own, so
615///   `;`/`||`/background joins and smuggled mutations behind a verifier
616///   prefix are rejected (only standalone verifiers and pure `&&` verifier
617///   chains pass, whose `&&` exit status is truthful; output redirects such
618///   as `> build.log` are preserved verbatim in the head and do not mask the
619///   status, so they rewrite safely);
620/// - every tail stage is a pure `is_pure_truncation_stage` truncator, so
621///   `grep`/`wc`/`sort` tails (whose filtering is real work) are preserved.
622///
623/// Returns the standalone verifier text, or `None` when the command must run
624/// (or be rejected) exactly as typed. Only commands whose raw text carries a
625/// top-level `|` are candidates; array and indexed spellings re-quote
626/// operators when joined, so they never present a top-level pipe and keep
627/// today's behavior.
628pub fn rewrite_truncation_only_verifier(args: &Value) -> Option<String> {
629    use crate::config::constants::tools as tool_names;
630
631    let command = shell_command_text_with_args(args)?;
632    if !shell_command_is_admitted_verification_attempt(args) {
633        return None;
634    }
635    let stages = split_top_level_pipes(&command)?;
636    let (head, tails) = stages.split_first()?;
637    let head = head.trim();
638    if head.is_empty() || tails.iter().any(|stage| !is_pure_truncation_stage(stage)) {
639        return None;
640    }
641    let head_args = serde_json::json!({"cmd": head});
642    if !matches!(classify_shell_activity(tool_names::EXEC_COMMAND, &head_args), ShellActivity::Verification) {
643        return None;
644    }
645    Some(head.to_string())
646}
647
648// `&&` fails closed when the selected shell does not support pipefail.
649const VERIFIER_PIPEFAIL_PREFIX: &str = "set -o pipefail && ";
650
651/// The exact status-only shell setup, with the entire verifier pipeline validated.
652/// Command policy still checks every executable and explicit deny rule.
653pub(crate) fn pipefail_verifier_pipeline(command: &str) -> Option<&str> {
654    let pipeline = command.strip_prefix(VERIFIER_PIPEFAIL_PREFIX)?;
655    if crate::tools::command_args::contains_dynamic_shell_syntax(pipeline) {
656        return None;
657    }
658    verifier_pipeline_head(pipeline).map(|_| pipeline)
659}
660
661/// Validate a static pipeline without granting execution or mutation permissions.
662fn verifier_pipeline_head(command: &str) -> Option<&str> {
663    let stages = split_top_level_pipes(command)?;
664    let (head, tails) = stages.split_first()?;
665    let head = head.trim();
666    let head_args = serde_json::json!({"cmd": head});
667    if classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, &head_args) != ShellActivity::Verification
668    {
669        return None;
670    }
671    // Tail stages must be independently static and read-only. Logical joins,
672    // unsafe redirections, substitutions, and mutations cannot ride behind it.
673    for stage in tails {
674        let segments = static_shell_command_words(stage)?;
675        if segments.len() != 1 || !segments.first().is_some_and(|words| command_words_are_readonly(words)) {
676            return None;
677        }
678    }
679    Some(head)
680}
681
682/// Preserve filtering while making pipeline success depend on every stage.
683/// Pure truncator pipelines retain the existing standalone rewrite to avoid
684/// SIGPIPE from early-closing head/tail. Other static read-only tails use
685/// pipefail; failures (including no matches or SIGPIPE) never clear the gate.
686fn rewrite_verifier_pipeline(args: &Value) -> Option<String> {
687    if let Some(standalone) = rewrite_truncation_only_verifier(args) {
688        return Some(standalone);
689    }
690    let command = shell_command_text_with_args(args)?;
691    if command.starts_with(VERIFIER_PIPEFAIL_PREFIX)
692        || !shell_command_is_admitted_verification_attempt(args)
693        || verifier_pipeline_head(&command).is_none()
694    {
695        return None;
696    }
697    Some(format!("{VERIFIER_PIPEFAIL_PREFIX}{command}"))
698}
699
700/// Shell-call arguments as the execution kernel runs them.
701///
702/// The kernel applies [`rewrite_verifier_pipeline`] to every
703/// command-run call before execution, so the process that runs (and whose
704/// exit status the outcome reports) is the standalone verifier, not the typed
705/// pipeline. Static filtering pipelines instead enable pipefail. Gate
706/// bookkeeping must classify that same command; classifying the
707/// typed pipeline would call a truthful `cargo check 2>&1 | tail -5` success a
708/// mutation and leave the gate pending. Arguments the kernel runs unchanged
709/// (already-normalized arguments included, since the rewrite is idempotent)
710/// are borrowed as-is.
711pub fn shell_args_as_executed<'a>(tool_name: &str, args: &'a Value) -> std::borrow::Cow<'a, Value> {
712    if !super::is_command_run_tool_call(tool_name, args) {
713        return std::borrow::Cow::Borrowed(args);
714    }
715    let Some(rewritten) = rewrite_verifier_pipeline(args) else {
716        return std::borrow::Cow::Borrowed(args);
717    };
718    let mut executed = args.clone();
719    match executed.as_object_mut() {
720        Some(payload) => {
721            if payload.get("raw_command").is_some_and(Value::is_string) {
722                payload.insert("raw_command".to_string(), Value::String(rewritten.clone()));
723            }
724            // The rewrite already includes the suffix (or elides its truncator).
725            // Keeping args would append it a second time at launch.
726            payload.remove("args");
727            payload.insert("command".to_string(), Value::String(rewritten));
728            std::borrow::Cow::Owned(executed)
729        }
730        None => std::borrow::Cow::Borrowed(args),
731    }
732}
733
734fn is_known_inspection(words: &[String]) -> bool {
735    if words
736        .iter()
737        .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
738    {
739        return false;
740    }
741    command_words_are_readonly(words)
742}
743
744fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
745    let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
746        (segments, false)
747    } else {
748        (static_shell_command_words_with_output_plumbing(command)?, true)
749    };
750    if has_output_plumbing && segments.len() != 1 {
751        return None;
752    }
753    let has_multiple_segments = segments.len() > 1;
754    let mut saw_verification = false;
755
756    for words in segments {
757        if is_verification_invocation(&words) {
758            saw_verification = true;
759        } else if !command_words_are_readonly(&words) {
760            return None;
761        }
762    }
763
764    if has_output_plumbing && !saw_verification {
765        return None;
766    }
767
768    // Shell execution does not guarantee that a pipeline or logical chain's
769    // final status reflects every verification stage. Do not let a successful
770    // downstream command clear the anti-blind checkpoint after an earlier
771    // verifier failed.
772    //
773    // Exception: a pure `&&` chain of verification (or readonly) segments
774    // short-circuits on first failure, so its exit status does represent every
775    // stage. `cargo fmt --check && cargo check --locked && cargo nextest run`
776    // must clear the gate; `;`, `||`, `|`, and background `&` still mask
777    // failures and stay `Mutation`.
778    if saw_verification && has_multiple_segments {
779        if shell_uses_only_and_chaining(command) {
780            return Some(ShellActivity::Verification);
781        }
782        return Some(ShellActivity::Mutation);
783    }
784
785    Some(if saw_verification {
786        ShellActivity::Verification
787    } else {
788        ShellActivity::Inspection
789    })
790}
791
792fn has_shell_sequence(command: &str) -> bool {
793    static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
794}
795
796/// Quote-aware check that a shell command chains segments only with `&&`.
797///
798/// Returns `false` when an unquoted `;`, newline, `||`, single `&`
799/// (background), or `|` (pipeline) is present, since those operators let a
800/// downstream success mask an earlier verifier failure. `&&` short-circuits,
801/// so a pure `&&` chain of verifiers has a faithful aggregate exit status and
802/// may clear the anti-blind-editing gate. Backslash escapes outside single
803/// quotes are honored, so a shell-literal `\"` cannot open a phantom quote
804/// state that hides a later live operator.
805fn shell_uses_only_and_chaining(command: &str) -> bool {
806    let chars: Vec<char> = command.chars().collect();
807    let mut index = 0;
808    let mut in_single_quote = false;
809    let mut in_double_quote = false;
810
811    while index < chars.len() {
812        let character = chars[index];
813        // Outside single quotes a backslash escapes the next character for the
814        // shell: `\"` is a literal quote (no quote-state change) and `\;` is an
815        // inert character, not an operator. Skip the pair so the scanner stays
816        // aligned with the shell and fails closed.
817        if character == '\\' && !in_single_quote && index + 1 < chars.len() {
818            index += 2;
819            continue;
820        }
821        if character == '\'' && !in_double_quote {
822            in_single_quote = !in_single_quote;
823            index += 1;
824            continue;
825        }
826        if character == '"' && !in_single_quote {
827            in_double_quote = !in_double_quote;
828            index += 1;
829            continue;
830        }
831        if in_single_quote || in_double_quote {
832            index += 1;
833            continue;
834        }
835        match character {
836            // Token extraction walks nested commands and drops negation;
837            // neither shape proves each check actually returned success.
838            ';' | '\n' | '|' | '!' | '(' | ')' => return false,
839            '&' => {
840                let next_is_and = chars.get(index + 1) == Some(&'&');
841                if !next_is_and {
842                    return false;
843                }
844                // A `||`-style `|` was already rejected above; `&&` consumes
845                // both characters. `|||`, `&&&`, and similar malformed
846                // sequences fail closed.
847                let third = chars.get(index + 2);
848                if third == Some(&'&') || third == Some(&'|') {
849                    return false;
850                }
851                index += 2;
852                continue;
853            }
854            _ => {}
855        }
856        index += 1;
857    }
858
859    true
860}
861
862/// Match required checks within a statically proven, status-preserving chain.
863/// This is evidence accounting only; execution and permission gates still apply.
864pub(crate) fn shell_command_covers_required_check(command: &str, required: &str) -> bool {
865    if !shell_uses_only_and_chaining(command) || !shell_uses_only_and_chaining(required) {
866        return false;
867    }
868    let Some(segments) = static_shell_command_words(command) else {
869        return false;
870    };
871    let Some(required_segments) = static_shell_command_words(required) else {
872        return false;
873    };
874    if segments.iter().any(|words| {
875        words.first().is_some_and(|word| word == "cd")
876            || (!is_verification_invocation(words) && !command_words_are_readonly(words))
877    }) {
878        return false;
879    }
880    segments
881        .windows(required_segments.len())
882        .any(|window| window == required_segments)
883}
884
885/// Classify a shell call without weakening the authoritative mutation guard.
886///
887/// Standalone output plumbing such as `2>&1` or `> build.log` does not turn a
888/// primary verification command into a mutation for progress accounting.
889/// Pipelines and `;`/`||` chains remain mutations because their final
890/// status does not reliably represent every verification stage. Pure `&&`
891/// chains of verification-or-readonly segments are `Verification` since `&&`
892/// short-circuits on first failure.
893#[must_use]
894pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
895    let command = shell_command_text_with_args(args);
896    if command.is_none() && crate::tools::command_args::raw_command_text(args).is_some() {
897        return ShellActivity::Mutation;
898    }
899    if command.as_deref().and_then(pipefail_verifier_pipeline).is_some() {
900        return ShellActivity::Verification;
901    }
902    let words = crate::tools::command_args::command_words(args).ok().flatten();
903    let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);
904
905    if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
906        return activity;
907    }
908    // A failed sequence proof cannot fall back to the original tool intent:
909    // appended arguments may make an otherwise read-only tail mutate state.
910    if has_unclassified_shell_sequence {
911        return ShellActivity::Mutation;
912    }
913
914    let intent = super::classify_tool_intent(tool_name, args);
915
916    if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
917        return ShellActivity::Inspection;
918    }
919
920    let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
921    let contains_verification =
922        starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
923    if !intent.mutating {
924        return if contains_verification {
925            ShellActivity::Verification
926        } else {
927            ShellActivity::Inspection
928        };
929    }
930
931    if starts_with_verification
932        && !has_unclassified_shell_sequence
933        && !words.as_deref().is_some_and(has_logical_sequencing)
934    {
935        ShellActivity::Verification
936    } else {
937        ShellActivity::Mutation
938    }
939}
940
941/// Whether an exec result creates workspace mutation debt. This is accounting
942/// only: permission and admission still use the stricter static classifiers.
943/// Unknown syntax or commands retain debt; changed-file evidence always wins.
944#[must_use]
945pub fn exec_mutation_should_incur_debt(command: &str, output: &Value) -> bool {
946    if exec_output_has_changes(output) {
947        return true;
948    }
949    let mut parser = tree_sitter::Parser::new();
950    if parser.set_language(&tree_sitter_bash::LANGUAGE.into()).is_err() {
951        return true;
952    }
953    let Some(tree) = parser.parse(command, None) else {
954        return true;
955    };
956    let root = tree.root_node();
957    root.has_error() || root.named_child_count() == 0 || !nonmutating_shell_node(root, command)
958}
959
960pub(crate) fn exec_output_has_changes(output: &Value) -> bool {
961    output
962        .get("modified_files")
963        .and_then(Value::as_array)
964        .is_some_and(|files| !files.is_empty())
965        || crate::tools::file_ops::diff_output_has_effective_change(output) == Some(true)
966}
967
968/// Path-prefixed globs cannot expand into options. Keep this narrower than
969/// general expansion: variables, substitutions, bare globs and dynamic heads
970/// remain unproven, and the parent command still checks program/options.
971fn is_literal_path_glob(word: &str) -> bool {
972    let Some((prefix, _)) = word.split_once('/') else {
973        return false;
974    };
975    !prefix.starts_with('-')
976        && !prefix.contains(['*', '?', '[', ']'])
977        && word.contains(['*', '?'])
978        && !word.contains(['[', ']'])
979        && !word
980            .chars()
981            .any(|character| character.is_whitespace() || "\"'$`\\{}()<>&;|~!".contains(character))
982}
983
984/// Walk every supported node, including commands nested in substitutions.
985/// Unlike command extraction, this proof must never discard unknown syntax.
986fn nonmutating_shell_node(node: tree_sitter::Node<'_>, source: &str) -> bool {
987    use crate::command_safety::shell_parser::contains_dynamic_shell_syntax;
988    let Ok(text) = node.utf8_text(source.as_bytes()) else {
989        return false;
990    };
991    match node.kind() {
992        "command" => {
993            let Some(name) = node.child_by_field_name("name") else {
994                return false;
995            };
996            let Ok(name_text) = name.utf8_text(source.as_bytes()) else {
997                return false;
998            };
999            if contains_dynamic_shell_syntax(name_text) {
1000                return false;
1001            }
1002            let mut words = Vec::new();
1003            let mut cursor = node.walk();
1004            for child in node.named_children(&mut cursor) {
1005                let Ok(word) = child.utf8_text(source.as_bytes()) else {
1006                    return false;
1007                };
1008                if child.kind() == "variable_assignment" {
1009                    return false;
1010                }
1011                if child != name
1012                    && child.kind() == "word"
1013                    && is_literal_path_glob(word)
1014                    && matches!(name_text, "rg" | "grep" | "egrep" | "fgrep" | "ls" | "cat" | "head" | "tail" | "wc")
1015                {
1016                    // Classification sees a path operand; expansion cannot
1017                    // produce flags, while static mutating options stay visible.
1018                    words.push(word.to_owned());
1019                } else if contains_dynamic_shell_syntax(word) {
1020                    // Only these builtins cannot turn a dynamic argument into
1021                    // an option that writes or executes code. Keep arguments
1022                    // quoted so expansion cannot alter their dimensionality.
1023                    if !matches!(name_text, "echo" | "test") || !word.starts_with('"') {
1024                        return false;
1025                    }
1026                    words.push("dynamic-argument".to_owned());
1027                } else {
1028                    let Ok(parts) = shell_words::split(word) else {
1029                        return false;
1030                    };
1031                    words.extend(parts);
1032                }
1033            }
1034            if crate::tools::command_args::has_unsafe_readonly_options(&words)
1035                || (!is_verification_invocation(&words) && !command_words_are_readonly(&words))
1036            {
1037                return false;
1038            }
1039        }
1040        "file_redirect" => {
1041            // Exact AST spans, never substring replacement: a filename such
1042            // as /dev/null.backup must not be mistaken for descriptor plumbing.
1043            return matches!(text, "2>&1" | "1>&2") || (cfg!(unix) && text == "2>/dev/null");
1044        }
1045        "word" | "regex" => return !contains_dynamic_shell_syntax(text) || is_literal_path_glob(text),
1046        "raw_string" | "string_content" | "comment" | "number" | "variable_name" | "special_variable_name" => {
1047            return true;
1048        }
1049        "simple_expansion" => return true,
1050        "expansion" => {
1051            // Support the literal prefix/suffix removal used by link probes;
1052            // other parameter operators and arithmetic remain unproven.
1053            if text.contains([':', '=', '+', '?', '!']) {
1054                return false;
1055            }
1056        }
1057        "program"
1058        | "list"
1059        | "pipeline"
1060        | "for_statement"
1061        | "do_group"
1062        | "if_statement"
1063        | "elif_clause"
1064        | "else_clause"
1065        | "while_statement"
1066        | "command_name"
1067        | "string"
1068        | "concatenation"
1069        | "command_substitution"
1070        | "redirected_statement" => {}
1071        _ if !node.is_named() => {
1072            return matches!(
1073                text,
1074                "&&" | "||"
1075                    | ";"
1076                    | "|"
1077                    | "for"
1078                    | "in"
1079                    | "do"
1080                    | "done"
1081                    | "if"
1082                    | "then"
1083                    | "elif"
1084                    | "else"
1085                    | "fi"
1086                    | "while"
1087                    | "until"
1088                    | "\""
1089                    | "'"
1090                    | "$("
1091                    | ")"
1092                    | "`"
1093                    | "$"
1094                    | "${"
1095                    | "}"
1096                    | "#"
1097                    | "##"
1098                    | "%"
1099                    | "%%"
1100            );
1101        }
1102        _ => return false,
1103    }
1104    let mut cursor = node.walk();
1105    node.children(&mut cursor).all(|child| nonmutating_shell_node(child, source))
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use serde_json::json;
1111
1112    use super::*;
1113    use crate::config::constants::tools;
1114    use crate::tools::tool_intent::is_readonly_command_session_command;
1115
1116    fn exec_command(command: &str) -> Value {
1117        json!({"cmd": command})
1118    }
1119
1120    #[test]
1121    fn source_repair_commands_create_debt_instead_of_verifying() {
1122        for command in [
1123            "cargo clippy --fix --allow-dirty",
1124            "env LD_PRELOAD=./rewrite.so cargo clippy",
1125            "cargo +nightly clippy --fix",
1126            "cargo fix --allow-dirty",
1127            "ruff check --fix .",
1128            "ruff check --fix-only .",
1129            "ruff check --add-noqa .",
1130            "ruff check --add-ignore=reason .",
1131            "uv run --frozen --offline ruff check --fix .",
1132            "cargo clippy --fix && cargo check",
1133            "ruff check --fix . | head -20",
1134            // Do not treat an operand value as a safe preview flag.
1135            "ruff check --fix --exclude --diff .",
1136        ] {
1137            let args = exec_command(command);
1138            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1139            assert!(!shell_command_is_admitted_verification_attempt(&args), "{command}");
1140            assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code": 0})), "{command}");
1141        }
1142        for command in [
1143            "cargo clippy --locked",
1144            "cargo nextest run fix",
1145            "ruff check --no-fix .",
1146            "ruff check --diff .",
1147            "uv run --frozen --offline ruff check --no-fix .",
1148        ] {
1149            let args = exec_command(command);
1150            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Verification, "{command}");
1151            assert!(shell_command_is_admitted_verification_attempt(&args), "{command}");
1152            assert!(!exec_mutation_should_incur_debt(command, &json!({"exit_code": 0})), "{command}");
1153        }
1154    }
1155
1156    #[test]
1157    fn compiler_artifact_writes_remain_eligible_verification() {
1158        let command = "rustc --crate-type lib sample.rs -o checked.rlib";
1159        let args = exec_command(command);
1160        assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Verification);
1161        assert!(shell_command_is_admitted_verification_attempt(&args));
1162        // The inspection proof still sees a write; verifier eligibility owns
1163        // the build-artifact exemption, not the read-only permission boundary.
1164        assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code":0})));
1165    }
1166
1167    #[test]
1168    fn verifier_diagnostics_recognize_masked_status_without_granting_admission() {
1169        for command in [
1170            "npx markdownlint-cli2 README.md; echo \"lint exit: $?\"",
1171            "cargo check; echo $?",
1172            "python3 scripts/check_markdown.py; echo \"lint exit: $?\"",
1173            "cargo check --locked || true",
1174            "cargo check; rm marker",
1175        ] {
1176            let args = exec_command(command);
1177            assert!(shell_command_contains_verifier(&args), "{command}");
1178            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation);
1179        }
1180        let masked = exec_command("npx markdownlint-cli2 README.md; echo \"lint exit: $?\"");
1181        assert!(!shell_command_is_admitted_verification_attempt(&masked));
1182        assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &masked).as_ref(), &masked);
1183        for args in [
1184            exec_command("echo 'cargo check; echo $?'"),
1185            exec_command("grep 'cargo check' README.md"),
1186            exec_command("npx markdownlint-cli2 --fix README.md"),
1187            json!({"cmd":"cargo check; true", "raw_command":"echo hello"}),
1188            json!({"cmd":"npx markdownlint-cli2", "args":["--fix", "README.md"]}),
1189        ] {
1190            assert!(!shell_command_contains_verifier(&args), "{args}");
1191        }
1192    }
1193
1194    #[test]
1195    fn markdownlint_verification_preserves_command_boundaries() {
1196        for command in [
1197            "markdownlint-cli2 README.md",
1198            "npx --yes markdownlint-cli2 README.md",
1199            "npx --no-install markdownlint-cli2@0.23.3 README.md",
1200            "npx --yes --loglevel=error markdownlint-cli2@0.23.3 --config .markdownlint-cli2.jsonc ':README.md'",
1201            "npx -y markdownlint-cli2 README.md && cargo check --locked",
1202            "python3 scripts/check_markdown.py",
1203            "python ./scripts/check_markdown.py",
1204            "python3 scripts/check_docs_links.py",
1205            "python ./scripts/check_docs_links.py",
1206        ] {
1207            assert!(shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
1208            assert_eq!(
1209                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1210                ShellActivity::Verification,
1211                "{command}"
1212            );
1213        }
1214        for command in [
1215            "markdownlint-cli2 --fix README.md",
1216            "npx --yes markdownlint-cli2 -f README.md",
1217            "npx --yes markdownlint-cli2 --fix=true README.md",
1218            "markdownlint-cli2 --version",
1219            "npx --package evil markdownlint-cli2 README.md",
1220            "npx --loglevel=error --package evil markdownlint-cli2 README.md",
1221            "npx --loglevel=error markdownlint-cli2 --fix README.md",
1222            "npx --loglevel=unknown markdownlint-cli2 README.md",
1223            "npx markdownlint-cli2@evil README.md",
1224            "npx markdownlint-cli2 README.md && rm README.md",
1225            "python3 scripts/check_markdown.py --fix",
1226            "python3 scripts/check_markdown.py --list",
1227            "python3 scripts/check_markdown.py --help",
1228            "python3 other/check_markdown.py",
1229            "python3 scripts/check_docs_links.py --help",
1230            "python3 other/check_docs_links.py",
1231        ] {
1232            assert!(!shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
1233        }
1234        assert_ne!(
1235            classify_shell_activity(tools::EXEC_COMMAND, &exec_command("npx markdownlint-cli2 README.md | grep error")),
1236            ShellActivity::Verification
1237        );
1238    }
1239
1240    #[test]
1241    fn output_search_recognizes_static_search_pipelines() {
1242        for command in [
1243            "rg --files --hidden -g '.markdownlint*'",
1244            "rg --files --hidden | rg -i 'markdownlint' | sed -n '1,15p'",
1245            "git ls-files | grep -i markdownlint | head -10",
1246            "LANG=C /usr/bin/grep -n missing README.md",
1247            "rg -- '-q' README.md",
1248            "rg -e -query src | head -10",
1249            "grep -ne-query README.md",
1250            "rg --regexp --quiet src",
1251            "rg -g '-q*' missing src",
1252            "rg '-g-q*' missing src",
1253            "rg -q --no-quiet missing src",
1254        ] {
1255            assert!(shell_command_is_output_search(&exec_command(command)), "{command}");
1256        }
1257        assert!(shell_command_is_output_search(&json!({"command": ["rg", "missing", "src"]})));
1258    }
1259
1260    #[test]
1261    fn output_search_excludes_quiet_probes_and_unproven_commands() {
1262        for command in [
1263            "rg -q pattern src",
1264            "grep -nq pattern README.md",
1265            "rg --quiet pattern src | head -10",
1266            "grep --silent pattern README.md",
1267            "rg -e -query -q src",
1268            "grep -nqe-query README.md",
1269            "rg --regexp --quiet --quiet src",
1270            "rg -q --no-quiet -q missing src",
1271            "echo 'rg missing'",
1272            "cat empty.txt",
1273            "cargo check | rg error",
1274            "rg missing src; touch result.txt",
1275            "rg missing $(pwd)",
1276            "rg missing src > result.txt",
1277            "rg --pre 'touch result.txt' missing src",
1278        ] {
1279            assert!(!shell_command_is_output_search(&exec_command(command)), "{command}");
1280        }
1281        assert!(!shell_command_is_output_search(&json!({})));
1282    }
1283
1284    #[test]
1285    fn output_search_distinguishes_separator_values_from_quiet_flags() {
1286        for option in [
1287            "--context-separator",
1288            "--field-context-separator",
1289            "--field-match-separator",
1290        ] {
1291            let command = format!("rg {option} -q missing src | head -10");
1292            assert!(shell_command_is_output_search(&exec_command(&command)), "{command}");
1293            let quiet_command = format!("rg {option} -q --quiet missing src | head -10");
1294            assert!(!shell_command_is_output_search(&exec_command(&quiet_command)), "{quiet_command}");
1295        }
1296    }
1297
1298    #[test]
1299    fn standalone_grep_search_preserves_exit_status_ownership() {
1300        for command in [
1301            "rg missing src",
1302            "grep -q missing README.md",
1303            "LANG=C /usr/bin/grep -n missing README.md",
1304            "rg 'a|b' src",
1305            "fgrep missing README.md",
1306        ] {
1307            assert!(shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1308        }
1309        assert!(shell_command_is_standalone_grep_search(&json!({"command": ["rg", "missing", "src"]})));
1310        for command in [
1311            "rg missing src; false",
1312            "rg missing src && false",
1313            "rg missing src | grep other",
1314            "! rg missing src",
1315            "(rg missing src)",
1316            "echo '/rg missing'; false",
1317            "rg missing src 2>/dev/null",
1318            "rg missing src 2>&1",
1319            "rg missing $(pwd)",
1320            "rg --pre 'false' missing src",
1321        ] {
1322            assert!(!shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1323        }
1324    }
1325
1326    #[test]
1327    fn admitted_verification_attempt_allows_truncation_but_blocks_smuggled_mutations() {
1328        for command in [
1329            "cargo check --locked 2>&1 | head -c 4000",
1330            "cargo check --locked",
1331            "cargo nextest run 2>&1 | head -c 4000",
1332        ] {
1333            assert!(
1334                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1335                "expected admission: {command}"
1336            );
1337        }
1338        for command in [
1339            "cargo check && rm -rf target",
1340            "cargo check; rm foo.txt",
1341            "cargo check || rm foo.txt",
1342            "cargo check && cargo test && rm foo.txt",
1343            "sed -i '' 's/old/new/' README.md",
1344            "echo $(date)",
1345            "cargo check > build.log && rm foo.txt",
1346        ] {
1347            assert!(
1348                !shell_command_is_admitted_verification_attempt(&exec_command(command)),
1349                "expected block: {command}"
1350            );
1351        }
1352        assert!(!shell_command_is_admitted_verification_attempt(&json!({})));
1353    }
1354
1355    #[test]
1356    fn logged_compound_inspection_commands_are_not_mutations() {
1357        for command in [
1358            "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
1359            "wc -l README.md; rg -n '^#' README.md",
1360            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
1361        ] {
1362            assert_eq!(
1363                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1364                ShellActivity::Inspection,
1365                "{command}"
1366            );
1367        }
1368    }
1369
1370    #[cfg(unix)]
1371    #[test]
1372    fn captured_read_commands_with_output_suppression_are_inspection() {
1373        for command in [
1374            r#"sed -n '1,180p' README.md; sed -n '280,350p' README.md; sed -n '389,411p' README.md; printf '\n--- repo metadata ---\n'; git log -1 --format='%h %s'; sed -n '1,100p' Cargo.toml; rg -n '^version\s*=|rust-version|workspace\.package' Cargo.toml crates -g Cargo.toml | head -40"#,
1375            r#"sed -n '1,120p' crates/codegen/vtcode-core/src/tools/tool_intent/activity.rs; printf '\n--- readonly policy ---\n'; rg -n 'READONLY_UNIFIED_EXEC_COMMANDS|command_words_are_readonly' crates/codegen/vtcode-core/src/tools/tool_intent/readonly.rs; printf '\n--- recent commits ---\n'; git log -5 --oneline; printf '\n--- command arguments ---\n'; sed -n '1,180p' crates/codegen/vtcode-core/src/tools/command_args.rs"#,
1376            r###"git diff --stat; find docs -maxdepth 2 -type f | sort | head -40; rg -n "vtcode init|vtcode models|full-auto|run-debug|cargo install" docs/user-guide docs/installation docs/development 2>/dev/null | head -50"###,
1377        ] {
1378            assert_eq!(
1379                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1380                ShellActivity::Inspection,
1381                "{command}"
1382            );
1383        }
1384    }
1385
1386    #[cfg(unix)]
1387    #[test]
1388    fn printf_output_safety_guards_remain_mutations() {
1389        for command in [
1390            "printf 'captured output\\n' > output.txt",
1391            "printf '%s\\n' \"$(git status --short)\"",
1392            "printf '%s\\n' `git status --short`",
1393            "printf '\\n--- inspection ---\\n' && rm output.txt",
1394        ] {
1395            let args = exec_command(command);
1396            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1397            assert!(!is_readonly_command_session_command(&args), "unexpected readonly command: {command}");
1398        }
1399    }
1400
1401    #[test]
1402    fn git_diff_check_chain_remains_inspection() {
1403        assert_eq!(
1404            classify_shell_activity(
1405                tools::EXEC_COMMAND,
1406                &exec_command("git diff --check && git status --short && git diff --stat"),
1407            ),
1408            ShellActivity::Inspection
1409        );
1410    }
1411
1412    #[test]
1413    fn verification_detection_skips_environment_prefixes() {
1414        for command in [
1415            "env RUSTFLAGS=-Dwarnings cargo check",
1416            "RUSTFLAGS=-Dwarnings env cargo check",
1417            "env -u PATH cargo check",
1418            "env -C /tmp cargo check",
1419        ] {
1420            assert_eq!(
1421                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1422                ShellActivity::Verification,
1423                "{command}"
1424            );
1425        }
1426    }
1427
1428    #[test]
1429    fn ambiguous_or_mutating_compounds_remain_mutations() {
1430        for command in [
1431            "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
1432            "cat README.md; sed -i '' 's/a/b/' README.md",
1433            "sed --in-place= README.md",
1434            "git diff --output=out",
1435            "git diff '--output=out'",
1436            "git diff -o out",
1437            "git diff -oout",
1438            "git log --output=out",
1439            "git show --textconv",
1440            // `-C <dir> <read-only sub>` is an Inspection now (the redirect
1441            // only changes which repository is read); config injection stays
1442            // a Mutation.
1443            "git -c core.fsmonitor=touch status",
1444            "find . -fprint output.txt",
1445            "find . -fprintf output.txt '%p'",
1446            "rg --hostname-bin sh pattern",
1447            "rg --search-zip pattern",
1448            "rg -z pattern",
1449            "sort -o generated.txt README.md",
1450            "sort --compress-program=sh README.md",
1451            "date -s now",
1452            "awk -i inplace '{print}' README.md",
1453            "sed -n 's/a/b/e' README.md",
1454            "fd --exec sh -c 'touch output'",
1455            "tree -o output.txt",
1456            "ast-grep -r 'README.md'",
1457            "sed -n -fmalicious.sed -e '1p' src/main.rs",
1458            "sed -I '' 's/a/b/' src/main.rs",
1459            "sed -n '1p\nw leaked.txt' src/main.rs",
1460            "cargo check & rm output",
1461            "cargo check > build.log | rm output",
1462            "cargo check | head -40 > build.log",
1463            "cargo check | echo x > output.log",
1464            "cargo check | head -40",
1465            "cargo check > build.log &",
1466            "env -S 'cargo check'",
1467            "echo x > output.log && cargo check",
1468            "cargo check < build-input.log",
1469            "cat README.md > copied.txt",
1470            "git diff --check; rm output",
1471            "cat README.md\nrm output",
1472        ] {
1473            assert_eq!(
1474                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1475                ShellActivity::Mutation,
1476                "{command}"
1477            );
1478        }
1479    }
1480
1481    #[test]
1482    fn quoted_output_text_does_not_change_inspection_classification() {
1483        for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
1484            assert_eq!(
1485                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1486                ShellActivity::Inspection,
1487                "{command}"
1488            );
1489        }
1490    }
1491
1492    #[test]
1493    fn cargo_fmt_check_is_verification_but_plain_fmt_is_not() {
1494        for command in [
1495            "cargo fmt --check",
1496            "cargo fmt --all -- --check",
1497            "cargo fmt -- --check",
1498        ] {
1499            assert_eq!(
1500                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1501                ShellActivity::Verification,
1502                "{command}"
1503            );
1504            assert!(
1505                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1506                "expected admission: {command}"
1507            );
1508        }
1509        // Plain `cargo fmt` rewrites the worktree: it must stay a mutation and
1510        // must not ride through the verification gate.
1511        assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command("cargo fmt")), ShellActivity::Mutation);
1512        assert!(!shell_command_is_admitted_verification_attempt(&exec_command("cargo fmt")));
1513    }
1514
1515    #[test]
1516    fn pure_and_chained_verifiers_are_verification() {
1517        for command in [
1518            "cargo fmt --all -- --check && cargo check --locked",
1519            "cargo check --locked && cargo nextest run --locked -p vtcode-ui",
1520            "cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
1521            "git diff --check && cargo check --locked",
1522        ] {
1523            assert_eq!(
1524                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1525                ShellActivity::Verification,
1526                "{command}"
1527            );
1528            assert!(
1529                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1530                "expected admission: {command}"
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn non_and_chained_verifiers_remain_mutations() {
1537        for command in [
1538            "cargo check --locked; cargo nextest run --locked -p vtcode-ui",
1539            "cargo check --locked || cargo nextest run --locked -p vtcode-ui",
1540            "cargo check --locked | head -40",
1541            "cargo check --locked && cargo nextest run --locked -p vtcode-ui | head -40",
1542            "cargo check --locked &",
1543        ] {
1544            assert_eq!(
1545                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1546                ShellActivity::Mutation,
1547                "{command}"
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn expanded_verifiers_classify_as_verification() {
1554        for command in [
1555            "bun test",
1556            "bun run test",
1557            "deno lint",
1558            "deno check mod.ts",
1559            "make test",
1560            "make lint",
1561            "just verify",
1562            "ruff check src/",
1563            "ruff format --check",
1564            "tsc --noEmit",
1565            "eslint src/",
1566            "python3 -m pytest",
1567            "uv run pytest",
1568            "npm run lint",
1569        ] {
1570            assert_eq!(
1571                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1572                ShellActivity::Verification,
1573                "{command}"
1574            );
1575        }
1576        for command in [
1577            "make clean",
1578            "make test clean",
1579            "just fmt",
1580            "tsc",
1581            "eslint --fix src/",
1582            "ruff format src/",
1583            "bun install",
1584            "uv sync",
1585        ] {
1586            assert_eq!(
1587                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1588                ShellActivity::Mutation,
1589                "{command}"
1590            );
1591        }
1592    }
1593
1594    #[test]
1595    fn escaped_quotes_fail_closed_instead_of_hiding_operators() {
1596        // A backslash-escaped quote is a literal for the shell, so the trailing
1597        // `;` is a live separator: the aggregate exit status can mask a failed
1598        // verifier, and the chain must not classify as pure `&&`.
1599        assert!(!shell_uses_only_and_chaining("cargo check --locked \\\"; echo ok"));
1600        assert!(!shell_uses_only_and_chaining("echo \\\" ; cargo check --locked && echo done"));
1601        // Escaped quotes inside real double quotes stay inert.
1602        assert!(shell_uses_only_and_chaining("echo \"a\\\"b\" && cargo check --locked"));
1603        assert!(shell_uses_only_and_chaining("echo \"path\" && cargo fmt --check"));
1604        // Backslash is literal inside single quotes.
1605        assert!(shell_uses_only_and_chaining("echo 'a\\b' && cargo check --locked"));
1606    }
1607
1608    #[test]
1609    fn default_verifier_prefers_manifest_priority_order() {
1610        let dir = tempfile::tempdir().expect("tempdir");
1611        assert_eq!(default_verifier_for_workspace(dir.path()), None);
1612
1613        std::fs::write(dir.path().join("justfile"), "test:\n\techo ok\n").expect("justfile");
1614        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("just test"));
1615        std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1616        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("pytest -q"));
1617        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1618        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1619        std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1620        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1621        std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
1622        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("cargo check --locked"));
1623    }
1624
1625    #[test]
1626    fn default_verifier_marker_aliases_and_tier_priority() {
1627        // Every table row resolves, including alternate spellings — a
1628        // dropped alias in the marker table must fail here, not in a
1629        // live session. Each fixture names a different row.
1630        for (marker, expected) in [
1631            ("Justfile", "just test"),
1632            ("makefile", "make test"),
1633            ("bun.lock", "bun test"),
1634            ("deno.jsonc", "deno test"),
1635        ] {
1636            let dir = tempfile::tempdir().expect("tempdir");
1637            std::fs::write(dir.path().join(marker), "x\n").expect(marker);
1638            assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some(expected), "{marker}");
1639        }
1640        // Cross-tier priority: earlier tiers win over later ones.
1641        let dir = tempfile::tempdir().expect("tempdir");
1642        std::fs::write(dir.path().join("bun.lockb"), "x\n").expect("bun.lockb");
1643        std::fs::write(dir.path().join("deno.json"), "{}\n").expect("deno.json");
1644        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("deno test"));
1645        std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1646        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1647        // The Node tier (script sniffing) sits between Bun and Python.
1648        let dir = tempfile::tempdir().expect("tempdir");
1649        std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1650        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1651        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1652        // Unrecognized scripts keep the `npm test` fallback.
1653        let dir = tempfile::tempdir().expect("tempdir");
1654        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"start":"serve"}}"#).expect("package.json");
1655        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1656    }
1657
1658    #[test]
1659    fn default_verifier_reads_npm_script_fallbacks() {
1660        let dir = tempfile::tempdir().expect("tempdir");
1661        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"lint":"eslint ."}}"#).expect("package.json");
1662        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm run lint"));
1663    }
1664
1665    #[test]
1666    fn verification_recovery_directive_names_concrete_command() {
1667        let directive = verification_recovery_directive(Some("go test ./..."), 1, 2);
1668        assert!(directive.contains("go test ./..."));
1669        assert!(directive.contains("1/2"));
1670        assert!(directive.contains("max_output_tokens"));
1671        assert!(directive.contains(VERIFIER_SHELL_FORM_NOTE));
1672        assert!(directive.contains("tails run standalone"));
1673        assert!(directive.contains("fail-closed `pipefail`"));
1674        let fallback = verification_recovery_directive(None, 2, 2);
1675        assert!(fallback.contains(GENERIC_VERIFIER_DESCRIPTION));
1676        assert!(fallback.contains("2/2"));
1677    }
1678
1679    #[test]
1680    fn verifier_reference_names_resolved_command_or_generic_description() {
1681        assert_eq!(verifier_reference(Some("go test ./...")), "`go test ./...`");
1682        assert_eq!(verifier_reference(Some("  npm test ")), "`npm test`");
1683        for missing in [None, Some(""), Some("   ")] {
1684            let reference = verifier_reference(missing);
1685            assert_eq!(reference, GENERIC_VERIFIER_DESCRIPTION);
1686            assert!(!reference.starts_with('`'), "no single command is named: {reference}");
1687            for ecosystem in ["cargo check --locked", "go test ./...", "npm test", "pytest -q"] {
1688                assert!(reference.contains(ecosystem), "missing {ecosystem}: {reference}");
1689            }
1690        }
1691    }
1692
1693    #[test]
1694    fn truncation_only_verifier_rewrites_to_standalone_prefix() {
1695        for (command, expected) in [
1696            ("cargo check --locked 2>&1 | head -c 4000", "cargo check --locked 2>&1"),
1697            ("cargo check --locked -p vtcode 2>&1 | tail -20", "cargo check --locked -p vtcode 2>&1"),
1698            ("cargo nextest run 2>&1 | tail -15", "cargo nextest run 2>&1"),
1699            (
1700                "cargo check --locked && cargo nextest run --locked -p vtcode | tail -5",
1701                "cargo check --locked && cargo nextest run --locked -p vtcode",
1702            ),
1703            ("cargo check | tail -5 | head -20", "cargo check"),
1704            ("RUSTFLAGS=-Dwarnings cargo check | head -20", "RUSTFLAGS=-Dwarnings cargo check"),
1705            // File-output redirects are preserved verbatim in the head slice
1706            // (only the status-masking pipe tail is elided), and `>` does not
1707            // mask the exit status — so the rewrite stays truthful here too.
1708            ("cargo check > build.log | tail -5", "cargo check > build.log"),
1709        ] {
1710            assert_eq!(
1711                rewrite_truncation_only_verifier(&exec_command(command)).as_deref(),
1712                Some(expected),
1713                "expected rewrite: {command}"
1714            );
1715        }
1716    }
1717
1718    #[test]
1719    fn non_truncation_pipelines_and_mutations_are_never_rewritten() {
1720        // Filtering tails do real work; dropping them would discard evidence.
1721        // Smuggled mutations, joins, redirects, and array forms must run (or
1722        // be rejected) exactly as typed — the rewrite must stay silent.
1723        for command in [
1724            "cargo check | grep error",
1725            "cargo check | wc -l",
1726            "cargo check | sort | uniq",
1727            "cargo check && rm -rf target | tail -5",
1728            "cargo check; git status | tail -5",
1729            "cargo check || cargo test | tail -5",
1730            "cargo check | tail -5; rm foo.txt",
1731            "cargo check | tail &",
1732            "cargo check",
1733            "echo done | tail -5",
1734            "rg -n 'pattern' src | head -20",
1735            "echo $(date) | tail -5",
1736        ] {
1737            assert_eq!(rewrite_truncation_only_verifier(&exec_command(command)), None, "must not rewrite: {command}");
1738        }
1739        assert_eq!(
1740            rewrite_truncation_only_verifier(&serde_json::json!({"command": ["cargo", "check", "|", "tail"]})),
1741            None,
1742            "array-form commands keep today's behavior"
1743        );
1744        assert_eq!(rewrite_truncation_only_verifier(&serde_json::json!({})), None);
1745    }
1746
1747    #[test]
1748    fn args_as_executed_classify_elided_truncation_verifiers_as_verification() {
1749        for command in [
1750            "cargo check --locked 2>&1 | tail -5",
1751            "cargo nextest run 2>&1 | head -c 4000",
1752        ] {
1753            let typed = exec_command(command);
1754            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1755            assert!(matches!(executed, std::borrow::Cow::Owned(_)), "expected rewrite: {command}");
1756            assert_eq!(
1757                classify_shell_activity(tools::EXEC_COMMAND, &executed),
1758                ShellActivity::Verification,
1759                "{command}"
1760            );
1761            // Idempotent: the executed form runs unchanged on a second pass.
1762            assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1763        }
1764
1765        let unified = json!({"action": "run", "command": "cargo check 2>&1 | tail -5"});
1766        assert_eq!(shell_args_as_executed(tools::UNIFIED_EXEC, &unified)["command"], "cargo check 2>&1");
1767    }
1768
1769    #[test]
1770    fn verifier_rewrite_keeps_raw_alias_and_appended_arguments_in_sync() {
1771        let command = "cargo check | grep";
1772        let typed = json!({"cmd":command, "raw_command":command, "args":["warning"]});
1773        let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1774        let expected = "set -o pipefail && cargo check | grep warning";
1775        assert_eq!(executed["command"], expected);
1776        assert_eq!(executed["raw_command"], expected);
1777        assert!(executed.get("args").is_none(), "suffix must execute exactly once");
1778        assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1779        assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1780
1781        let truncator = json!({"cmd":"cargo check | head", "args":["-20"]});
1782        assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &truncator)["command"], "cargo check");
1783    }
1784
1785    #[test]
1786    fn verifier_pipeline_rejects_conflicting_raw_alias_and_mutating_suffix() {
1787        for typed in [
1788            json!({"cmd":"cargo check | grep marker", "raw_command":"printf marker"}),
1789            json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1790            json!({"cmd":"set -o pipefail && cargo check | sort", "args":["-o", "changed.txt"]}),
1791            json!({"cmd":"cargo check | grep", "args":"invalid"}),
1792        ] {
1793            assert_eq!(rewrite_verifier_pipeline(&typed), None, "{typed}");
1794            assert!(!shell_command_is_admitted_verification_attempt(&typed), "{typed}");
1795            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation, "{typed}");
1796            assert!(super::super::classify_tool_intent(tools::EXEC_COMMAND, &typed).mutating, "{typed}");
1797        }
1798    }
1799
1800    #[test]
1801    fn filtered_verifier_pipeline_preserves_filters_and_truthful_status() {
1802        for command in [
1803            "python3 scripts/check_markdown.py 2>&1 | grep -E '^(README|docs/SECURITY)\\.md' | head -20",
1804            "cargo check --locked | grep warning",
1805            "cargo fmt --check && cargo check --locked | wc -l",
1806        ] {
1807            let typed = exec_command(command);
1808            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1809            assert_eq!(executed["command"], format!("set -o pipefail && {command}"));
1810            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1811            assert!(shell_command_is_admitted_verification_attempt(&executed));
1812            assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1813            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation);
1814        }
1815        for command in [
1816            "cargo check | grep error; true",
1817            "cargo check | grep error || true",
1818            "cargo check | tee source.rs",
1819            "cargo check | sed -i 's/old/new/' source.rs",
1820            "cargo check | grep $(touch marker)",
1821            "cargo check | grep error > hidden.log",
1822            "cargo check | grep error &",
1823            "cargo check && rm source.rs | grep error",
1824        ] {
1825            assert_eq!(rewrite_verifier_pipeline(&exec_command(command)), None, "{command}");
1826            let prefixed = exec_command(&format!("set -o pipefail && {command}"));
1827            assert_ne!(
1828                classify_shell_activity(tools::EXEC_COMMAND, &prefixed),
1829                ShellActivity::Verification,
1830                "{command}"
1831            );
1832        }
1833    }
1834
1835    #[test]
1836    fn args_as_executed_keep_unsafe_joins_and_non_run_calls_as_typed() {
1837        for command in [
1838            "cargo check 2>&1 | grep error; true",
1839            "cargo check; git status",
1840            "cargo check || true",
1841        ] {
1842            let typed = exec_command(command);
1843            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1844            assert!(matches!(executed, std::borrow::Cow::Borrowed(_)), "must run as typed: {command}");
1845            assert_ne!(
1846                classify_shell_activity(tools::EXEC_COMMAND, &executed),
1847                ShellActivity::Verification,
1848                "{command}"
1849            );
1850        }
1851        let poll = json!({"action": "poll", "session_id": "s1", "command": "cargo check | tail -5"});
1852        assert!(matches!(shell_args_as_executed(tools::UNIFIED_EXEC, &poll), std::borrow::Cow::Borrowed(_)));
1853        let read = json!({"path": "src/lib.rs"});
1854        assert!(matches!(shell_args_as_executed(tools::READ_FILE, &read), std::borrow::Cow::Borrowed(_)));
1855    }
1856
1857    #[test]
1858    fn path_glob_inspections_preserve_debt_without_weakening_admission() {
1859        let clean = json!({"exit_code":0});
1860        for command in [
1861            "rg -n \"MD013|line_length\" docs/development/*.md | head",
1862            "rg -n x ./docs/file?.md | head -20",
1863            "grep -n x docs/*.md",
1864            "cat ./docs/*.md",
1865            "ls docs/*.md",
1866        ] {
1867            assert!(!exec_mutation_should_incur_debt(command, &clean), "{command}");
1868            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)), ShellActivity::Mutation);
1869            assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code":0,"modified_files":["src/lib.rs"]})));
1870        }
1871        for command in [
1872            "rg -n x *",
1873            "rg -n x ./docs/[a-z]*.md",
1874            "rg -n x -docs/*.md",
1875            "rg -n x $dir/*.md",
1876            "rg --pre touch docs/*.md",
1877            "rg --hostname-bin=touch docs/*.md",
1878            "sed docs/*.md",
1879            "sort -o marker docs/*.md",
1880            "./tools/* --version",
1881            "rg x docs/$(touch marker)/*.md",
1882            "rg x docs/*.md > marker",
1883            "rg x docs/*.md; touch marker",
1884            "rg x docs/*.md; if",
1885            "rg x docs*/*.md",
1886        ] {
1887            assert!(exec_mutation_should_incur_debt(command, &clean), "{command}");
1888        }
1889    }
1890
1891    #[test]
1892    fn mutation_accounting_rejects_unproven_shell_forms() {
1893        let output = json!({"exit_code": 0});
1894        for command in [
1895            "echo \"$(touch marker)\"",
1896            "echo \"`touch marker`\"",
1897            "for p in $(echo $(touch marker)); do test -f \"$p\"; done",
1898            "for p in a; do sed -i '' 's/a/b/' README.md; done",
1899            "for p in a; do sort -o README.md README.md; done",
1900            "echo x 2>/dev/null.backup",
1901            "echo x 2>&1>marker",
1902            "cat <(touch marker)",
1903            "$program --version",
1904            "\"$(echo rm)\" marker",
1905            "grep $options README.md",
1906            "if true; then touch marker; fi",
1907            "for p in a; do echo x",
1908            "echo $(",
1909            "echo hi &",
1910            "sed -n '1p' README.md > marker",
1911            "echo ${x:=value}",
1912        ] {
1913            assert!(exec_mutation_should_incur_debt(command, &output), "{command}");
1914        }
1915        for command in [
1916            "echo '>'",
1917            "echo '2>/dev/null.backup'",
1918            "echo \"$(grep -n x README.md)\"",
1919        ] {
1920            assert!(!exec_mutation_should_incur_debt(command, &output), "{command}");
1921        }
1922    }
1923
1924    #[test]
1925    fn dynamic_readonly_inspections_do_not_incur_mutation_debt() {
1926        let clean_output = json!({"success": true, "exit_code": 0});
1927        // Session-vtcode-20261015T142625Z: `for` loops over grep results with
1928        // `test -f` exited 0 with no writes, bumped the completion generation,
1929        // invalidated tracker evidence, and looped harness auto-verification
1930        // on `rg` inspections instead of ending the turn. Admission stays
1931        // fail-closed (still Mutation, still blocked when pending); accounting
1932        // must not create new debt.
1933        for command in [
1934            "for p in $(grep -o '](\\./[^)#]*' README.md | sed 's/](//' | sort -u); do test -f \"$p\" || echo \"missing: $p\"; done; echo \"check done\"",
1935            "for p in $(grep -o '](\\./[^)#]*' README.md | sed 's/](\\.\\///' | sort -u); do test -f \"docs/${p#docs/}\" 2>/dev/null || echo \"missing: $p\"; done",
1936            "rg -n 'vinhnx/(VTCode|vtcode)' README.md",
1937            "grep -n '^## ' README.md",
1938            "ls docs/guides/zed-acp.md",
1939            "git diff --stat -- README.md",
1940            "git status --short",
1941            "npx markdownlint-cli2 README.md",
1942            "cargo check --locked",
1943        ] {
1944            let expected = if command.starts_with("npx markdownlint") || command.starts_with("cargo check") {
1945                ShellActivity::Verification
1946            } else if command.starts_with("for") {
1947                ShellActivity::Mutation
1948            } else {
1949                ShellActivity::Inspection
1950            };
1951            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)), expected, "{command}");
1952            assert!(!exec_mutation_should_incur_debt(command, &clean_output), "{command}");
1953        }
1954        // Dynamic `for`/`test` inspections: no debt without writes.
1955        assert!(!exec_mutation_should_incur_debt(
1956            "for p in $(grep -o 'x' README.md); do test -f \"$p\" || echo \"missing: $p\"; done",
1957            &clean_output,
1958        ));
1959        assert!(!exec_mutation_should_incur_debt("rg -n 'x' README.md", &clean_output));
1960        assert!(!exec_mutation_should_incur_debt("grep -n 'x' README.md", &clean_output));
1961        assert!(!exec_mutation_should_incur_debt("git status --short", &clean_output));
1962        // Concrete evidence still counts.
1963        assert!(exec_mutation_should_incur_debt(
1964            "for p in $(grep -o 'x' README.md); do test -f \"$p\" || echo \"missing: $p\"; done",
1965            &json!({"success": true, "exit_code": 0, "modified_files": ["README.md"]}),
1966        ));
1967        // Shell redirection is a potential write.
1968        assert!(exec_mutation_should_incur_debt("echo hi > README.md", &clean_output));
1969        assert!(exec_mutation_should_incur_debt("for f in a b; do echo x > \"$f\"; done", &clean_output));
1970        // Static mutating base still counts (fail-closed for `rm`, etc.).
1971        assert!(exec_mutation_should_incur_debt("rm -rf target", &clean_output));
1972        // Dynamic mutating base with variable still counts.
1973        assert!(exec_mutation_should_incur_debt("rm $f", &clean_output));
1974        // Stderr plumbing alone is not a file write.
1975        assert!(!exec_mutation_should_incur_debt("rg -n 'x' README.md 2>&1", &clean_output));
1976        // Shared classifiers decide verifier-shaped commands: mutating
1977        // variants count, clean checks do not. Asymmetric pairs.
1978        assert!(exec_mutation_should_incur_debt("npx markdownlint-cli2 --fix README.md", &clean_output));
1979        assert!(!exec_mutation_should_incur_debt("npx markdownlint-cli2 README.md", &clean_output));
1980        assert!(exec_mutation_should_incur_debt("cargo fmt", &clean_output));
1981        assert!(!exec_mutation_should_incur_debt("cargo fmt --check", &clean_output));
1982        assert!(exec_mutation_should_incur_debt("tsc", &clean_output));
1983        assert!(!exec_mutation_should_incur_debt("tsc --noEmit", &clean_output));
1984        // Unresolved options stay unproven even for inspection subcommands.
1985        assert!(exec_mutation_should_incur_debt("git diff $x", &clean_output));
1986        assert!(exec_mutation_should_incur_debt("git checkout $x", &clean_output));
1987        // Dynamic chains of inspections with exit-echo tails stay debt-free.
1988        assert!(!exec_mutation_should_incur_debt("grep -n 'x' README.md; echo \"exit:$?\"", &clean_output,));
1989    }
1990}