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