vtcode-core 0.146.6

Core library for VT Code - a Rust-based terminal coding agent
//! Shell activity classification for progress accounting and output previews.
//!
//! Mutation safety remains owned by [`super::classify_tool_intent`]. This
//! module adds the narrower distinction between repository inspection and
//! verification without duplicating that safety decision in binary consumers.

use std::path::Path;

use serde_json::Value;

use super::readonly::{
    command_words_are_readonly, static_shell_command_words, static_shell_command_words_with_output_plumbing,
};

/// Progress semantics for a command invocation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShellActivity {
    /// Read-only repository or environment inspection.
    Inspection,
    /// A build, test, lint, or compile command that verifies work.
    Verification,
    /// A command that may mutate state and is not primarily verification.
    Mutation,
}

fn is_verification_invocation(words: &[String]) -> bool {
    let command_words = crate::tools::command_args::command_words_after_environment_prefix(words);
    let first = command_words.first().map(String::as_str).unwrap_or_default();
    let second = command_words.get(1).map(|word| word.to_ascii_lowercase());
    let third = command_words.get(2).map(|word| word.to_ascii_lowercase());
    let program = Path::new(first)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(first)
        .to_ascii_lowercase();

    match program.as_str() {
        "cargo" => {
            matches!(second.as_deref(), Some("check" | "build" | "clippy" | "test"))
                || (second.as_deref() == Some("nextest") && third.as_deref() == Some("run"))
        }
        "go" => matches!(second.as_deref(), Some("test" | "build")),
        "npm" | "pnpm" | "yarn" => {
            matches!(second.as_deref(), Some("test" | "build"))
                || (second.as_deref() == Some("run") && matches!(third.as_deref(), Some("test" | "build")))
        }
        "rustc" | "pytest" | "xcodebuild" | "gradle" | "gradlew" => true,
        _ if first.ends_with("/scripts/check.sh") || first.ends_with("/scripts/check-dev.sh") => true,
        _ => false,
    }
}

fn contains_verification_invocation(command: &str) -> bool {
    static_shell_command_words(command)
        .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
}

fn has_logical_sequencing(words: &[String]) -> bool {
    words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
}

fn is_known_inspection(words: &[String]) -> bool {
    if words
        .iter()
        .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
    {
        return false;
    }
    command_words_are_readonly(words)
}

fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
    let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
        (segments, false)
    } else {
        (static_shell_command_words_with_output_plumbing(command)?, true)
    };
    if has_output_plumbing && segments.len() != 1 {
        return None;
    }
    let has_multiple_segments = segments.len() > 1;
    let mut saw_verification = false;

    for words in segments {
        if is_verification_invocation(&words) {
            saw_verification = true;
        } else if !command_words_are_readonly(&words) {
            return None;
        }
    }

    if has_output_plumbing && !saw_verification {
        return None;
    }

    // Shell execution does not guarantee that a pipeline or logical chain's
    // final status reflects every verification stage. Do not let a successful
    // downstream command clear the anti-blind checkpoint after an earlier
    // verifier failed.
    if saw_verification && has_multiple_segments {
        return Some(ShellActivity::Mutation);
    }

    Some(if saw_verification {
        ShellActivity::Verification
    } else {
        ShellActivity::Inspection
    })
}

fn has_shell_sequence(command: &str) -> bool {
    static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
}

/// Classify a shell call without weakening the authoritative mutation guard.
///
/// Standalone output plumbing such as `2>&1` or `> build.log` does not turn a
/// primary verification command into a mutation for progress accounting.
/// Multi-stage pipelines and chains remain mutations because their final
/// status does not reliably represent every verification stage.
#[must_use]
pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
    let command = crate::tools::command_args::raw_command_text(args);
    let words = crate::tools::command_args::command_words(args).ok().flatten();
    let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);

    if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
        return activity;
    }

    let intent = super::classify_tool_intent(tool_name, args);

    if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
        return ShellActivity::Inspection;
    }

    let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
    let contains_verification =
        starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
    if !intent.mutating {
        return if contains_verification {
            ShellActivity::Verification
        } else {
            ShellActivity::Inspection
        };
    }

    if starts_with_verification
        && !has_unclassified_shell_sequence
        && !words.as_deref().is_some_and(has_logical_sequencing)
    {
        ShellActivity::Verification
    } else {
        ShellActivity::Mutation
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;
    use crate::config::constants::tools;

    fn exec_command(command: &str) -> Value {
        json!({"cmd": command})
    }

    #[test]
    fn logged_compound_inspection_commands_are_not_mutations() {
        for command in [
            "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
            "wc -l README.md; rg -n '^#' README.md",
            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
        ] {
            assert_eq!(
                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
                ShellActivity::Inspection,
                "{command}"
            );
        }
    }

    #[cfg(unix)]
    #[test]
    fn captured_read_commands_with_output_suppression_are_inspection() {
        for command in [
            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"#,
            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"###,
        ] {
            assert_eq!(
                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
                ShellActivity::Inspection,
                "{command}"
            );
        }
    }

    #[test]
    fn git_diff_check_chain_remains_inspection() {
        assert_eq!(
            classify_shell_activity(
                tools::EXEC_COMMAND,
                &exec_command("git diff --check && git status --short && git diff --stat"),
            ),
            ShellActivity::Inspection
        );
    }

    #[test]
    fn verification_detection_skips_environment_prefixes() {
        for command in [
            "env RUSTFLAGS=-Dwarnings cargo check",
            "RUSTFLAGS=-Dwarnings env cargo check",
            "env -u PATH cargo check",
            "env -C /tmp cargo check",
        ] {
            assert_eq!(
                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
                ShellActivity::Verification,
                "{command}"
            );
        }
    }

    #[test]
    fn ambiguous_or_mutating_compounds_remain_mutations() {
        for command in [
            "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
            "cat README.md; sed -i '' 's/a/b/' README.md",
            "sed --in-place= README.md",
            "git diff --output=out",
            "git diff '--output=out'",
            "git diff -o out",
            "git diff -oout",
            "git log --output=out",
            "git show --textconv",
            "git -C /external/repo=alt status",
            "find . -fprint output.txt",
            "find . -fprintf output.txt '%p'",
            "rg --hostname-bin sh pattern",
            "rg --search-zip pattern",
            "rg -z pattern",
            "sort -o generated.txt README.md",
            "sort --compress-program=sh README.md",
            "date -s now",
            "awk -i inplace '{print}' README.md",
            "sed -n 's/a/b/e' README.md",
            "fd --exec sh -c 'touch output'",
            "tree -o output.txt",
            "ast-grep -r 'README.md'",
            "sed -n -fmalicious.sed -e '1p' src/main.rs",
            "sed -I '' 's/a/b/' src/main.rs",
            "sed -n '1p\nw leaked.txt' src/main.rs",
            "cargo check & rm output",
            "cargo check > build.log | rm output",
            "cargo check | head -40 > build.log",
            "cargo check | echo x > output.log",
            "cargo check | head -40",
            "cargo check > build.log &",
            "env -S 'cargo check'",
            "echo x > output.log && cargo check",
            "cargo check < build-input.log",
            "cat README.md > copied.txt",
            "git diff --check; rm output",
            "cat README.md\nrm output",
        ] {
            assert_eq!(
                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
                ShellActivity::Mutation,
                "{command}"
            );
        }
    }

    #[test]
    fn quoted_output_text_does_not_change_inspection_classification() {
        for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
            assert_eq!(
                classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
                ShellActivity::Inspection,
                "{command}"
            );
        }
    }
}