Skip to main content

vtcode_core/tools/tool_intent/
readonly.rs

1use crate::tools::command_args::{
2    command_words_after_environment_prefix, has_unsafe_readonly_options, shell_command_text_with_args,
3};
4use crate::tools::output_spooler::SpooledOutputReference;
5use serde_json::Value;
6use std::path::Path;
7
8/// Conservative allow-list of read-only inspection commands used by
9/// `command_session`. Any command that could write, move, or delete must be
10/// rejected so it is not cached or parallelized as read-only.
11///
12/// `cd` is included because changing the working directory mutates nothing;
13/// models habitually prefix exploration with `cd <workspace> && …` and plan
14/// mode must not reject that pattern (checkpoint turn_810).
15const READONLY_UNIFIED_EXEC_COMMANDS: &[&str] = &[
16    "rg", "ls", "cat", "diff", "find", "wc", "grep", "egrep", "fgrep", "head", "tail", "sort", "uniq", "bat", "sed",
17    "awk", "cut", "tr", "ast-grep", "sg", "echo", "pwd", "printf", "true", "false", "test", "cd", "fd", "tree",
18    "which", "stat", "file", "du", "df", "realpath", "basename", "dirname", "nl", "column", "jq", "date", "whoami",
19    "uname",
20];
21
22pub fn is_readonly_base_command(command: &str) -> bool {
23    READONLY_UNIFIED_EXEC_COMMANDS.contains(&command)
24}
25
26/// Read-only subcommand allow-list for multi-word tools whose base command is
27/// not inherently safe (`git`, `cargo`, package managers). Only inspection
28/// subcommands are listed; anything that can mutate the worktree, index,
29/// refs, or lockfiles must stay out.
30///
31/// Unconditionally read-only: every form of these subcommands is an
32/// inspection, so this list is also safe for the flag-skipping parallel-safe
33/// extractor. Flag-shaped subcommands whose bare form reads but whose operand
34/// forms mutate live in [`is_readonly_flag_shaped_git_subcommand`].
35fn is_readonly_subcommand(first: &str, second: Option<&str>, third: Option<&str>) -> bool {
36    match first {
37        "git" => matches!(
38            second,
39            Some(
40                "status"
41                    | "log"
42                    | "diff"
43                    | "show"
44                    | "blame"
45                    | "ls-files"
46                    | "rev-parse"
47                    | "describe"
48                    | "shortlog"
49                    | "grep"
50                    | "rev-list"
51                    | "ls-tree"
52                    | "cat-file"
53                    | "diff-tree"
54                    | "merge-base"
55                    | "range-diff"
56                    | "whatchanged"
57                    | "count-objects"
58                    | "var"
59                    | "version"
60                    | "help"
61            )
62        ),
63        "cargo" => match second {
64            Some("check" | "test" | "metadata" | "tree" | "clippy") => true,
65            Some("nextest") => matches!(third, Some("run" | "list")),
66            _ => false,
67        },
68        "npm" | "pnpm" | "yarn" => match second {
69            Some("test") => true,
70            Some("run") => matches!(third, Some("test")),
71            _ => false,
72        },
73        _ => false,
74    }
75}
76
77/// Flag-shaped git subcommands whose bare or flag-only form is a pure read
78/// (`git tag`, `git branch -a`, `git remote -v`, `git reflog`, `git stash
79/// list`, `git worktree list`), while the same name with an operand mutates
80/// (`tag v1`, `branch -D x`, `reflog expire`, `stash pop`, `remote add`,
81/// `worktree add`). The mutating operand survives as the third word, so this
82/// classification is position-safe only on the read-only path, which keeps
83/// operands; the flag-skipping parallel-safe extractor must never consult it
84/// (there `git tag -f x` would look identical to `git tag`).
85///
86/// Two hardening rules keep the bare/flag-only forms honest:
87/// - bare `stash` is excluded: bare `git stash` is `git stash push`, which
88///   stashes uncommitted work, and flag-only push forms (`-k`, `-u`, `-a`,
89///   `-m=msg`) carry no operand to betray them;
90/// - `branch` carries a mutating-flag denylist, because inline-value flags
91///   (`--set-upstream-to=origin/x`, `--unset-upstream`, `--edit-description`)
92///   and glued short mutators (`-m<name>`, `-c<name>`, `-u<upstream>`) start
93///   with `-` and are invisible to the operand finder.
94fn is_readonly_flag_shaped_git_subcommand(
95    subcommand: Option<&str>,
96    operand: Option<&str>,
97    command_words: &[String],
98    sub_index: usize,
99) -> bool {
100    if subcommand == Some("branch") {
101        let mutates = command_words.iter().skip(sub_index + 1).any(|word| {
102            matches!(word.as_str(), "--set-upstream" | "--unset-upstream" | "--edit-description")
103                || word.starts_with("--set-upstream-to")
104                || word.starts_with("-u")
105                || word.starts_with("-m")
106                || word.starts_with("-M")
107                || word.starts_with("-c")
108                || word.starts_with("-C")
109        });
110        if mutates {
111            return false;
112        }
113    }
114
115    matches!(
116        (subcommand, operand),
117        (Some("tag") | Some("branch"), None)
118            | (Some("reflog"), None | Some("show"))
119            | (Some("remote"), None | Some("show") | Some("get-url"))
120            | (Some("stash"), Some("list") | Some("show"))
121            | (Some("worktree"), Some("list"))
122    )
123}
124
125/// A version or help probe prints to stdout and mutates nothing for any
126/// program, including subcommand-based tools (`cargo --version`, `node
127/// --version`, `python3 -V`) that the subcommand allow-list cannot classify
128/// because every word after the program is a flag. At least one probe flag
129/// is required (a bare program name like `tsc` still compiles) and ALL
130/// trailing words must be probe flags, so `python3 script.py` or `git commit
131/// -v` stay outside. Lowercase `-v` is deliberately excluded: it means
132/// verbose rather than version for most tools, and for shell interpreters
133/// (`bash -v`) it reads stdin.
134fn is_version_or_help_probe(command_words: &[String]) -> bool {
135    fn is_probe_flag(word: &str) -> bool {
136        matches!(word, "-h" | "--help" | "-V" | "--version")
137    }
138
139    let mut flags = command_words.iter().skip(1);
140    flags.next().is_some_and(|word| is_probe_flag(word)) && flags.all(|word| is_probe_flag(word))
141}
142
143/// Parse a shell script into simple command words after proving that it has no
144/// dynamic expansion, file redirection, or unsupported background operator.
145///
146/// The tree-sitter parser is the single shell-grammar boundary used by the
147/// safety subsystem. Callers receive already-tokenized command words and must
148/// still apply their own command-policy predicate; an unknown command never
149/// becomes read-only merely because parsing succeeded.
150pub(crate) fn static_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
151    let sanitized = sanitize_static_shell_command(command)?;
152    parse_shell_command_words(&sanitized)
153}
154
155/// Parse a command whose only shell operators outside quotes are output
156/// redirections. This is intentionally separate from the read-only parser:
157/// writing a build log is still a mutating command for the authoritative tool
158/// intent, but it should not hide the verification nature of `cargo check` in
159/// progress accounting.
160pub(crate) fn static_shell_command_words_with_output_plumbing(command: &str) -> Option<Vec<Vec<String>>> {
161    if !crate::command_safety::shell_parser::has_only_output_redirections(command) {
162        return None;
163    }
164    parse_shell_command_words(command)
165}
166
167/// Tokenize shell invocations without proving static execution or safety.
168/// Permission and activity predicates must sanitize before using these words;
169/// diagnostic-only consumers may inspect dynamic shell text without grants.
170pub(super) fn parse_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
171    let commands = crate::command_safety::shell_parser::parse_shell_commands_tree_sitter(command).ok()?;
172    if commands.is_empty() {
173        return None;
174    }
175
176    commands
177        .into_iter()
178        .map(|command| {
179            let mut words = Vec::new();
180            for word in command {
181                let tokens = shell_words::split(&word).ok()?;
182                words.extend(tokens);
183            }
184            (!words.is_empty()).then_some(words)
185        })
186        .collect()
187}
188
189/// Shared head of both read-only classifiers: strip environment prefixes,
190/// resolve the lowercased program name, and apply the option-level guard.
191/// Returns the stripped words plus the program name, or `None` when the
192/// command cannot be classified (fail closed). The parallel-safe classifier
193/// shares this head; its stricter tail (no builds/tests, git allow-list
194/// only) stays separate on purpose.
195///
196/// The guard reads the original `words` (not the stripped view) so
197/// environment-injected options stay visible; keep that argument as-is.
198fn readonly_command_head(words: &[String]) -> Option<(&[String], String)> {
199    let command_words = command_words_after_environment_prefix(words);
200    let first = command_words
201        .first()
202        .and_then(|word| Path::new(word).file_name())
203        .and_then(|name| name.to_str())
204        .map(str::to_ascii_lowercase)?;
205    if has_unsafe_readonly_options(words) {
206        return None;
207    }
208    Some((command_words, first))
209}
210
211/// Returns whether one parsed command is an allow-listed read-only command.
212/// Option-level guards are shared with raw argument validation so activity
213/// accounting cannot accidentally become less strict than tool intent.
214pub(crate) fn command_words_are_readonly(words: &[String]) -> bool {
215    let Some((command_words, first)) = readonly_command_head(words) else {
216        return false;
217    };
218    if first == "command" {
219        return matches!(command_words.get(1).map(String::as_str), Some("-v" | "-V"))
220            && command_words.len() > 2
221            && command_words
222                .iter()
223                .skip(2)
224                .all(|word| !word.starts_with('-') && !word.is_empty());
225    }
226
227    if is_readonly_base_command(&first)
228        || is_version_or_help_probe(command_words)
229        || is_known_readonly_dry_run(command_words, &first)
230    {
231        return true;
232    }
233
234    let lowered = command_words
235        .iter()
236        .filter(|word| !word.starts_with('-') && !word.contains('='))
237        .take(3)
238        .map(|word| word.to_ascii_lowercase())
239        .collect::<Vec<_>>();
240    if first != "git" {
241        let (second, third) = (lowered.get(1).map(String::as_str), lowered.get(2).map(String::as_str));
242        return is_readonly_subcommand(&first, second, third);
243    }
244
245    let Some((sub_index, operand)) = git_subcommand_and_operand(command_words) else {
246        return false;
247    };
248    let subcommand = command_words.get(sub_index).map(String::as_str);
249    is_readonly_subcommand("git", subcommand, None)
250        || is_readonly_flag_shaped_git_subcommand(subcommand, operand, command_words, sub_index)
251}
252
253/// Locate the git subcommand and the first operand after it, skipping global
254/// options — including their separate values (`-C <dir>`, `-c <config>`,
255/// `--git-dir <path>`, `--shallow-file <info>`), whose inline forms
256/// (`-C<dir>`, `--git-dir=<path>`) carry no extra word. Without this, the
257/// option value occupies the subcommand slot and worktree-style exploration
258/// (`git -C <dir> log`) is misread as an unknown subcommand.
259///
260/// Position-based on purpose: the operand of a flag-shaped subcommand stays
261/// visible as the second tuple field, which is what keeps `git tag v1`
262/// distinguishable from `git tag -l`. Fails closed on any unknown
263/// global-shaped word: an unrecognized option may consume a separate value,
264/// which would otherwise smuggle the real (possibly mutating) subcommand past
265/// the allow-list (`git --shallow-file log commit -m x`).
266///
267/// Returns the subcommand's index into `command_words` (callers re-read it —
268/// real git subcommands are case-sensitive, so no lowercasing happens here)
269/// plus the first non-flag operand after it, or `None` when the command must
270/// stay denied.
271fn git_subcommand_and_operand(command_words: &[String]) -> Option<(usize, Option<&str>)> {
272    const GLOBAL_OPTIONS_WITH_VALUE: &[&str] = &[
273        "-C",
274        "-c",
275        "--git-dir",
276        "--work-tree",
277        "--namespace",
278        "--super-prefix",
279        "--exec-path",
280        "--config-env",
281        "--shallow-file",
282        "--attr-source",
283    ];
284    const GLOBAL_OPTIONS_FLAG_ONLY: &[&str] = &[
285        "-p",
286        "--paginate",
287        "--no-pager",
288        "--bare",
289        "--literal-pathspecs",
290        "--no-replace-objects",
291        "--no-optional-locks",
292        "--end-of-options",
293        "--version",
294        "--help",
295        "--html-path",
296    ];
297
298    let mut index = 1;
299    while let Some(word) = command_words.get(index) {
300        if !word.starts_with('-') {
301            let operand = command_words
302                .iter()
303                .skip(index + 1)
304                .find(|candidate| !candidate.starts_with('-'))
305                .map(String::as_str);
306            return Some((index, operand));
307        }
308        if GLOBAL_OPTIONS_WITH_VALUE.contains(&word.as_str()) {
309            index += 2; // skip the option and its separate value
310            continue;
311        }
312        if GLOBAL_OPTIONS_FLAG_ONLY.contains(&word.as_str()) {
313            index += 1;
314            continue;
315        }
316        // Unknown global-shaped word: an option this allow-list does not know
317        // may take a separate value, so the next word could be that value
318        // rather than the subcommand. Fail closed.
319        return None;
320    }
321    None
322}
323
324/// Return whether one command is a side-effect-free inspection suitable for
325/// concurrent execution. Build and test commands remain read-only for policy
326/// purposes, but are excluded here because they write caches, lockfiles, or
327/// build artifacts and contend on shared package-manager state.
328fn command_words_are_parallel_safe(words: &[String]) -> bool {
329    let Some((command_words, first)) = readonly_command_head(words) else {
330        return false;
331    };
332
333    if is_readonly_base_command(&first) {
334        return true;
335    }
336    // Version/help probes are side-effect-free for any program.
337    if is_version_or_help_probe(command_words) {
338        return true;
339    }
340    if first != "git" {
341        return false;
342    }
343
344    let subcommand = command_words
345        .iter()
346        .skip(1)
347        .find(|word| !word.starts_with('-') && !word.contains('='))
348        .map(|word| word.to_ascii_lowercase());
349    is_readonly_subcommand("git", subcommand.as_deref(), None)
350}
351
352fn is_known_readonly_dry_run(words: &[String], program: &str) -> bool {
353    if !matches!(program, "npm" | "pnpm" | "yarn") {
354        return false;
355    }
356
357    let Some(subcommand) = words.iter().skip(1).find(|word| !word.starts_with('-') && !word.contains('=')) else {
358        return false;
359    };
360
361    subcommand == "install" && words.iter().any(|word| word == "--dry-run")
362}
363
364fn matches_at(chars: &[char], index: usize, pattern: &[char]) -> bool {
365    chars
366        .get(index..index.saturating_add(pattern.len()))
367        .is_some_and(|candidate| candidate == pattern)
368}
369
370/// Remove only stderr plumbing that cannot write workspace state and reject
371/// all other redirection/background syntax. Operators inside quoted arguments
372/// remain ordinary data and are never rewritten.
373fn sanitize_static_shell_command(command: &str) -> Option<String> {
374    if crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command) {
375        return None;
376    }
377
378    let chars = command.chars().collect::<Vec<_>>();
379    let mut sanitized = String::with_capacity(command.len());
380    let mut index = 0;
381    let mut in_single_quote = false;
382    let mut in_double_quote = false;
383
384    while index < chars.len() {
385        let character = chars[index];
386        if character == '\'' && !in_double_quote {
387            in_single_quote = !in_single_quote;
388            sanitized.push(character);
389            index += 1;
390            continue;
391        }
392        if character == '"' && !in_single_quote {
393            in_double_quote = !in_double_quote;
394            sanitized.push(character);
395            index += 1;
396            continue;
397        }
398        if in_single_quote || in_double_quote {
399            sanitized.push(character);
400            index += 1;
401            continue;
402        }
403
404        let preceded_by_boundary = index == 0
405            || chars
406                .get(index.saturating_sub(1))
407                .is_some_and(|previous| previous.is_whitespace() || matches!(previous, '|' | '&' | ';'));
408        if preceded_by_boundary && matches_at(&chars, index, &['2', '>', '&', '1']) {
409            index += 4;
410            continue;
411        }
412        if cfg!(unix)
413            && preceded_by_boundary
414            && matches_at(&chars, index, &['2', '>', '/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'])
415        {
416            index += 11;
417            continue;
418        }
419
420        match character {
421            '<' | '>' => return None,
422            '&' if matches_at(&chars, index, &['&', '&']) => {
423                sanitized.push('&');
424                sanitized.push('&');
425                index += 2;
426            }
427            '&' => return None,
428            _ => {
429                sanitized.push(character);
430                index += 1;
431            }
432        }
433    }
434
435    (!sanitized.trim().is_empty()).then_some(sanitized)
436}
437
438/// Shared extract + static-parse prologue for command-session classifiers.
439///
440/// Extracts the full classification candidate (including the quoted argv
441/// suffix) and proves a static shape through the tree-sitter boundary.
442/// Returns `None` when the args cannot prove one invocation (conflicting raw
443/// alias, dynamic syntax, redirection) — every caller maps `None` to
444/// fail-closed `false`, in the same order as before.
445fn parsed_session_commands(args: &Value) -> Option<Vec<Vec<String>>> {
446    let raw = shell_command_text_with_args(args)?;
447    static_shell_command_words(&raw)
448}
449
450/// Shared read-only gate over already-parsed segments: a `&&` chain is
451/// accepted only when every segment independently passes the allow-list.
452/// (`sanitize_static_shell_command` already rejected `;`, `||`,
453/// redirections, and background `&` up front.)
454fn all_commands_readonly(commands: &[Vec<String>]) -> bool {
455    commands.iter().all(|words| command_words_are_readonly(words))
456}
457
458pub fn is_readonly_command_session_command(args: &Value) -> bool {
459    let Some(commands) = parsed_session_commands(args) else {
460        return false;
461    };
462
463    all_commands_readonly(&commands)
464}
465
466/// A stricter read-only command shape that may run concurrently after hooks,
467/// command admission, and preflight have all succeeded.
468pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
469    if args.get("session_id").is_some()
470        || args.get("chars").is_some()
471        || args.get("input").is_some()
472        || args.get("tty").and_then(Value::as_bool) == Some(true)
473        || args
474            .get("sandbox_permissions")
475            .and_then(Value::as_str)
476            .is_some_and(|value| value != "use_default")
477    {
478        return false;
479    }
480    let Some(commands) = parsed_session_commands(args) else {
481        return false;
482    };
483    commands.len() == 1 && command_words_are_parallel_safe(&commands[0])
484}
485
486/// Returns `true` when a safe shell inspection command reads the internal tool
487/// output spool directory. Such reads must stay inline: spooling their output
488/// again would create a recursive chain of spool references.
489pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
490    if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
491        return false;
492    }
493
494    // Parse once through the shared prologue: the same segments feed both
495    // the read-only gate below and the spool-path scan, so a completed
496    // command output (see `prepare_execution_output`) pays the extract +
497    // sanitize + tree-sitter pipeline a single time.
498    let Some(commands) = parsed_session_commands(args) else {
499        return false;
500    };
501    if !all_commands_readonly(&commands) {
502        return false;
503    }
504    commands
505        .iter()
506        .flatten()
507        .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use serde_json::json;
514
515    fn run_cmd(command: &str) -> Value {
516        json!({"action": "run", "command": command})
517    }
518
519    #[test]
520    fn command_availability_probes_do_not_admit_execution() {
521        for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
522            assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
523        }
524        for command in [
525            "command rm file",
526            "command -p rm file",
527            "command -v",
528            "command -v -- rm",
529            "command -v $(touch file)",
530        ] {
531            assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
532        }
533    }
534
535    #[test]
536    fn and_chain_allows_readonly_segments() {
537        assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
538        assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
539        assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
540    }
541
542    #[test]
543    fn compound_inspection_commands_share_the_readonly_policy() {
544        for command in [
545            "cat README.md; rg -n '^#' README.md",
546            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
547            "cat README.md\nrg -n '^version' Cargo.toml",
548        ] {
549            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
550        }
551    }
552
553    #[test]
554    fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
555        let command = r#"sed -n '180,285p' src/main.rs; sed -n '60,285p' src/startup/mod.rs; sed -n '1,220p' src/main_helpers/bootstrap.rs; rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#;
556
557        assert!(is_readonly_command_session_command(&run_cmd(command)));
558    }
559
560    #[test]
561    fn and_chain_rejects_destructive_segments() {
562        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
563        assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
564        assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
565    }
566
567    #[test]
568    fn and_chain_rejects_non_allowlisted_segments() {
569        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
570        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
571        assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
572        assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
573    }
574
575    #[test]
576    fn and_chain_allows_pipeline_within_segment() {
577        assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
578    }
579
580    #[test]
581    fn and_chain_single_command_passes() {
582        assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
583        assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
584    }
585
586    #[test]
587    fn readonly_command_session_allows_and_chain() {
588        // The exact pattern from checkpoint turn_726 that was blocked.
589        assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
590    }
591
592    #[test]
593    fn readonly_command_session_rejects_destructive_and_chain() {
594        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
595    }
596
597    #[test]
598    fn awk_range_print_is_readonly() {
599        // Regression shapes from the blocked README session: `awk` paging
600        // must not count as blind-editing mutations.
601        for command in [
602            "awk 'NR>=40 && NR<=140' README.md",
603            "awk 'NR>=297 && NR<=312' README.md",
604            "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
605            "awk -F: '{print $1}' README.md",
606            "awk -F, '{print $2}' data.csv",
607            "awk -v limit=10 'NR<=limit' README.md",
608            "awk -- '{print $1}' README.md",
609            "awk -- '{print $1}' -- -weird",
610            // `systime()` only reads the clock — must not be confused with
611            // `system()`. Absolute paths resolve via file_name().
612            "awk 'BEGIN{print systime()}' README.md",
613            "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
614            "awk 'NR>=1 && NR<=5' README.md | sort",
615            // Blocked-session regression: numeric `>` comparisons without
616            // `print` redirection are reads, not writes.
617            "awk '$3>100' README.md",
618            "awk '{if(p2==0 && i>1) print}' README.md",
619            "awk '{if(NR>1 && $2>10) print $1}' README.md",
620            "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
621            "awk '{print (a>b)}' README.md",
622        ] {
623            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
624        }
625    }
626
627    #[test]
628    fn awk_write_primitives_stay_mutating() {
629        for command in [
630            "awk '{print > \"out.txt\"}' README.md",
631            "awk '{print >> \"out.txt\"}' README.md",
632            "awk '{print x > \"out.txt\"}' README.md",
633            "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
634            "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
635            "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
636            "awk '{print | \"sort\"}' README.md",
637            "awk '\"sort\" | getline line' README.md",
638            "awk 'BEGIN{system(\"touch out\")}' README.md",
639            "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
640            "awk 'BEGIN{System(\"id\")}' README.md",
641            "awk 'BEGIN{system\t(\"id\")}' README.md",
642            // gawk indirect calls and directives can execute or load code,
643            // including a `system` name smuggled via `-v`. Fail closed.
644            "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
645            "awk 'BEGIN{@s(\"id\")}' README.md",
646            "awk '@include \"x.awk\"' README.md",
647            "awk '@load \"ext\"' README.md",
648            "awk '{print \"a@b\"}' README.md",
649            // `|` alternations inside regex stay conservative without a full
650            // parser; single `>` without `print` is now a comparison above.
651            "awk '/error|warning/' README.md",
652            "awk -i inplace '{print}' README.md",
653            "awk -f program.awk README.md",
654            "awk --source '{print}' README.md",
655            "awk -l injail '{print}' README.md",
656            "awk --profile '{print}' README.md",
657            "awk -W dump-variables '{print}' README.md",
658            "awk --posix '{print}' README.md",
659            "awk -F:",
660            "awk",
661            "awk -v",
662            // Shell-level redirection stays mutating even with a safe program.
663            "awk 'NR>=1' README.md > out.txt",
664        ] {
665            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
666        }
667    }
668
669    #[test]
670    fn awk_string_literal_operators_are_readonly() {
671        // The reported false positive: a quoted `"|"` passed to `index()` is a
672        // string literal, not a command pipe, so the lexer must not reject it.
673        for command in [
674            r#"awk '{n=index(rest,"|"); print n}' README.md"#,
675            r#"awk 'index($0,">")' README.md"#,
676            // `>=` comparison and `||` boolean operators are not redirection
677            // or a pipe.
678            r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
679            // Division must not be mistaken for a regex literal.
680            r#"awk '{print $1 / $2}' README.md"#,
681            "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
682            // Exact reported shape: a multi-line program whose `index(rest,"|")`
683            // carries a quoted pipe and a `# ...` comment line.
684            r#"awk 'NR>=208 && NR<=212 {line=$0; body=substr(line,1,length(line)-1); n=0; while (body ~ / \$/) { body=substr(body,1,length(body)-1); n++ }} # find guide start after label cell
685rest=substr(line,3); g=index(rest,"|")+1; guide=substr(rest,g+2); gp=0; gg=guide; while (gg ~ / \$/) { gg=substr(gg,1,length(gg)-1); gp++ } print "%d: linelen=%d labelcell=%s pad_before_final_pipe=%d guide_pad=%d\n", NR, length(line), substr(line,3,20), n, gp }' README.md"#,
686        ] {
687            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
688        }
689    }
690
691    #[test]
692    fn awk_regex_with_quote_cannot_hide_pipe() {
693        // Adversarial shape for a naive quote-toggling scanner: `/a"b/` opens
694        // a "string" that stays open, hiding the real `|`; the later `/c"d/`
695        // rebalances the quote count, so an unbalanced-quote guard never
696        // fires. The lexer treats quotes as regex content and still catches
697        // the pipe.
698        for command in [
699            r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
700            // Regex alternation stays mutating (pinned conservative policy).
701            r#"awk '/a|b/ {print}' README.md"#,
702        ] {
703            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
704        }
705    }
706
707    #[test]
708    fn cd_prefixed_exploration_is_readonly() {
709        // Exact patterns from checkpoint turn_810 that plan mode rejected:
710        // the model habitually prefixes exploration with `cd <workspace> &&`.
711        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
712        assert!(is_readonly_command_session_command(&run_cmd(
713            "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
714        )));
715        // `cd` must not become a bypass for mutating chains.
716        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
717        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
718    }
719
720    #[test]
721    fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
722        assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
723        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
724        assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
725        assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
726        // Mutating git subcommands stay rejected.
727        assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
728        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
729        assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
730    }
731
732    #[test]
733    fn env_value_injection_is_not_readonly() {
734        // A4: `command_words_after_environment_prefix` strips assignments, so
735        // values must still be inspected. `GIT_CONFIG_*` reproduces the blocked
736        // `git -c core.fsmonitor` injection without any `-c` token.
737        for command in [
738            "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
739            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
740            "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
741            "env GIT_EXTERNAL_DIFF=evil git diff",
742            "env GIT_TEXTCONV=evil git show HEAD",
743            "env LD_PRELOAD=./evil.so ls",
744            "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
745            "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
746            "env NODE_OPTIONS='--require ./evil.js' node --version",
747            "env EDITOR=evil sed -n '1,10p' README.md",
748        ] {
749            assert!(
750                !is_readonly_command_session_command(&run_cmd(command)),
751                "expected mutating env-injection command: {command}"
752            );
753        }
754
755        // Ordinary env prefixes stay read-only.
756        for command in [
757            "env LANG=C.UTF-8 rg 'TODO' src",
758            "FOO=bar git status",
759            "env FOO=bar git log --oneline",
760            "env -u GIT_CONFIG_COUNT git status",
761            "env -C /repo git status",
762        ] {
763            assert!(
764                is_readonly_command_session_command(&run_cmd(command)),
765                "expected readonly env-prefixed command: {command}"
766            );
767        }
768    }
769
770    #[test]
771    fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
772        // turn-1030-class exploration: read-only plumbing subcommands the
773        // plan gate used to deny outright.
774        for command in [
775            "git rev-list --count HEAD",
776            "git ls-tree HEAD crates/",
777            "git cat-file -p HEAD:Cargo.toml",
778            "git diff-tree -r HEAD",
779            "git merge-base main feat",
780            "git range-diff main~2..main feat~2..feat",
781            "git whatchanged -5",
782            "git count-objects -v",
783            "git reflog | head",
784            "git worktree list",
785            "git stash list",
786            "git stash show",
787            "git branch --show-current",
788            "git --no-pager log --oneline | head -5",
789            "git remote -v",
790            "cd /repo && git -C . branch -a && git tag",
791        ] {
792            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
793        }
794    }
795
796    #[test]
797    fn version_and_help_probes_are_readonly_for_any_program() {
798        for command in [
799            "cargo --version",
800            "node --version",
801            "python3 -V",
802            "npm --version",
803            "git --version",
804            "bash --help",
805        ] {
806            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
807        }
808        // Only an all-flags probe is a version/help query; real operands keep
809        // the command outside the allow-list. Lowercase `-v` is excluded: it
810        // means verbose for most tools and reads stdin for shells.
811        assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
812        assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
813        assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
814    }
815
816    #[test]
817    fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
818        // `-C <dir>` only redirects which repository is read; plan mode must
819        // not reject worktree-style exploration.
820        assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
821        assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
822        // Config injection (`-c`) stays denied — the `-C`/`-c` pair must not
823        // be relaxed together.
824        assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
825        // A read-only subcommand with a write option stays denied.
826        assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
827    }
828
829    #[test]
830    fn readonly_git_and_inspection_options_stay_fail_closed() {
831        for command in [
832            "git diff -o output.txt",
833            "git diff '--output=output.txt'",
834            "git diff -ooutput.txt",
835            "git log --output=output.txt",
836            "git show --textconv",
837            // Config injection is a command-execution vector and stays denied.
838            "git -c core.fsmonitor=touch status",
839            "git --exec-path=/tmp/sh status",
840            // Bare `git stash` is `git stash push` — it stashes uncommitted
841            // work — and flag-only push forms carry no operand to betray
842            // them. Only `list`/`show` are reads.
843            "git stash",
844            "git stash -u",
845            "git stash -k",
846            // Inline-value and glued short mutators are invisible to the
847            // operand finder and must stay denied.
848            "git branch --unset-upstream",
849            "git branch --set-upstream-to=origin/main",
850            "git branch --edit-description",
851            "git branch -mnew-name",
852            "git branch -u=origin/main",
853            // An unknown value-taking global must not smuggle a mutating
854            // subcommand past the allow-list.
855            "git --shallow-file log commit -m x",
856            // Operand forms of flag-shaped subcommands mutate and stay denied.
857            "git tag v1.0.0",
858            "git branch feature-x",
859            "git branch -D stale",
860            "git reflog expire --expire=now --all",
861            "git stash pop",
862            "git remote add origin https://example.com/r.git",
863            "git worktree add ../side feat",
864            "find . -fprint output.txt",
865            "find . -fprintf output.txt '%p'",
866            "rg --hostname-bin sh pattern",
867            "rg --search-zip pattern",
868            "rg -z pattern",
869            "sort -o output.txt README.md",
870            "sort --compress-program=sh README.md",
871            "date -s now",
872            "awk -i inplace '{print}' README.md",
873            "sed -n 's/a/b/e' README.md",
874            "fd --exec sh -c 'touch out'",
875            "tree -o out.txt",
876            "ast-grep -r 'README.md'",
877            "sed -n -fmalicious.sed -e '1p' src/main.rs",
878            "sed -I '' 's/a/b/' src/main.rs",
879            "sed -n '1p\nw leaked.txt' src/main.rs",
880            "sed -n 'woutput.txt' src/main.rs",
881            "cargo check & rm output",
882        ] {
883            assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
884        }
885    }
886
887    #[test]
888    fn cargo_readonly_subcommands_allowed() {
889        assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
890        assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
891        assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
892        assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
893        assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
894    }
895
896    #[test]
897    fn parallel_commands_are_limited_to_side_effect_free_inspection() {
898        for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
899            assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
900        }
901        for command in [
902            "cargo check",
903            "cargo test",
904            "cargo nextest run",
905            "cargo clippy",
906            "npm test",
907            "pnpm run test",
908            "git diff | head -20",
909        ] {
910            assert!(
911                !is_parallel_safe_command_session_command(&run_cmd(command)),
912                "command can touch shared state or crosses a sequential boundary: {command}"
913            );
914        }
915    }
916
917    #[test]
918    fn shared_command_head_fails_closed_on_env_only_words() {
919        // Asymmetric pair: env assignments alone leave no program to
920        // classify (fail closed), while the same prefix with a program
921        // classifies on both paths. Guards the shared helper's strip
922        // order: prefix strip first, program resolution second.
923        let env_only: Vec<String> = vec!["FOO=bar".to_string()];
924        assert!(!command_words_are_readonly(&env_only));
925        assert!(!command_words_are_parallel_safe(&env_only));
926        assert!(readonly_command_head(&env_only).is_none());
927
928        let env_ls: Vec<String> = vec!["FOO=bar".to_string(), "ls".to_string()];
929        assert!(command_words_are_readonly(&env_ls));
930        assert!(command_words_are_parallel_safe(&env_ls));
931        let (stripped, first) = readonly_command_head(&env_ls).expect("env-prefixed ls classifies");
932        assert_eq!(first, "ls");
933        assert_eq!(stripped, ["ls".to_string()].as_slice());
934    }
935
936    #[test]
937    fn parallel_safe_path_rejects_env_injection_like_readonly() {
938        // The shared head must pass the ORIGINAL words (env prefix
939        // included) to the option guard. Stripped `["ls"]` looks clean;
940        // only the unstripped view carries the injection.
941        let injected: Vec<String> = vec!["LD_PRELOAD=./evil.so".to_string(), "ls".to_string()];
942        assert!(!command_words_are_parallel_safe(&injected));
943        assert!(!command_words_are_readonly(&injected));
944        assert!(readonly_command_head(&injected).is_none());
945    }
946
947    #[test]
948    fn stderr_merge_is_not_a_write_redirection() {
949        assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
950        // A real file redirection is still rejected.
951        assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
952    }
953
954    #[test]
955    fn extra_inspection_base_commands_are_readonly() {
956        assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
957        assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
958        assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
959        assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
960    }
961
962    #[test]
963    fn spool_file_reads_are_detected_only_for_readonly_commands() {
964        for command in [
965            "cat .vtcode/context/tool_outputs/run-1.txt",
966            "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
967            "rg error .vtcode/context/tool_outputs",
968            "rg -n error .vtcode/context/tool_outputs | head -20",
969            "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
970        ] {
971            assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
972        }
973
974        for command in [
975            "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
976            "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
977            "rm .vtcode/context/tool_outputs/run-1.txt",
978            "cat \"$VTCODE_SPOOL\"",
979            "cat .vtcode/context/tool_outputs/run-1.txt |",
980            "cat notes-.vtcode/context/tool_outputs/run-1.txt",
981            "cat .vtcode/context/tool_outputs-extra/run-1.txt",
982        ] {
983            assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
984        }
985
986        assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
987        assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
988    }
989
990    #[test]
991    fn spool_file_read_uses_one_parse_for_gate_and_scan() {
992        // Equivalence with the old parse-twice form: env prefixes and
993        // `&&` chains must resolve identically in the gate and the scan.
994        // A dropped `all(readonly)` gate would flip the mutating row to
995        // true (spooling a mutation); a reordered extract would flip the
996        // env-prefixed row to false.
997        assert!(is_spool_file_read_command(
998            "exec_command",
999            &run_cmd("FOO=bar cat .vtcode/context/tool_outputs/run-1.txt")
1000        ));
1001        assert!(is_spool_file_read_command(
1002            "exec_command",
1003            &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && echo done")
1004        ));
1005        assert!(!is_spool_file_read_command(
1006            "exec_command",
1007            &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt")
1008        ));
1009    }
1010
1011    #[test]
1012    fn shared_prologue_keeps_classifier_gates_distinct() {
1013        // Same args through all three classifiers: outcomes must differ per
1014        // gate, proving the shared extract+parse did not collapse predicates.
1015        // Asymmetric by construction: each row flips a different gate.
1016        let spool_read = run_cmd("cat .vtcode/context/tool_outputs/run-1.txt");
1017        assert!(is_readonly_command_session_command(&spool_read));
1018        assert!(is_spool_file_read_command("exec_command", &spool_read));
1019        assert!(is_parallel_safe_command_session_command(&spool_read));
1020
1021        // Build commands are policy-readonly but touch shared package-manager
1022        // state, so parallel safety stays denied while the spool gate (which
1023        // needs a spool path) also stays denied.
1024        let build = run_cmd("cargo check");
1025        assert!(is_readonly_command_session_command(&build));
1026        assert!(!is_parallel_safe_command_session_command(&build));
1027        assert!(!is_spool_file_read_command("exec_command", &build));
1028
1029        // A mutating tail fails the shared read-only gate on both paths that
1030        // use it; the spool scan must never admit a mutation.
1031        let mutating_spool = run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt");
1032        assert!(!is_readonly_command_session_command(&mutating_spool));
1033        assert!(!is_spool_file_read_command("exec_command", &mutating_spool));
1034
1035        // Conflicting raw overrides prove no single invocation: fail closed
1036        // on every classifier.
1037        let conflicted = json!({"action": "run", "command": "ls", "raw_command": "rm -rf /"});
1038        assert!(!is_readonly_command_session_command(&conflicted));
1039        assert!(!is_parallel_safe_command_session_command(&conflicted));
1040        assert!(!is_spool_file_read_command("exec_command", &conflicted));
1041    }
1042}