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 is_verification_invocation(words: &[String]) -> bool {
45    let command_words = crate::tools::command_args::command_words_after_environment_prefix(words);
46    let first = command_words.first().map(String::as_str).unwrap_or_default();
47    let second = command_words.get(1).map(|word| word.to_ascii_lowercase());
48    let third = command_words.get(2).map(|word| word.to_ascii_lowercase());
49    let program = Path::new(first)
50        .file_name()
51        .and_then(|name| name.to_str())
52        .unwrap_or(first)
53        .to_ascii_lowercase();
54
55    match program.as_str() {
56        "markdownlint-cli2" => command_words.get(1..).is_some_and(markdownlint_arguments_verify),
57        "npx" => {
58            let package_index = command_words
59                .iter()
60                .enumerate()
61                .skip(1)
62                .find(|(_, word)| !matches!(word.as_str(), "--yes" | "-y" | "--no-install"))
63                .map(|(index, _)| index);
64            package_index.is_some_and(|index| {
65                let Some(package) = command_words.get(index) else {
66                    return false;
67                };
68                let is_markdownlint = package == "markdownlint-cli2"
69                    || package.strip_prefix("markdownlint-cli2@").is_some_and(|version| {
70                        version.split('.').count() == 3
71                            && version
72                                .split('.')
73                                .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
74                    });
75                is_markdownlint && command_words.get(index + 1..).is_some_and(markdownlint_arguments_verify)
76            })
77        }
78        "cargo" => {
79            if second.as_deref() == Some("fmt") {
80                // `cargo fmt` without `--check` reformats the worktree (mutation).
81                // With `--check` it is a read-only lint verification.
82                return words.iter().any(|word| word == "--check");
83            }
84            matches!(second.as_deref(), Some("check" | "build" | "clippy" | "test"))
85                || (second.as_deref() == Some("nextest") && third.as_deref() == Some("run"))
86        }
87        "go" => matches!(second.as_deref(), Some("test" | "build")),
88        "npm" | "pnpm" | "yarn" => {
89            matches!(second.as_deref(), Some("test" | "build" | "lint" | "check"))
90                || (second.as_deref() == Some("run")
91                    && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
92        }
93        "bun" | "bunx" => {
94            matches!(second.as_deref(), Some("test"))
95                || (second.as_deref() == Some("run")
96                    && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
97        }
98        "deno" => matches!(second.as_deref(), Some("test" | "lint" | "check")),
99        "make" | "gmake" | "just" => make_like_verification_targets(command_words),
100        "uv" => {
101            command_words.iter().any(|word| word == "pytest")
102                || (command_words.iter().any(|word| word == "ruff") && command_words.iter().any(|word| word == "check"))
103        }
104        "ruff" => {
105            if second.as_deref() == Some("format") {
106                return words.iter().any(|word| word == "--check");
107            }
108            matches!(second.as_deref(), Some("check" | "lint"))
109        }
110        "tsc" => {
111            // Bare `tsc` emits output (mutation). Only `--noEmit` type-checks.
112            words.iter().any(|word| word == "--noEmit")
113        }
114        "eslint" => {
115            // `eslint --fix` rewrites the worktree (mutation).
116            !words.iter().any(|word| word == "--fix" || word == "--fix-dry-run")
117        }
118        "rustc" | "pytest" | "xcodebuild" | "gradle" | "gradlew" => true,
119        "python" | "python3" => {
120            (command_words.len() == 2
121                && command_words
122                    .get(1)
123                    .is_some_and(|script| Path::new(script).ends_with("scripts/check_markdown.py")))
124                || (command_words.iter().any(|word| word == "-m") && command_words.iter().any(|word| word == "pytest"))
125        }
126        _ if first.ends_with("/scripts/check.sh") || first.ends_with("/scripts/check-dev.sh") => true,
127        _ => false,
128    }
129}
130
131fn markdownlint_arguments_verify(arguments: &[String]) -> bool {
132    !arguments.is_empty()
133        && !arguments.iter().any(|argument| {
134            matches!(argument.as_str(), "--fix" | "-f" | "--help" | "-h" | "--version" | "-v")
135                || argument.starts_with("--fix=")
136        })
137}
138
139fn contains_verification_invocation(command: &str) -> bool {
140    static_shell_command_words(command)
141        .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
142}
143
144/// Recognize a verifier for diagnostic feedback, including status-masking tails
145/// such as `; echo $?` that cannot pass static verification admission.
146/// This heuristic grants no permissions, verification credit, or repair edits.
147pub fn shell_command_contains_verifier(args: &Value) -> bool {
148    let Some(command) = shell_command_text_with_args(args) else {
149        return false;
150    };
151    super::readonly::parse_shell_command_words(&command)
152        .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
153}
154
155/// Whether a command inspects search results that normally produce output.
156///
157/// Used only for progress accounting: an empty read-only search pipeline can
158/// exit successfully when its final filter masks the search's no-match status.
159/// Quiet/existence probes intentionally return no text and are excluded. This
160/// predicate grants no execution permissions and never verifies mutations.
161pub fn shell_command_is_output_search(args: &Value) -> bool {
162    let Some(command) = shell_command_text_with_args(args) else {
163        return false;
164    };
165    let Some(segments) = static_shell_command_words(&command) else {
166        return false;
167    };
168    let mut saw_search = false;
169    for words in &segments {
170        if !command_words_are_readonly(words) || is_verification_invocation(words) {
171            return false;
172        }
173        let words = crate::tools::command_args::command_words_after_environment_prefix(words);
174        if command_words_are_grep_search(words) {
175            if grep_words_request_quiet_output(words) {
176                return false;
177            }
178            saw_search = true;
179        }
180    }
181    saw_search
182}
183
184fn grep_words_request_quiet_output(words: &[String]) -> bool {
185    let is_rg = words
186        .first()
187        .and_then(|word| Path::new(word).file_name())
188        .is_some_and(|name| name == "rg");
189    let mut arguments = words.iter().skip(1);
190    let mut quiet = false;
191    while let Some(argument) = arguments.next() {
192        match argument.as_str() {
193            "--" => break,
194            "--quiet" | "--silent" => quiet = true,
195            "--no-quiet" if is_rg => quiet = false,
196            _ if argument.starts_with("--") => {
197                // Values can look like options, including patterns such as `-query`.
198                let takes_value = matches!(
199                    argument.as_str(),
200                    "--regexp"
201                        | "--file"
202                        | "--after-context"
203                        | "--before-context"
204                        | "--context"
205                        | "--max-count"
206                        | "--include"
207                        | "--exclude"
208                        | "--exclude-from"
209                        | "--exclude-dir"
210                        | "--label"
211                        | "--directories"
212                        | "--devices"
213                ) || (is_rg
214                    && matches!(
215                        argument.as_str(),
216                        "--glob"
217                            | "--iglob"
218                            | "--ignore-file"
219                            | "--pre-glob"
220                            | "--type"
221                            | "--type-not"
222                            | "--type-add"
223                            | "--type-clear"
224                            | "--replace"
225                            | "--encoding"
226                            | "--engine"
227                            | "--color"
228                            | "--colors"
229                            | "--threads"
230                            | "--max-depth"
231                            | "--max-columns"
232                            | "--max-filesize"
233                            | "--dfa-size-limit"
234                            | "--regex-size-limit"
235                            | "--context-separator"
236                            | "--field-context-separator"
237                            | "--field-match-separator"
238                            | "--hyperlink-format"
239                            | "--path-separator"
240                            | "--sort"
241                            | "--sortr"
242                            | "--generate"
243                    ));
244                if takes_value {
245                    arguments.next();
246                }
247            }
248            _ if argument.starts_with('-') => {
249                let mut flags = argument.chars().skip(1).peekable();
250                while let Some(flag) = flags.next() {
251                    if flag == 'q' {
252                        quiet = true;
253                    }
254                    let takes_value = matches!(flag, 'e' | 'f' | 'A' | 'B' | 'C' | 'm' | 'd')
255                        || (is_rg && matches!(flag, 'g' | 't' | 'T' | 'r' | 'E' | 'j' | 'M'))
256                        || (!is_rg && flag == 'D');
257                    if takes_value {
258                        if flags.peek().is_none() {
259                            arguments.next();
260                        }
261                        break;
262                    }
263                }
264            }
265            _ => {}
266        }
267    }
268    quiet
269}
270
271/// Whether exit code 1 belongs to a standalone grep-style search.
272/// Compound commands, shell wrappers and redirected diagnostics cannot prove
273/// the search's no-match status, even when their visible output is empty.
274pub fn shell_command_is_standalone_grep_search(args: &Value) -> bool {
275    let Some(command) = shell_command_text_with_args(args) else {
276        return false;
277    };
278    let Some(segments) = static_shell_command_words(&command) else {
279        return false;
280    };
281    let [words] = segments.as_slice() else {
282        return false;
283    };
284    shell_words::split(&command).is_ok_and(|raw_words| raw_words == *words)
285        && command_words_are_readonly(words)
286        && command_words_are_grep_search(words)
287}
288
289fn command_words_are_grep_search(words: &[String]) -> bool {
290    let words = crate::tools::command_args::command_words_after_environment_prefix(words);
291    let program = words
292        .first()
293        .and_then(|word| Path::new(word).file_name())
294        .and_then(|name| name.to_str());
295    matches!(program, Some("rg" | "grep" | "egrep" | "fgrep"))
296}
297
298/// Project markers checked in priority order before the Node tier: each
299/// entry names the files that select its verifier. First tier with any
300/// marker present wins, matching the previous sequential `if` chain.
301const PRE_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
302    (&["Cargo.toml"], "cargo check --locked"),
303    (&["go.mod"], "go test ./..."),
304    (&["deno.json", "deno.jsonc"], "deno test"),
305    (&["bun.lockb", "bun.lock"], "bun test"),
306];
307
308/// Project markers checked after the Node tier (`package.json` is resolved
309/// separately at its priority position for script sniffing).
310const POST_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
311    (&["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"], "pytest -q"),
312    (&["Makefile", "makefile"], "make test"),
313    (&["justfile", "Justfile"], "just test"),
314];
315
316/// First verifier whose tier has any marker file under `workspace_root`.
317fn verifier_for_markers(workspace_root: &Path, markers: &[(&[&str], &str)]) -> Option<String> {
318    markers.iter().find_map(|(files, verifier)| {
319        files
320            .iter()
321            .any(|file| workspace_root.join(file).is_file())
322            .then(|| (*verifier).to_string())
323    })
324}
325
326/// Node verifier with `package.json` script sniffing: `test` runs directly,
327/// other known scripts run via `npm run`, and a missing/unreadable script
328/// map falls back to `npm test` so the directive never names a command that
329/// does not exist. Returns `None` when there is no `package.json`.
330fn default_verifier_for_node_workspace(workspace_root: &Path) -> Option<String> {
331    let package_json = workspace_root.join("package.json");
332    if !package_json.is_file() {
333        return None;
334    }
335    if let Ok(content) = std::fs::read_to_string(&package_json)
336        && let Ok(parsed) = serde_json::from_str::<Value>(&content)
337        && let Some(scripts) = parsed.get("scripts").and_then(Value::as_object)
338    {
339        for (script, command) in [
340            ("test", "npm test"),
341            ("check", "npm run check"),
342            ("lint", "npm run lint"),
343            ("build", "npm run build"),
344        ] {
345            if scripts.contains_key(script) {
346                return Some(command.to_string());
347            }
348        }
349    }
350    Some("npm test".to_string())
351}
352
353/// Detect a project-appropriate default verifier for autonomous recovery.
354///
355/// Inspects well-known project markers under `workspace_root` and returns a
356/// concrete standalone verification command the agent can run via
357/// `exec_command` (no pipes, no `;`/`||` joins). Priority follows the
358/// existing `is_verification_invocation` coverage: Rust → Go → Deno/Bun →
359/// Node → Python → Make/Just. Returns `None` when no marker is found so
360/// callers can fall back to the generic verifier examples.
361///
362/// This is intentionally synchronous and allocation-light (existence checks
363/// plus one small `package.json` read): it runs on the turn hot path when
364/// the anti-blind-editing gate needs an actionable recovery directive.
365/// Codex-style harness-managed verification (Stop-hook test gates,
366/// auto-review) shows that naming the exact command — rather than listing
367/// examples — is what unblocks long-running autonomous work.
368pub fn default_verifier_for_workspace(workspace_root: &Path) -> Option<String> {
369    if let Some(verifier) = verifier_for_markers(workspace_root, PRE_NODE_VERIFIER_MARKERS) {
370        return Some(verifier);
371    }
372    if let Some(verifier) = default_verifier_for_node_workspace(workspace_root) {
373        return Some(verifier);
374    }
375    // No recognised project marker; callers fall back to generic examples.
376    verifier_for_markers(workspace_root, POST_NODE_VERIFIER_MARKERS)
377}
378
379/// Which shell forms of a verifier clear the anti-blind-editing gate. Shared
380/// by the recovery directive and the blocked-mutation `next_action` so the
381/// two surfaces cannot drift.
382pub 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.";
383
384/// Stand-in for the verifier when no project command was detected or
385/// configured. It lists examples across ecosystems instead of naming one
386/// command, because a single concrete fallback (such as a Cargo command in a
387/// Go or Node workspace) would direct the model to a verifier that does not
388/// exist.
389pub const GENERIC_VERIFIER_DESCRIPTION: &str =
390    "your project's build/test/lint command (e.g. `cargo check --locked`, `go test ./...`, `npm test`, or `pytest -q`)";
391
392/// How harness text names the verifier to run: the resolved command in
393/// backticks, or [`GENERIC_VERIFIER_DESCRIPTION`] when none was resolved.
394/// `default_verifier` should come from [`default_verifier_for_workspace`] or
395/// the harness override resolution built on it.
396pub fn verifier_reference(default_verifier: Option<&str>) -> String {
397    match default_verifier.map(str::trim).filter(|command| !command.is_empty()) {
398        Some(command) => format!("`{command}`"),
399        None => GENERIC_VERIFIER_DESCRIPTION.to_string(),
400    }
401}
402
403/// Build the actionable verification-recovery directive with a concrete
404/// command. `default_verifier` should come from
405/// [`default_verifier_for_workspace`]; when `None`, the generic examples are
406/// kept so the directive never names a command that does not exist.
407pub fn verification_recovery_directive(default_verifier: Option<&str>, attempt: u8, max_attempts: u8) -> String {
408    let verifier = verifier_reference(default_verifier);
409    format!(
410        "Verification recovery ({attempt}/{max_attempts}): pending edits have not been verified, so further mutations are blocked \
411        and the turn ends blocked unless a verifier exits 0. Run {verifier} with `exec_command`, standalone or as a pure `&&` chain of verifiers. \
412        {VERIFIER_SHELL_FORM_NOTE} A failed verifier grants a bounded number of fix-up edits before the next verification is required."
413    )
414}
415
416/// Return whether a shell tool call is an admitted truncation-only verification
417/// attempt while the anti-blind-editing gate is pending.
418///
419/// Piped verifiers (e.g. `cargo check 2>&1 | head -c 4000`) must be allowed to
420/// run so the model can see the failure; otherwise the generic "cap output
421/// with `| head`" guidance deadlocks on `Mutation blocked until verification`.
422/// Only a truthful successful verifier clears the gate; this helper only
423/// decides admission, never clearance.
424///
425/// Fail-closed smuggling guard: every parsed shell segment must be a
426/// verification invocation or an allow-listed readonly command. A chained
427/// mutation such as `cargo check && rm -rf target` therefore stays blocked
428/// instead of riding through on the verifier prefix. Unparseable (dynamic)
429/// shell syntax also stays blocked.
430pub fn shell_command_is_admitted_verification_attempt(args: &Value) -> bool {
431    let Some(command) = shell_command_text_with_args(args) else {
432        return false;
433    };
434    if crate::tools::command_args::contains_dynamic_shell_syntax(&command) {
435        return false;
436    }
437    if command.starts_with(VERIFIER_PIPEFAIL_PREFIX) {
438        return pipefail_verifier_pipeline(&command).is_some();
439    }
440    let segments =
441        static_shell_command_words(&command).or_else(|| static_shell_command_words_with_output_plumbing(&command));
442    let Some(segments) = segments else {
443        return false;
444    };
445    if segments.is_empty() {
446        return false;
447    }
448    let mut saw_verification = false;
449    for words in &segments {
450        if is_verification_invocation(words) {
451            saw_verification = true;
452        } else if !command_words_are_readonly(words) {
453            return false;
454        }
455    }
456    saw_verification
457}
458
459fn has_logical_sequencing(words: &[String]) -> bool {
460    words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
461}
462
463/// Split a command on top-level `|` operators, tracking single/double quotes
464/// and backslash escapes with the same discipline as
465/// [`shell_uses_only_and_chaining`]. Returns the pipe-separated stages in
466/// order, or `None` when no top-level pipe exists. `||` is not special-cased:
467/// it yields an empty stage, which stage validation rejects — fail-closed
468/// without a second operator table to keep in sync.
469fn split_top_level_pipes(command: &str) -> Option<Vec<&str>> {
470    if !command.contains('|') {
471        return None;
472    }
473    let mut stages = Vec::new();
474    let mut start = 0;
475    let mut saw_pipe = false;
476    let mut in_single_quote = false;
477    let mut in_double_quote = false;
478    let mut chars = command.char_indices().peekable();
479    while let Some((index, character)) = chars.next() {
480        // Outside single quotes a backslash escapes the next character for the
481        // shell, so `\|` is a literal pipe, not a stage separator. Consume the
482        // pair to stay aligned with the shell (mirrors the `&&`-chain scanner).
483        if character == '\\' && !in_single_quote {
484            chars.next();
485            continue;
486        }
487        if character == '\'' && !in_double_quote {
488            in_single_quote = !in_single_quote;
489            continue;
490        }
491        if character == '"' && !in_single_quote {
492            in_double_quote = !in_double_quote;
493            continue;
494        }
495        if in_single_quote || in_double_quote {
496            continue;
497        }
498        if character == '|' {
499            saw_pipe = true;
500            stages.push(&command[start..index]);
501            start = index + character.len_utf8();
502        }
503    }
504    if !saw_pipe {
505        return None;
506    }
507    stages.push(&command[start..]);
508    Some(stages)
509}
510
511/// Whether a pipeline tail stage is a pure output truncator: a bare `head` or
512/// `tail` invocation (any flags) with no shell operators of its own. Anything
513/// else (`grep`, `wc`, `sort`, redirects, backgrounding, chaining) keeps its
514/// pipeline semantics and must not be elided — dropping it would discard work
515/// the caller asked for or change what the exit status means.
516fn is_pure_truncation_stage(stage: &str) -> bool {
517    let trimmed = stage.trim();
518    if trimmed.is_empty() {
519        return false;
520    }
521    // A truncator stage is one simple command: reject any operator that would
522    // indicate chaining, backgrounding, redirection, or nesting. The scan is
523    // quote-aware so quoted flag values (e.g. `--sep=';'`) do not false-reject;
524    // an unaware scan could only over-reject, which stays fail-closed.
525    let mut in_single_quote = false;
526    let mut in_double_quote = false;
527    let mut chars = trimmed.chars().peekable();
528    while let Some(character) = chars.next() {
529        if character == '\\' && !in_single_quote {
530            chars.next();
531            continue;
532        }
533        if character == '\'' && !in_double_quote {
534            in_single_quote = !in_single_quote;
535            continue;
536        }
537        if character == '"' && !in_single_quote {
538            in_double_quote = !in_double_quote;
539            continue;
540        }
541        if in_single_quote || in_double_quote {
542            continue;
543        }
544        if matches!(character, ';' | '&' | '<' | '>' | '\n' | '|') {
545            return false;
546        }
547    }
548    let words = match shell_words::split(trimmed) {
549        Ok(words) => words,
550        Err(_) => return false,
551    };
552    let Some(parsed) = NonEmptySlice::from_slice(&words) else {
553        return false;
554    };
555    let program = Path::new(parsed.first())
556        .file_name()
557        .and_then(|name| name.to_str())
558        .unwrap_or(parsed.first().as_str())
559        .to_ascii_lowercase();
560    matches!(program.as_str(), "head" | "tail")
561}
562
563/// Rewrite a truncation-only piped verifier (`cargo check 2>&1 | head -c 4000`)
564/// to its standalone verifier prefix so the observed exit status is the
565/// verifier's, not the truncator's.
566///
567/// A pipeline's status belongs to its tail: running `cargo check | tail`
568/// reports `tail`'s success even when the build fails, and the model reads
569/// that exit 0 as "verified" while the anti-blind gate (correctly) stays
570/// pending — a deadlock manufactured by shell exit-status semantics, observed
571/// in real sessions. Eliding the truncator (output stays capped via
572/// `max_output_tokens`) makes the status truthful in one round trip.
573///
574/// Fail-closed gates, all required:
575/// - the full command is an admitted verification attempt (no dynamic
576///   syntax; every segment independently verification-or-readonly), so shapes
577///   like `cargo check | tail; rm …` can never reach the rewrite;
578/// - the head classifies as [`ShellActivity::Verification`] on its own, so
579///   `;`/`||`/background joins and smuggled mutations behind a verifier
580///   prefix are rejected (only standalone verifiers and pure `&&` verifier
581///   chains pass, whose `&&` exit status is truthful; output redirects such
582///   as `> build.log` are preserved verbatim in the head and do not mask the
583///   status, so they rewrite safely);
584/// - every tail stage is a pure `is_pure_truncation_stage` truncator, so
585///   `grep`/`wc`/`sort` tails (whose filtering is real work) are preserved.
586///
587/// Returns the standalone verifier text, or `None` when the command must run
588/// (or be rejected) exactly as typed. Only commands whose raw text carries a
589/// top-level `|` are candidates; array and indexed spellings re-quote
590/// operators when joined, so they never present a top-level pipe and keep
591/// today's behavior.
592pub fn rewrite_truncation_only_verifier(args: &Value) -> Option<String> {
593    use crate::config::constants::tools as tool_names;
594
595    let command = shell_command_text_with_args(args)?;
596    if !shell_command_is_admitted_verification_attempt(args) {
597        return None;
598    }
599    let stages = split_top_level_pipes(&command)?;
600    let (head, tails) = stages.split_first()?;
601    let head = head.trim();
602    if head.is_empty() || tails.iter().any(|stage| !is_pure_truncation_stage(stage)) {
603        return None;
604    }
605    let head_args = serde_json::json!({"cmd": head});
606    if !matches!(classify_shell_activity(tool_names::EXEC_COMMAND, &head_args), ShellActivity::Verification) {
607        return None;
608    }
609    Some(head.to_string())
610}
611
612// `&&` fails closed when the selected shell does not support pipefail.
613const VERIFIER_PIPEFAIL_PREFIX: &str = "set -o pipefail && ";
614
615/// The exact status-only shell setup, with the entire verifier pipeline validated.
616/// Command policy still checks every executable and explicit deny rule.
617pub(crate) fn pipefail_verifier_pipeline(command: &str) -> Option<&str> {
618    let pipeline = command.strip_prefix(VERIFIER_PIPEFAIL_PREFIX)?;
619    if crate::tools::command_args::contains_dynamic_shell_syntax(pipeline) {
620        return None;
621    }
622    verifier_pipeline_head(pipeline).map(|_| pipeline)
623}
624
625/// Validate a static pipeline without granting execution or mutation permissions.
626fn verifier_pipeline_head(command: &str) -> Option<&str> {
627    let stages = split_top_level_pipes(command)?;
628    let (head, tails) = stages.split_first()?;
629    let head = head.trim();
630    let head_args = serde_json::json!({"cmd": head});
631    if classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, &head_args) != ShellActivity::Verification
632    {
633        return None;
634    }
635    // Tail stages must be independently static and read-only. Logical joins,
636    // unsafe redirections, substitutions, and mutations cannot ride behind it.
637    for stage in tails {
638        let segments = static_shell_command_words(stage)?;
639        if segments.len() != 1 || !segments.first().is_some_and(|words| command_words_are_readonly(words)) {
640            return None;
641        }
642    }
643    Some(head)
644}
645
646/// Preserve filtering while making pipeline success depend on every stage.
647/// Pure truncator pipelines retain the existing standalone rewrite to avoid
648/// SIGPIPE from early-closing head/tail. Other static read-only tails use
649/// pipefail; failures (including no matches or SIGPIPE) never clear the gate.
650fn rewrite_verifier_pipeline(args: &Value) -> Option<String> {
651    if let Some(standalone) = rewrite_truncation_only_verifier(args) {
652        return Some(standalone);
653    }
654    let command = shell_command_text_with_args(args)?;
655    if command.starts_with(VERIFIER_PIPEFAIL_PREFIX)
656        || !shell_command_is_admitted_verification_attempt(args)
657        || verifier_pipeline_head(&command).is_none()
658    {
659        return None;
660    }
661    Some(format!("{VERIFIER_PIPEFAIL_PREFIX}{command}"))
662}
663
664/// Shell-call arguments as the execution kernel runs them.
665///
666/// The kernel applies [`rewrite_verifier_pipeline`] to every
667/// command-run call before execution, so the process that runs (and whose
668/// exit status the outcome reports) is the standalone verifier, not the typed
669/// pipeline. Static filtering pipelines instead enable pipefail. Gate
670/// bookkeeping must classify that same command; classifying the
671/// typed pipeline would call a truthful `cargo check 2>&1 | tail -5` success a
672/// mutation and leave the gate pending. Arguments the kernel runs unchanged
673/// (already-normalized arguments included, since the rewrite is idempotent)
674/// are borrowed as-is.
675pub fn shell_args_as_executed<'a>(tool_name: &str, args: &'a Value) -> std::borrow::Cow<'a, Value> {
676    if !super::is_command_run_tool_call(tool_name, args) {
677        return std::borrow::Cow::Borrowed(args);
678    }
679    let Some(rewritten) = rewrite_verifier_pipeline(args) else {
680        return std::borrow::Cow::Borrowed(args);
681    };
682    let mut executed = args.clone();
683    match executed.as_object_mut() {
684        Some(payload) => {
685            if payload.get("raw_command").is_some_and(Value::is_string) {
686                payload.insert("raw_command".to_string(), Value::String(rewritten.clone()));
687            }
688            // The rewrite already includes the suffix (or elides its truncator).
689            // Keeping args would append it a second time at launch.
690            payload.remove("args");
691            payload.insert("command".to_string(), Value::String(rewritten));
692            std::borrow::Cow::Owned(executed)
693        }
694        None => std::borrow::Cow::Borrowed(args),
695    }
696}
697
698fn is_known_inspection(words: &[String]) -> bool {
699    if words
700        .iter()
701        .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
702    {
703        return false;
704    }
705    command_words_are_readonly(words)
706}
707
708fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
709    let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
710        (segments, false)
711    } else {
712        (static_shell_command_words_with_output_plumbing(command)?, true)
713    };
714    if has_output_plumbing && segments.len() != 1 {
715        return None;
716    }
717    let has_multiple_segments = segments.len() > 1;
718    let mut saw_verification = false;
719
720    for words in segments {
721        if is_verification_invocation(&words) {
722            saw_verification = true;
723        } else if !command_words_are_readonly(&words) {
724            return None;
725        }
726    }
727
728    if has_output_plumbing && !saw_verification {
729        return None;
730    }
731
732    // Shell execution does not guarantee that a pipeline or logical chain's
733    // final status reflects every verification stage. Do not let a successful
734    // downstream command clear the anti-blind checkpoint after an earlier
735    // verifier failed.
736    //
737    // Exception: a pure `&&` chain of verification (or readonly) segments
738    // short-circuits on first failure, so its exit status does represent every
739    // stage. `cargo fmt --check && cargo check --locked && cargo nextest run`
740    // must clear the gate; `;`, `||`, `|`, and background `&` still mask
741    // failures and stay `Mutation`.
742    if saw_verification && has_multiple_segments {
743        if shell_uses_only_and_chaining(command) {
744            return Some(ShellActivity::Verification);
745        }
746        return Some(ShellActivity::Mutation);
747    }
748
749    Some(if saw_verification {
750        ShellActivity::Verification
751    } else {
752        ShellActivity::Inspection
753    })
754}
755
756fn has_shell_sequence(command: &str) -> bool {
757    static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
758}
759
760/// Quote-aware check that a shell command chains segments only with `&&`.
761///
762/// Returns `false` when an unquoted `;`, newline, `||`, single `&`
763/// (background), or `|` (pipeline) is present, since those operators let a
764/// downstream success mask an earlier verifier failure. `&&` short-circuits,
765/// so a pure `&&` chain of verifiers has a faithful aggregate exit status and
766/// may clear the anti-blind-editing gate. Backslash escapes outside single
767/// quotes are honored, so a shell-literal `\"` cannot open a phantom quote
768/// state that hides a later live operator.
769fn shell_uses_only_and_chaining(command: &str) -> bool {
770    let chars: Vec<char> = command.chars().collect();
771    let mut index = 0;
772    let mut in_single_quote = false;
773    let mut in_double_quote = false;
774
775    while index < chars.len() {
776        let character = chars[index];
777        // Outside single quotes a backslash escapes the next character for the
778        // shell: `\"` is a literal quote (no quote-state change) and `\;` is an
779        // inert character, not an operator. Skip the pair so the scanner stays
780        // aligned with the shell and fails closed.
781        if character == '\\' && !in_single_quote && index + 1 < chars.len() {
782            index += 2;
783            continue;
784        }
785        if character == '\'' && !in_double_quote {
786            in_single_quote = !in_single_quote;
787            index += 1;
788            continue;
789        }
790        if character == '"' && !in_single_quote {
791            in_double_quote = !in_double_quote;
792            index += 1;
793            continue;
794        }
795        if in_single_quote || in_double_quote {
796            index += 1;
797            continue;
798        }
799        match character {
800            ';' | '\n' | '|' => return false,
801            '&' => {
802                let next_is_and = chars.get(index + 1) == Some(&'&');
803                if !next_is_and {
804                    return false;
805                }
806                // A `||`-style `|` was already rejected above; `&&` consumes
807                // both characters. `|||`, `&&&`, and similar malformed
808                // sequences fail closed.
809                let third = chars.get(index + 2);
810                if third == Some(&'&') || third == Some(&'|') {
811                    return false;
812                }
813                index += 2;
814                continue;
815            }
816            _ => {}
817        }
818        index += 1;
819    }
820
821    true
822}
823
824/// Classify a shell call without weakening the authoritative mutation guard.
825///
826/// Standalone output plumbing such as `2>&1` or `> build.log` does not turn a
827/// primary verification command into a mutation for progress accounting.
828/// Pipelines and `;`/`||` chains remain mutations because their final
829/// status does not reliably represent every verification stage. Pure `&&`
830/// chains of verification-or-readonly segments are `Verification` since `&&`
831/// short-circuits on first failure.
832#[must_use]
833pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
834    let command = shell_command_text_with_args(args);
835    if command.is_none() && crate::tools::command_args::raw_command_text(args).is_some() {
836        return ShellActivity::Mutation;
837    }
838    if command.as_deref().and_then(pipefail_verifier_pipeline).is_some() {
839        return ShellActivity::Verification;
840    }
841    let words = crate::tools::command_args::command_words(args).ok().flatten();
842    let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);
843
844    if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
845        return activity;
846    }
847    // A failed sequence proof cannot fall back to the original tool intent:
848    // appended arguments may make an otherwise read-only tail mutate state.
849    if has_unclassified_shell_sequence {
850        return ShellActivity::Mutation;
851    }
852
853    let intent = super::classify_tool_intent(tool_name, args);
854
855    if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
856        return ShellActivity::Inspection;
857    }
858
859    let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
860    let contains_verification =
861        starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
862    if !intent.mutating {
863        return if contains_verification {
864            ShellActivity::Verification
865        } else {
866            ShellActivity::Inspection
867        };
868    }
869
870    if starts_with_verification
871        && !has_unclassified_shell_sequence
872        && !words.as_deref().is_some_and(has_logical_sequencing)
873    {
874        ShellActivity::Verification
875    } else {
876        ShellActivity::Mutation
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use serde_json::json;
883
884    use super::*;
885    use crate::config::constants::tools;
886    use crate::tools::tool_intent::is_readonly_command_session_command;
887
888    fn exec_command(command: &str) -> Value {
889        json!({"cmd": command})
890    }
891
892    #[test]
893    fn verifier_diagnostics_recognize_masked_status_without_granting_admission() {
894        for command in [
895            "npx markdownlint-cli2 README.md; echo \"lint exit: $?\"",
896            "cargo check; echo $?",
897            "python3 scripts/check_markdown.py; echo \"lint exit: $?\"",
898            "cargo check --locked || true",
899            "cargo check; rm marker",
900        ] {
901            let args = exec_command(command);
902            assert!(shell_command_contains_verifier(&args), "{command}");
903            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation);
904        }
905        let masked = exec_command("npx markdownlint-cli2 README.md; echo \"lint exit: $?\"");
906        assert!(!shell_command_is_admitted_verification_attempt(&masked));
907        assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &masked).as_ref(), &masked);
908        for args in [
909            exec_command("echo 'cargo check; echo $?'"),
910            exec_command("grep 'cargo check' README.md"),
911            exec_command("npx markdownlint-cli2 --fix README.md"),
912            json!({"cmd":"cargo check; true", "raw_command":"echo hello"}),
913            json!({"cmd":"npx markdownlint-cli2", "args":["--fix", "README.md"]}),
914        ] {
915            assert!(!shell_command_contains_verifier(&args), "{args}");
916        }
917    }
918
919    #[test]
920    fn markdownlint_verification_preserves_command_boundaries() {
921        for command in [
922            "markdownlint-cli2 README.md",
923            "npx --yes markdownlint-cli2 README.md",
924            "npx --no-install markdownlint-cli2@0.23.3 README.md",
925            "npx -y markdownlint-cli2 README.md && cargo check --locked",
926            "python3 scripts/check_markdown.py",
927            "python ./scripts/check_markdown.py",
928        ] {
929            assert!(shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
930            assert_eq!(
931                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
932                ShellActivity::Verification,
933                "{command}"
934            );
935        }
936        for command in [
937            "markdownlint-cli2 --fix README.md",
938            "npx --yes markdownlint-cli2 -f README.md",
939            "npx --yes markdownlint-cli2 --fix=true README.md",
940            "markdownlint-cli2 --version",
941            "npx --package evil markdownlint-cli2 README.md",
942            "npx markdownlint-cli2@evil README.md",
943            "npx markdownlint-cli2 README.md && rm README.md",
944            "python3 scripts/check_markdown.py --fix",
945            "python3 scripts/check_markdown.py --list",
946            "python3 scripts/check_markdown.py --help",
947            "python3 other/check_markdown.py",
948        ] {
949            assert!(!shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
950        }
951        assert_ne!(
952            classify_shell_activity(tools::EXEC_COMMAND, &exec_command("npx markdownlint-cli2 README.md | grep error")),
953            ShellActivity::Verification
954        );
955    }
956
957    #[test]
958    fn output_search_recognizes_static_search_pipelines() {
959        for command in [
960            "rg --files --hidden -g '.markdownlint*'",
961            "rg --files --hidden | rg -i 'markdownlint' | sed -n '1,15p'",
962            "git ls-files | grep -i markdownlint | head -10",
963            "LANG=C /usr/bin/grep -n missing README.md",
964            "rg -- '-q' README.md",
965            "rg -e -query src | head -10",
966            "grep -ne-query README.md",
967            "rg --regexp --quiet src",
968            "rg -g '-q*' missing src",
969            "rg '-g-q*' missing src",
970            "rg -q --no-quiet missing src",
971        ] {
972            assert!(shell_command_is_output_search(&exec_command(command)), "{command}");
973        }
974        assert!(shell_command_is_output_search(&json!({"command": ["rg", "missing", "src"]})));
975    }
976
977    #[test]
978    fn output_search_excludes_quiet_probes_and_unproven_commands() {
979        for command in [
980            "rg -q pattern src",
981            "grep -nq pattern README.md",
982            "rg --quiet pattern src | head -10",
983            "grep --silent pattern README.md",
984            "rg -e -query -q src",
985            "grep -nqe-query README.md",
986            "rg --regexp --quiet --quiet src",
987            "rg -q --no-quiet -q missing src",
988            "echo 'rg missing'",
989            "cat empty.txt",
990            "cargo check | rg error",
991            "rg missing src; touch result.txt",
992            "rg missing $(pwd)",
993            "rg missing src > result.txt",
994            "rg --pre 'touch result.txt' missing src",
995        ] {
996            assert!(!shell_command_is_output_search(&exec_command(command)), "{command}");
997        }
998        assert!(!shell_command_is_output_search(&json!({})));
999    }
1000
1001    #[test]
1002    fn output_search_distinguishes_separator_values_from_quiet_flags() {
1003        for option in [
1004            "--context-separator",
1005            "--field-context-separator",
1006            "--field-match-separator",
1007        ] {
1008            let command = format!("rg {option} -q missing src | head -10");
1009            assert!(shell_command_is_output_search(&exec_command(&command)), "{command}");
1010            let quiet_command = format!("rg {option} -q --quiet missing src | head -10");
1011            assert!(!shell_command_is_output_search(&exec_command(&quiet_command)), "{quiet_command}");
1012        }
1013    }
1014
1015    #[test]
1016    fn standalone_grep_search_preserves_exit_status_ownership() {
1017        for command in [
1018            "rg missing src",
1019            "grep -q missing README.md",
1020            "LANG=C /usr/bin/grep -n missing README.md",
1021            "rg 'a|b' src",
1022            "fgrep missing README.md",
1023        ] {
1024            assert!(shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1025        }
1026        assert!(shell_command_is_standalone_grep_search(&json!({"command": ["rg", "missing", "src"]})));
1027        for command in [
1028            "rg missing src; false",
1029            "rg missing src && false",
1030            "rg missing src | grep other",
1031            "! rg missing src",
1032            "(rg missing src)",
1033            "echo '/rg missing'; false",
1034            "rg missing src 2>/dev/null",
1035            "rg missing src 2>&1",
1036            "rg missing $(pwd)",
1037            "rg --pre 'false' missing src",
1038        ] {
1039            assert!(!shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1040        }
1041    }
1042
1043    #[test]
1044    fn admitted_verification_attempt_allows_truncation_but_blocks_smuggled_mutations() {
1045        for command in [
1046            "cargo check --locked 2>&1 | head -c 4000",
1047            "cargo check --locked",
1048            "cargo nextest run 2>&1 | head -c 4000",
1049        ] {
1050            assert!(
1051                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1052                "expected admission: {command}"
1053            );
1054        }
1055        for command in [
1056            "cargo check && rm -rf target",
1057            "cargo check; rm foo.txt",
1058            "cargo check || rm foo.txt",
1059            "cargo check && cargo test && rm foo.txt",
1060            "sed -i '' 's/old/new/' README.md",
1061            "echo $(date)",
1062            "cargo check > build.log && rm foo.txt",
1063        ] {
1064            assert!(
1065                !shell_command_is_admitted_verification_attempt(&exec_command(command)),
1066                "expected block: {command}"
1067            );
1068        }
1069        assert!(!shell_command_is_admitted_verification_attempt(&json!({})));
1070    }
1071
1072    #[test]
1073    fn logged_compound_inspection_commands_are_not_mutations() {
1074        for command in [
1075            "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
1076            "wc -l README.md; rg -n '^#' README.md",
1077            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
1078        ] {
1079            assert_eq!(
1080                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1081                ShellActivity::Inspection,
1082                "{command}"
1083            );
1084        }
1085    }
1086
1087    #[cfg(unix)]
1088    #[test]
1089    fn captured_read_commands_with_output_suppression_are_inspection() {
1090        for command in [
1091            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"#,
1092            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"#,
1093            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"###,
1094        ] {
1095            assert_eq!(
1096                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1097                ShellActivity::Inspection,
1098                "{command}"
1099            );
1100        }
1101    }
1102
1103    #[cfg(unix)]
1104    #[test]
1105    fn printf_output_safety_guards_remain_mutations() {
1106        for command in [
1107            "printf 'captured output\\n' > output.txt",
1108            "printf '%s\\n' \"$(git status --short)\"",
1109            "printf '%s\\n' `git status --short`",
1110            "printf '\\n--- inspection ---\\n' && rm output.txt",
1111        ] {
1112            let args = exec_command(command);
1113            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1114            assert!(!is_readonly_command_session_command(&args), "unexpected readonly command: {command}");
1115        }
1116    }
1117
1118    #[test]
1119    fn git_diff_check_chain_remains_inspection() {
1120        assert_eq!(
1121            classify_shell_activity(
1122                tools::EXEC_COMMAND,
1123                &exec_command("git diff --check && git status --short && git diff --stat"),
1124            ),
1125            ShellActivity::Inspection
1126        );
1127    }
1128
1129    #[test]
1130    fn verification_detection_skips_environment_prefixes() {
1131        for command in [
1132            "env RUSTFLAGS=-Dwarnings cargo check",
1133            "RUSTFLAGS=-Dwarnings env cargo check",
1134            "env -u PATH cargo check",
1135            "env -C /tmp cargo check",
1136        ] {
1137            assert_eq!(
1138                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1139                ShellActivity::Verification,
1140                "{command}"
1141            );
1142        }
1143    }
1144
1145    #[test]
1146    fn ambiguous_or_mutating_compounds_remain_mutations() {
1147        for command in [
1148            "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
1149            "cat README.md; sed -i '' 's/a/b/' README.md",
1150            "sed --in-place= README.md",
1151            "git diff --output=out",
1152            "git diff '--output=out'",
1153            "git diff -o out",
1154            "git diff -oout",
1155            "git log --output=out",
1156            "git show --textconv",
1157            // `-C <dir> <read-only sub>` is an Inspection now (the redirect
1158            // only changes which repository is read); config injection stays
1159            // a Mutation.
1160            "git -c core.fsmonitor=touch status",
1161            "find . -fprint output.txt",
1162            "find . -fprintf output.txt '%p'",
1163            "rg --hostname-bin sh pattern",
1164            "rg --search-zip pattern",
1165            "rg -z pattern",
1166            "sort -o generated.txt README.md",
1167            "sort --compress-program=sh README.md",
1168            "date -s now",
1169            "awk -i inplace '{print}' README.md",
1170            "sed -n 's/a/b/e' README.md",
1171            "fd --exec sh -c 'touch output'",
1172            "tree -o output.txt",
1173            "ast-grep -r 'README.md'",
1174            "sed -n -fmalicious.sed -e '1p' src/main.rs",
1175            "sed -I '' 's/a/b/' src/main.rs",
1176            "sed -n '1p\nw leaked.txt' src/main.rs",
1177            "cargo check & rm output",
1178            "cargo check > build.log | rm output",
1179            "cargo check | head -40 > build.log",
1180            "cargo check | echo x > output.log",
1181            "cargo check | head -40",
1182            "cargo check > build.log &",
1183            "env -S 'cargo check'",
1184            "echo x > output.log && cargo check",
1185            "cargo check < build-input.log",
1186            "cat README.md > copied.txt",
1187            "git diff --check; rm output",
1188            "cat README.md\nrm output",
1189        ] {
1190            assert_eq!(
1191                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1192                ShellActivity::Mutation,
1193                "{command}"
1194            );
1195        }
1196    }
1197
1198    #[test]
1199    fn quoted_output_text_does_not_change_inspection_classification() {
1200        for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
1201            assert_eq!(
1202                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1203                ShellActivity::Inspection,
1204                "{command}"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn cargo_fmt_check_is_verification_but_plain_fmt_is_not() {
1211        for command in [
1212            "cargo fmt --check",
1213            "cargo fmt --all -- --check",
1214            "cargo fmt -- --check",
1215        ] {
1216            assert_eq!(
1217                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1218                ShellActivity::Verification,
1219                "{command}"
1220            );
1221            assert!(
1222                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1223                "expected admission: {command}"
1224            );
1225        }
1226        // Plain `cargo fmt` rewrites the worktree: it must stay a mutation and
1227        // must not ride through the verification gate.
1228        assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command("cargo fmt")), ShellActivity::Mutation);
1229        assert!(!shell_command_is_admitted_verification_attempt(&exec_command("cargo fmt")));
1230    }
1231
1232    #[test]
1233    fn pure_and_chained_verifiers_are_verification() {
1234        for command in [
1235            "cargo fmt --all -- --check && cargo check --locked",
1236            "cargo check --locked && cargo nextest run --locked -p vtcode-ui",
1237            "cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
1238            "git diff --check && cargo check --locked",
1239        ] {
1240            assert_eq!(
1241                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1242                ShellActivity::Verification,
1243                "{command}"
1244            );
1245            assert!(
1246                shell_command_is_admitted_verification_attempt(&exec_command(command)),
1247                "expected admission: {command}"
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn non_and_chained_verifiers_remain_mutations() {
1254        for command in [
1255            "cargo check --locked; cargo nextest run --locked -p vtcode-ui",
1256            "cargo check --locked || cargo nextest run --locked -p vtcode-ui",
1257            "cargo check --locked | head -40",
1258            "cargo check --locked && cargo nextest run --locked -p vtcode-ui | head -40",
1259            "cargo check --locked &",
1260        ] {
1261            assert_eq!(
1262                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1263                ShellActivity::Mutation,
1264                "{command}"
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn expanded_verifiers_classify_as_verification() {
1271        for command in [
1272            "bun test",
1273            "bun run test",
1274            "deno lint",
1275            "deno check mod.ts",
1276            "make test",
1277            "make lint",
1278            "just verify",
1279            "ruff check src/",
1280            "ruff format --check",
1281            "tsc --noEmit",
1282            "eslint src/",
1283            "python3 -m pytest",
1284            "uv run pytest",
1285            "npm run lint",
1286        ] {
1287            assert_eq!(
1288                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1289                ShellActivity::Verification,
1290                "{command}"
1291            );
1292        }
1293        for command in [
1294            "make clean",
1295            "make test clean",
1296            "just fmt",
1297            "tsc",
1298            "eslint --fix src/",
1299            "ruff format src/",
1300            "bun install",
1301            "uv sync",
1302        ] {
1303            assert_eq!(
1304                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1305                ShellActivity::Mutation,
1306                "{command}"
1307            );
1308        }
1309    }
1310
1311    #[test]
1312    fn escaped_quotes_fail_closed_instead_of_hiding_operators() {
1313        // A backslash-escaped quote is a literal for the shell, so the trailing
1314        // `;` is a live separator: the aggregate exit status can mask a failed
1315        // verifier, and the chain must not classify as pure `&&`.
1316        assert!(!shell_uses_only_and_chaining("cargo check --locked \\\"; echo ok"));
1317        assert!(!shell_uses_only_and_chaining("echo \\\" ; cargo check --locked && echo done"));
1318        // Escaped quotes inside real double quotes stay inert.
1319        assert!(shell_uses_only_and_chaining("echo \"a\\\"b\" && cargo check --locked"));
1320        assert!(shell_uses_only_and_chaining("echo \"path\" && cargo fmt --check"));
1321        // Backslash is literal inside single quotes.
1322        assert!(shell_uses_only_and_chaining("echo 'a\\b' && cargo check --locked"));
1323    }
1324
1325    #[test]
1326    fn default_verifier_prefers_manifest_priority_order() {
1327        let dir = tempfile::tempdir().expect("tempdir");
1328        assert_eq!(default_verifier_for_workspace(dir.path()), None);
1329
1330        std::fs::write(dir.path().join("justfile"), "test:\n\techo ok\n").expect("justfile");
1331        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("just test"));
1332        std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1333        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("pytest -q"));
1334        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1335        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1336        std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1337        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1338        std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
1339        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("cargo check --locked"));
1340    }
1341
1342    #[test]
1343    fn default_verifier_marker_aliases_and_tier_priority() {
1344        // Every table row resolves, including alternate spellings — a
1345        // dropped alias in the marker table must fail here, not in a
1346        // live session. Each fixture names a different row.
1347        for (marker, expected) in [
1348            ("Justfile", "just test"),
1349            ("makefile", "make test"),
1350            ("bun.lock", "bun test"),
1351            ("deno.jsonc", "deno test"),
1352        ] {
1353            let dir = tempfile::tempdir().expect("tempdir");
1354            std::fs::write(dir.path().join(marker), "x\n").expect(marker);
1355            assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some(expected), "{marker}");
1356        }
1357        // Cross-tier priority: earlier tiers win over later ones.
1358        let dir = tempfile::tempdir().expect("tempdir");
1359        std::fs::write(dir.path().join("bun.lockb"), "x\n").expect("bun.lockb");
1360        std::fs::write(dir.path().join("deno.json"), "{}\n").expect("deno.json");
1361        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("deno test"));
1362        std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1363        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1364        // The Node tier (script sniffing) sits between Bun and Python.
1365        let dir = tempfile::tempdir().expect("tempdir");
1366        std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1367        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1368        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1369        // Unrecognized scripts keep the `npm test` fallback.
1370        let dir = tempfile::tempdir().expect("tempdir");
1371        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"start":"serve"}}"#).expect("package.json");
1372        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1373    }
1374
1375    #[test]
1376    fn default_verifier_reads_npm_script_fallbacks() {
1377        let dir = tempfile::tempdir().expect("tempdir");
1378        std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"lint":"eslint ."}}"#).expect("package.json");
1379        assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm run lint"));
1380    }
1381
1382    #[test]
1383    fn verification_recovery_directive_names_concrete_command() {
1384        let directive = verification_recovery_directive(Some("go test ./..."), 1, 2);
1385        assert!(directive.contains("go test ./..."));
1386        assert!(directive.contains("1/2"));
1387        assert!(directive.contains("max_output_tokens"));
1388        assert!(directive.contains(VERIFIER_SHELL_FORM_NOTE));
1389        assert!(directive.contains("tails run standalone"));
1390        assert!(directive.contains("fail-closed `pipefail`"));
1391        let fallback = verification_recovery_directive(None, 2, 2);
1392        assert!(fallback.contains(GENERIC_VERIFIER_DESCRIPTION));
1393        assert!(fallback.contains("2/2"));
1394    }
1395
1396    #[test]
1397    fn verifier_reference_names_resolved_command_or_generic_description() {
1398        assert_eq!(verifier_reference(Some("go test ./...")), "`go test ./...`");
1399        assert_eq!(verifier_reference(Some("  npm test ")), "`npm test`");
1400        for missing in [None, Some(""), Some("   ")] {
1401            let reference = verifier_reference(missing);
1402            assert_eq!(reference, GENERIC_VERIFIER_DESCRIPTION);
1403            assert!(!reference.starts_with('`'), "no single command is named: {reference}");
1404            for ecosystem in ["cargo check --locked", "go test ./...", "npm test", "pytest -q"] {
1405                assert!(reference.contains(ecosystem), "missing {ecosystem}: {reference}");
1406            }
1407        }
1408    }
1409
1410    #[test]
1411    fn truncation_only_verifier_rewrites_to_standalone_prefix() {
1412        for (command, expected) in [
1413            ("cargo check --locked 2>&1 | head -c 4000", "cargo check --locked 2>&1"),
1414            ("cargo check --locked -p vtcode 2>&1 | tail -20", "cargo check --locked -p vtcode 2>&1"),
1415            ("cargo nextest run 2>&1 | tail -15", "cargo nextest run 2>&1"),
1416            (
1417                "cargo check --locked && cargo nextest run --locked -p vtcode | tail -5",
1418                "cargo check --locked && cargo nextest run --locked -p vtcode",
1419            ),
1420            ("cargo check | tail -5 | head -20", "cargo check"),
1421            ("RUSTFLAGS=-Dwarnings cargo check | head -20", "RUSTFLAGS=-Dwarnings cargo check"),
1422            // File-output redirects are preserved verbatim in the head slice
1423            // (only the status-masking pipe tail is elided), and `>` does not
1424            // mask the exit status — so the rewrite stays truthful here too.
1425            ("cargo check > build.log | tail -5", "cargo check > build.log"),
1426        ] {
1427            assert_eq!(
1428                rewrite_truncation_only_verifier(&exec_command(command)).as_deref(),
1429                Some(expected),
1430                "expected rewrite: {command}"
1431            );
1432        }
1433    }
1434
1435    #[test]
1436    fn non_truncation_pipelines_and_mutations_are_never_rewritten() {
1437        // Filtering tails do real work; dropping them would discard evidence.
1438        // Smuggled mutations, joins, redirects, and array forms must run (or
1439        // be rejected) exactly as typed — the rewrite must stay silent.
1440        for command in [
1441            "cargo check | grep error",
1442            "cargo check | wc -l",
1443            "cargo check | sort | uniq",
1444            "cargo check && rm -rf target | tail -5",
1445            "cargo check; git status | tail -5",
1446            "cargo check || cargo test | tail -5",
1447            "cargo check | tail -5; rm foo.txt",
1448            "cargo check | tail &",
1449            "cargo check",
1450            "echo done | tail -5",
1451            "rg -n 'pattern' src | head -20",
1452            "echo $(date) | tail -5",
1453        ] {
1454            assert_eq!(rewrite_truncation_only_verifier(&exec_command(command)), None, "must not rewrite: {command}");
1455        }
1456        assert_eq!(
1457            rewrite_truncation_only_verifier(&serde_json::json!({"command": ["cargo", "check", "|", "tail"]})),
1458            None,
1459            "array-form commands keep today's behavior"
1460        );
1461        assert_eq!(rewrite_truncation_only_verifier(&serde_json::json!({})), None);
1462    }
1463
1464    #[test]
1465    fn args_as_executed_classify_elided_truncation_verifiers_as_verification() {
1466        for command in [
1467            "cargo check --locked 2>&1 | tail -5",
1468            "cargo nextest run 2>&1 | head -c 4000",
1469        ] {
1470            let typed = exec_command(command);
1471            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1472            assert!(matches!(executed, std::borrow::Cow::Owned(_)), "expected rewrite: {command}");
1473            assert_eq!(
1474                classify_shell_activity(tools::EXEC_COMMAND, &executed),
1475                ShellActivity::Verification,
1476                "{command}"
1477            );
1478            // Idempotent: the executed form runs unchanged on a second pass.
1479            assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1480        }
1481
1482        let unified = json!({"action": "run", "command": "cargo check 2>&1 | tail -5"});
1483        assert_eq!(shell_args_as_executed(tools::UNIFIED_EXEC, &unified)["command"], "cargo check 2>&1");
1484    }
1485
1486    #[test]
1487    fn verifier_rewrite_keeps_raw_alias_and_appended_arguments_in_sync() {
1488        let command = "cargo check | grep";
1489        let typed = json!({"cmd":command, "raw_command":command, "args":["warning"]});
1490        let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1491        let expected = "set -o pipefail && cargo check | grep warning";
1492        assert_eq!(executed["command"], expected);
1493        assert_eq!(executed["raw_command"], expected);
1494        assert!(executed.get("args").is_none(), "suffix must execute exactly once");
1495        assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1496        assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1497
1498        let truncator = json!({"cmd":"cargo check | head", "args":["-20"]});
1499        assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &truncator)["command"], "cargo check");
1500    }
1501
1502    #[test]
1503    fn verifier_pipeline_rejects_conflicting_raw_alias_and_mutating_suffix() {
1504        for typed in [
1505            json!({"cmd":"cargo check | grep marker", "raw_command":"printf marker"}),
1506            json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1507            json!({"cmd":"set -o pipefail && cargo check | sort", "args":["-o", "changed.txt"]}),
1508            json!({"cmd":"cargo check | grep", "args":"invalid"}),
1509        ] {
1510            assert_eq!(rewrite_verifier_pipeline(&typed), None, "{typed}");
1511            assert!(!shell_command_is_admitted_verification_attempt(&typed), "{typed}");
1512            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation, "{typed}");
1513            assert!(super::super::classify_tool_intent(tools::EXEC_COMMAND, &typed).mutating, "{typed}");
1514        }
1515    }
1516
1517    #[test]
1518    fn filtered_verifier_pipeline_preserves_filters_and_truthful_status() {
1519        for command in [
1520            "python3 scripts/check_markdown.py 2>&1 | grep -E '^(README|docs/SECURITY)\\.md' | head -20",
1521            "cargo check --locked | grep warning",
1522            "cargo fmt --check && cargo check --locked | wc -l",
1523        ] {
1524            let typed = exec_command(command);
1525            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1526            assert_eq!(executed["command"], format!("set -o pipefail && {command}"));
1527            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1528            assert!(shell_command_is_admitted_verification_attempt(&executed));
1529            assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1530            assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation);
1531        }
1532        for command in [
1533            "cargo check | grep error; true",
1534            "cargo check | grep error || true",
1535            "cargo check | tee source.rs",
1536            "cargo check | sed -i 's/old/new/' source.rs",
1537            "cargo check | grep $(touch marker)",
1538            "cargo check | grep error > hidden.log",
1539            "cargo check | grep error &",
1540            "cargo check && rm source.rs | grep error",
1541        ] {
1542            assert_eq!(rewrite_verifier_pipeline(&exec_command(command)), None, "{command}");
1543            let prefixed = exec_command(&format!("set -o pipefail && {command}"));
1544            assert_ne!(
1545                classify_shell_activity(tools::EXEC_COMMAND, &prefixed),
1546                ShellActivity::Verification,
1547                "{command}"
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn args_as_executed_keep_unsafe_joins_and_non_run_calls_as_typed() {
1554        for command in [
1555            "cargo check 2>&1 | grep error; true",
1556            "cargo check; git status",
1557            "cargo check || true",
1558        ] {
1559            let typed = exec_command(command);
1560            let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1561            assert!(matches!(executed, std::borrow::Cow::Borrowed(_)), "must run as typed: {command}");
1562            assert_ne!(
1563                classify_shell_activity(tools::EXEC_COMMAND, &executed),
1564                ShellActivity::Verification,
1565                "{command}"
1566            );
1567        }
1568        let poll = json!({"action": "poll", "session_id": "s1", "command": "cargo check | tail -5"});
1569        assert!(matches!(shell_args_as_executed(tools::UNIFIED_EXEC, &poll), std::borrow::Cow::Borrowed(_)));
1570        let read = json!({"path": "src/lib.rs"});
1571        assert!(matches!(shell_args_as_executed(tools::READ_FILE, &read), std::borrow::Cow::Borrowed(_)));
1572    }
1573}