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/// Returns whether one parsed command is an allow-listed read-only command.
190/// Option-level guards are shared with raw argument validation so activity
191/// accounting cannot accidentally become less strict than tool intent.
192pub(crate) fn command_words_are_readonly(words: &[String]) -> bool {
193    let command_words = command_words_after_environment_prefix(words);
194    let Some(first) = command_words
195        .first()
196        .and_then(|word| Path::new(word).file_name())
197        .and_then(|name| name.to_str())
198        .map(str::to_ascii_lowercase)
199    else {
200        return false;
201    };
202
203    if has_unsafe_readonly_options(words) {
204        return false;
205    }
206
207    if first == "command" {
208        return matches!(command_words.get(1).map(String::as_str), Some("-v" | "-V"))
209            && command_words.len() > 2
210            && command_words
211                .iter()
212                .skip(2)
213                .all(|word| !word.starts_with('-') && !word.is_empty());
214    }
215
216    if is_readonly_base_command(&first)
217        || is_version_or_help_probe(command_words)
218        || is_known_readonly_dry_run(command_words, &first)
219    {
220        return true;
221    }
222
223    let lowered = command_words
224        .iter()
225        .filter(|word| !word.starts_with('-') && !word.contains('='))
226        .take(3)
227        .map(|word| word.to_ascii_lowercase())
228        .collect::<Vec<_>>();
229    if first != "git" {
230        let (second, third) = (lowered.get(1).map(String::as_str), lowered.get(2).map(String::as_str));
231        return is_readonly_subcommand(&first, second, third);
232    }
233
234    let Some((sub_index, operand)) = git_subcommand_and_operand(command_words) else {
235        return false;
236    };
237    let subcommand = command_words.get(sub_index).map(String::as_str);
238    is_readonly_subcommand("git", subcommand, None)
239        || is_readonly_flag_shaped_git_subcommand(subcommand, operand, command_words, sub_index)
240}
241
242/// Locate the git subcommand and the first operand after it, skipping global
243/// options — including their separate values (`-C <dir>`, `-c <config>`,
244/// `--git-dir <path>`, `--shallow-file <info>`), whose inline forms
245/// (`-C<dir>`, `--git-dir=<path>`) carry no extra word. Without this, the
246/// option value occupies the subcommand slot and worktree-style exploration
247/// (`git -C <dir> log`) is misread as an unknown subcommand.
248///
249/// Position-based on purpose: the operand of a flag-shaped subcommand stays
250/// visible as the second tuple field, which is what keeps `git tag v1`
251/// distinguishable from `git tag -l`. Fails closed on any unknown
252/// global-shaped word: an unrecognized option may consume a separate value,
253/// which would otherwise smuggle the real (possibly mutating) subcommand past
254/// the allow-list (`git --shallow-file log commit -m x`).
255///
256/// Returns the subcommand's index into `command_words` (callers re-read it —
257/// real git subcommands are case-sensitive, so no lowercasing happens here)
258/// plus the first non-flag operand after it, or `None` when the command must
259/// stay denied.
260fn git_subcommand_and_operand(command_words: &[String]) -> Option<(usize, Option<&str>)> {
261    const GLOBAL_OPTIONS_WITH_VALUE: &[&str] = &[
262        "-C",
263        "-c",
264        "--git-dir",
265        "--work-tree",
266        "--namespace",
267        "--super-prefix",
268        "--exec-path",
269        "--config-env",
270        "--shallow-file",
271        "--attr-source",
272    ];
273    const GLOBAL_OPTIONS_FLAG_ONLY: &[&str] = &[
274        "-p",
275        "--paginate",
276        "--no-pager",
277        "--bare",
278        "--literal-pathspecs",
279        "--no-replace-objects",
280        "--no-optional-locks",
281        "--end-of-options",
282        "--version",
283        "--help",
284        "--html-path",
285    ];
286
287    let mut index = 1;
288    while let Some(word) = command_words.get(index) {
289        if !word.starts_with('-') {
290            let operand = command_words
291                .iter()
292                .skip(index + 1)
293                .find(|candidate| !candidate.starts_with('-'))
294                .map(String::as_str);
295            return Some((index, operand));
296        }
297        if GLOBAL_OPTIONS_WITH_VALUE.contains(&word.as_str()) {
298            index += 2; // skip the option and its separate value
299            continue;
300        }
301        if GLOBAL_OPTIONS_FLAG_ONLY.contains(&word.as_str()) {
302            index += 1;
303            continue;
304        }
305        // Unknown global-shaped word: an option this allow-list does not know
306        // may take a separate value, so the next word could be that value
307        // rather than the subcommand. Fail closed.
308        return None;
309    }
310    None
311}
312
313/// Return whether one command is a side-effect-free inspection suitable for
314/// concurrent execution. Build and test commands remain read-only for policy
315/// purposes, but are excluded here because they write caches, lockfiles, or
316/// build artifacts and contend on shared package-manager state.
317fn command_words_are_parallel_safe(words: &[String]) -> bool {
318    let command_words = command_words_after_environment_prefix(words);
319    let Some(first) = command_words
320        .first()
321        .and_then(|word| Path::new(word).file_name())
322        .and_then(|name| name.to_str())
323        .map(str::to_ascii_lowercase)
324    else {
325        return false;
326    };
327    if has_unsafe_readonly_options(words) {
328        return false;
329    }
330
331    if is_readonly_base_command(&first) {
332        return true;
333    }
334    // Version/help probes are side-effect-free for any program.
335    if is_version_or_help_probe(command_words) {
336        return true;
337    }
338    if first != "git" {
339        return false;
340    }
341
342    let subcommand = command_words
343        .iter()
344        .skip(1)
345        .find(|word| !word.starts_with('-') && !word.contains('='))
346        .map(|word| word.to_ascii_lowercase());
347    is_readonly_subcommand("git", subcommand.as_deref(), None)
348}
349
350fn is_known_readonly_dry_run(words: &[String], program: &str) -> bool {
351    if !matches!(program, "npm" | "pnpm" | "yarn") {
352        return false;
353    }
354
355    let Some(subcommand) = words.iter().skip(1).find(|word| !word.starts_with('-') && !word.contains('=')) else {
356        return false;
357    };
358
359    subcommand == "install" && words.iter().any(|word| word == "--dry-run")
360}
361
362fn matches_at(chars: &[char], index: usize, pattern: &[char]) -> bool {
363    chars
364        .get(index..index.saturating_add(pattern.len()))
365        .is_some_and(|candidate| candidate == pattern)
366}
367
368/// Remove only stderr plumbing that cannot write workspace state and reject
369/// all other redirection/background syntax. Operators inside quoted arguments
370/// remain ordinary data and are never rewritten.
371fn sanitize_static_shell_command(command: &str) -> Option<String> {
372    if crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command) {
373        return None;
374    }
375
376    let chars = command.chars().collect::<Vec<_>>();
377    let mut sanitized = String::with_capacity(command.len());
378    let mut index = 0;
379    let mut in_single_quote = false;
380    let mut in_double_quote = false;
381
382    while index < chars.len() {
383        let character = chars[index];
384        if character == '\'' && !in_double_quote {
385            in_single_quote = !in_single_quote;
386            sanitized.push(character);
387            index += 1;
388            continue;
389        }
390        if character == '"' && !in_single_quote {
391            in_double_quote = !in_double_quote;
392            sanitized.push(character);
393            index += 1;
394            continue;
395        }
396        if in_single_quote || in_double_quote {
397            sanitized.push(character);
398            index += 1;
399            continue;
400        }
401
402        let preceded_by_boundary = index == 0
403            || chars
404                .get(index.saturating_sub(1))
405                .is_some_and(|previous| previous.is_whitespace() || matches!(previous, '|' | '&' | ';'));
406        if preceded_by_boundary && matches_at(&chars, index, &['2', '>', '&', '1']) {
407            index += 4;
408            continue;
409        }
410        if cfg!(unix)
411            && preceded_by_boundary
412            && matches_at(&chars, index, &['2', '>', '/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'])
413        {
414            index += 11;
415            continue;
416        }
417
418        match character {
419            '<' | '>' => return None,
420            '&' if matches_at(&chars, index, &['&', '&']) => {
421                sanitized.push('&');
422                sanitized.push('&');
423                index += 2;
424            }
425            '&' => return None,
426            _ => {
427                sanitized.push(character);
428                index += 1;
429            }
430        }
431    }
432
433    (!sanitized.trim().is_empty()).then_some(sanitized)
434}
435
436pub fn is_readonly_command_session_command(args: &Value) -> bool {
437    let Some(raw) = shell_command_text_with_args(args) else {
438        return false;
439    };
440
441    // `sanitize_static_shell_command` rejects `;`, `||`, redirections, and
442    // background `&` up front; a `&&` chain is accepted only when every
443    // parsed segment independently passes the allow-list below.
444    static_shell_command_words(&raw)
445        .is_some_and(|commands| commands.iter().all(|words| command_words_are_readonly(words)))
446}
447
448/// A stricter read-only command shape that may run concurrently after hooks,
449/// command admission, and preflight have all succeeded.
450pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
451    if args.get("session_id").is_some()
452        || args.get("chars").is_some()
453        || args.get("input").is_some()
454        || args.get("tty").and_then(Value::as_bool) == Some(true)
455        || args
456            .get("sandbox_permissions")
457            .and_then(Value::as_str)
458            .is_some_and(|value| value != "use_default")
459    {
460        return false;
461    }
462    let Some(raw) = shell_command_text_with_args(args) else {
463        return false;
464    };
465    static_shell_command_words(&raw)
466        .is_some_and(|commands| commands.len() == 1 && command_words_are_parallel_safe(&commands[0]))
467}
468
469/// Returns `true` when a safe shell inspection command reads the internal tool
470/// output spool directory. Such reads must stay inline: spooling their output
471/// again would create a recursive chain of spool references.
472pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
473    if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
474        return false;
475    }
476
477    if !is_readonly_command_session_command(args) {
478        return false;
479    }
480
481    shell_command_text_with_args(args)
482        .and_then(|command| static_shell_command_words(&command))
483        .is_some_and(|commands| {
484            commands
485                .iter()
486                .flatten()
487                .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
488        })
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use serde_json::json;
495
496    fn run_cmd(command: &str) -> Value {
497        json!({"action": "run", "command": command})
498    }
499
500    #[test]
501    fn command_availability_probes_do_not_admit_execution() {
502        for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
503            assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
504        }
505        for command in [
506            "command rm file",
507            "command -p rm file",
508            "command -v",
509            "command -v -- rm",
510            "command -v $(touch file)",
511        ] {
512            assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
513        }
514    }
515
516    #[test]
517    fn and_chain_allows_readonly_segments() {
518        assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
519        assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
520        assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
521    }
522
523    #[test]
524    fn compound_inspection_commands_share_the_readonly_policy() {
525        for command in [
526            "cat README.md; rg -n '^#' README.md",
527            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
528            "cat README.md\nrg -n '^version' Cargo.toml",
529        ] {
530            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
531        }
532    }
533
534    #[test]
535    fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
536        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"#;
537
538        assert!(is_readonly_command_session_command(&run_cmd(command)));
539    }
540
541    #[test]
542    fn and_chain_rejects_destructive_segments() {
543        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
544        assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
545        assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
546    }
547
548    #[test]
549    fn and_chain_rejects_non_allowlisted_segments() {
550        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
551        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
552        assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
553        assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
554    }
555
556    #[test]
557    fn and_chain_allows_pipeline_within_segment() {
558        assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
559    }
560
561    #[test]
562    fn and_chain_single_command_passes() {
563        assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
564        assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
565    }
566
567    #[test]
568    fn readonly_command_session_allows_and_chain() {
569        // The exact pattern from checkpoint turn_726 that was blocked.
570        assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
571    }
572
573    #[test]
574    fn readonly_command_session_rejects_destructive_and_chain() {
575        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
576    }
577
578    #[test]
579    fn awk_range_print_is_readonly() {
580        // Regression shapes from the blocked README session: `awk` paging
581        // must not count as blind-editing mutations.
582        for command in [
583            "awk 'NR>=40 && NR<=140' README.md",
584            "awk 'NR>=297 && NR<=312' README.md",
585            "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
586            "awk -F: '{print $1}' README.md",
587            "awk -F, '{print $2}' data.csv",
588            "awk -v limit=10 'NR<=limit' README.md",
589            "awk -- '{print $1}' README.md",
590            "awk -- '{print $1}' -- -weird",
591            // `systime()` only reads the clock — must not be confused with
592            // `system()`. Absolute paths resolve via file_name().
593            "awk 'BEGIN{print systime()}' README.md",
594            "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
595            "awk 'NR>=1 && NR<=5' README.md | sort",
596            // Blocked-session regression: numeric `>` comparisons without
597            // `print` redirection are reads, not writes.
598            "awk '$3>100' README.md",
599            "awk '{if(p2==0 && i>1) print}' README.md",
600            "awk '{if(NR>1 && $2>10) print $1}' README.md",
601            "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
602            "awk '{print (a>b)}' README.md",
603        ] {
604            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
605        }
606    }
607
608    #[test]
609    fn awk_write_primitives_stay_mutating() {
610        for command in [
611            "awk '{print > \"out.txt\"}' README.md",
612            "awk '{print >> \"out.txt\"}' README.md",
613            "awk '{print x > \"out.txt\"}' README.md",
614            "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
615            "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
616            "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
617            "awk '{print | \"sort\"}' README.md",
618            "awk '\"sort\" | getline line' README.md",
619            "awk 'BEGIN{system(\"touch out\")}' README.md",
620            "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
621            "awk 'BEGIN{System(\"id\")}' README.md",
622            "awk 'BEGIN{system\t(\"id\")}' README.md",
623            // gawk indirect calls and directives can execute or load code,
624            // including a `system` name smuggled via `-v`. Fail closed.
625            "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
626            "awk 'BEGIN{@s(\"id\")}' README.md",
627            "awk '@include \"x.awk\"' README.md",
628            "awk '@load \"ext\"' README.md",
629            "awk '{print \"a@b\"}' README.md",
630            // `|` alternations inside regex stay conservative without a full
631            // parser; single `>` without `print` is now a comparison above.
632            "awk '/error|warning/' README.md",
633            "awk -i inplace '{print}' README.md",
634            "awk -f program.awk README.md",
635            "awk --source '{print}' README.md",
636            "awk -l injail '{print}' README.md",
637            "awk --profile '{print}' README.md",
638            "awk -W dump-variables '{print}' README.md",
639            "awk --posix '{print}' README.md",
640            "awk -F:",
641            "awk",
642            "awk -v",
643            // Shell-level redirection stays mutating even with a safe program.
644            "awk 'NR>=1' README.md > out.txt",
645        ] {
646            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
647        }
648    }
649
650    #[test]
651    fn awk_string_literal_operators_are_readonly() {
652        // The reported false positive: a quoted `"|"` passed to `index()` is a
653        // string literal, not a command pipe, so the lexer must not reject it.
654        for command in [
655            r#"awk '{n=index(rest,"|"); print n}' README.md"#,
656            r#"awk 'index($0,">")' README.md"#,
657            // `>=` comparison and `||` boolean operators are not redirection
658            // or a pipe.
659            r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
660            // Division must not be mistaken for a regex literal.
661            r#"awk '{print $1 / $2}' README.md"#,
662            "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
663            // Exact reported shape: a multi-line program whose `index(rest,"|")`
664            // carries a quoted pipe and a `# ...` comment line.
665            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
666rest=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"#,
667        ] {
668            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
669        }
670    }
671
672    #[test]
673    fn awk_regex_with_quote_cannot_hide_pipe() {
674        // Adversarial shape for a naive quote-toggling scanner: `/a"b/` opens
675        // a "string" that stays open, hiding the real `|`; the later `/c"d/`
676        // rebalances the quote count, so an unbalanced-quote guard never
677        // fires. The lexer treats quotes as regex content and still catches
678        // the pipe.
679        for command in [
680            r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
681            // Regex alternation stays mutating (pinned conservative policy).
682            r#"awk '/a|b/ {print}' README.md"#,
683        ] {
684            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
685        }
686    }
687
688    #[test]
689    fn cd_prefixed_exploration_is_readonly() {
690        // Exact patterns from checkpoint turn_810 that plan mode rejected:
691        // the model habitually prefixes exploration with `cd <workspace> &&`.
692        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
693        assert!(is_readonly_command_session_command(&run_cmd(
694            "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
695        )));
696        // `cd` must not become a bypass for mutating chains.
697        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
698        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
699    }
700
701    #[test]
702    fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
703        assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
704        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
705        assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
706        assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
707        // Mutating git subcommands stay rejected.
708        assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
709        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
710        assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
711    }
712
713    #[test]
714    fn env_value_injection_is_not_readonly() {
715        // A4: `command_words_after_environment_prefix` strips assignments, so
716        // values must still be inspected. `GIT_CONFIG_*` reproduces the blocked
717        // `git -c core.fsmonitor` injection without any `-c` token.
718        for command in [
719            "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
720            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
721            "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
722            "env GIT_EXTERNAL_DIFF=evil git diff",
723            "env GIT_TEXTCONV=evil git show HEAD",
724            "env LD_PRELOAD=./evil.so ls",
725            "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
726            "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
727            "env NODE_OPTIONS='--require ./evil.js' node --version",
728            "env EDITOR=evil sed -n '1,10p' README.md",
729        ] {
730            assert!(
731                !is_readonly_command_session_command(&run_cmd(command)),
732                "expected mutating env-injection command: {command}"
733            );
734        }
735
736        // Ordinary env prefixes stay read-only.
737        for command in [
738            "env LANG=C.UTF-8 rg 'TODO' src",
739            "FOO=bar git status",
740            "env FOO=bar git log --oneline",
741            "env -u GIT_CONFIG_COUNT git status",
742            "env -C /repo git status",
743        ] {
744            assert!(
745                is_readonly_command_session_command(&run_cmd(command)),
746                "expected readonly env-prefixed command: {command}"
747            );
748        }
749    }
750
751    #[test]
752    fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
753        // turn-1030-class exploration: read-only plumbing subcommands the
754        // plan gate used to deny outright.
755        for command in [
756            "git rev-list --count HEAD",
757            "git ls-tree HEAD crates/",
758            "git cat-file -p HEAD:Cargo.toml",
759            "git diff-tree -r HEAD",
760            "git merge-base main feat",
761            "git range-diff main~2..main feat~2..feat",
762            "git whatchanged -5",
763            "git count-objects -v",
764            "git reflog | head",
765            "git worktree list",
766            "git stash list",
767            "git stash show",
768            "git branch --show-current",
769            "git --no-pager log --oneline | head -5",
770            "git remote -v",
771            "cd /repo && git -C . branch -a && git tag",
772        ] {
773            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
774        }
775    }
776
777    #[test]
778    fn version_and_help_probes_are_readonly_for_any_program() {
779        for command in [
780            "cargo --version",
781            "node --version",
782            "python3 -V",
783            "npm --version",
784            "git --version",
785            "bash --help",
786        ] {
787            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
788        }
789        // Only an all-flags probe is a version/help query; real operands keep
790        // the command outside the allow-list. Lowercase `-v` is excluded: it
791        // means verbose for most tools and reads stdin for shells.
792        assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
793        assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
794        assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
795    }
796
797    #[test]
798    fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
799        // `-C <dir>` only redirects which repository is read; plan mode must
800        // not reject worktree-style exploration.
801        assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
802        assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
803        // Config injection (`-c`) stays denied — the `-C`/`-c` pair must not
804        // be relaxed together.
805        assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
806        // A read-only subcommand with a write option stays denied.
807        assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
808    }
809
810    #[test]
811    fn readonly_git_and_inspection_options_stay_fail_closed() {
812        for command in [
813            "git diff -o output.txt",
814            "git diff '--output=output.txt'",
815            "git diff -ooutput.txt",
816            "git log --output=output.txt",
817            "git show --textconv",
818            // Config injection is a command-execution vector and stays denied.
819            "git -c core.fsmonitor=touch status",
820            "git --exec-path=/tmp/sh status",
821            // Bare `git stash` is `git stash push` — it stashes uncommitted
822            // work — and flag-only push forms carry no operand to betray
823            // them. Only `list`/`show` are reads.
824            "git stash",
825            "git stash -u",
826            "git stash -k",
827            // Inline-value and glued short mutators are invisible to the
828            // operand finder and must stay denied.
829            "git branch --unset-upstream",
830            "git branch --set-upstream-to=origin/main",
831            "git branch --edit-description",
832            "git branch -mnew-name",
833            "git branch -u=origin/main",
834            // An unknown value-taking global must not smuggle a mutating
835            // subcommand past the allow-list.
836            "git --shallow-file log commit -m x",
837            // Operand forms of flag-shaped subcommands mutate and stay denied.
838            "git tag v1.0.0",
839            "git branch feature-x",
840            "git branch -D stale",
841            "git reflog expire --expire=now --all",
842            "git stash pop",
843            "git remote add origin https://example.com/r.git",
844            "git worktree add ../side feat",
845            "find . -fprint output.txt",
846            "find . -fprintf output.txt '%p'",
847            "rg --hostname-bin sh pattern",
848            "rg --search-zip pattern",
849            "rg -z pattern",
850            "sort -o output.txt README.md",
851            "sort --compress-program=sh README.md",
852            "date -s now",
853            "awk -i inplace '{print}' README.md",
854            "sed -n 's/a/b/e' README.md",
855            "fd --exec sh -c 'touch out'",
856            "tree -o out.txt",
857            "ast-grep -r 'README.md'",
858            "sed -n -fmalicious.sed -e '1p' src/main.rs",
859            "sed -I '' 's/a/b/' src/main.rs",
860            "sed -n '1p\nw leaked.txt' src/main.rs",
861            "sed -n 'woutput.txt' src/main.rs",
862            "cargo check & rm output",
863        ] {
864            assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
865        }
866    }
867
868    #[test]
869    fn cargo_readonly_subcommands_allowed() {
870        assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
871        assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
872        assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
873        assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
874        assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
875    }
876
877    #[test]
878    fn parallel_commands_are_limited_to_side_effect_free_inspection() {
879        for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
880            assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
881        }
882        for command in [
883            "cargo check",
884            "cargo test",
885            "cargo nextest run",
886            "cargo clippy",
887            "npm test",
888            "pnpm run test",
889            "git diff | head -20",
890        ] {
891            assert!(
892                !is_parallel_safe_command_session_command(&run_cmd(command)),
893                "command can touch shared state or crosses a sequential boundary: {command}"
894            );
895        }
896    }
897
898    #[test]
899    fn stderr_merge_is_not_a_write_redirection() {
900        assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
901        // A real file redirection is still rejected.
902        assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
903    }
904
905    #[test]
906    fn extra_inspection_base_commands_are_readonly() {
907        assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
908        assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
909        assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
910        assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
911    }
912
913    #[test]
914    fn spool_file_reads_are_detected_only_for_readonly_commands() {
915        for command in [
916            "cat .vtcode/context/tool_outputs/run-1.txt",
917            "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
918            "rg error .vtcode/context/tool_outputs",
919            "rg -n error .vtcode/context/tool_outputs | head -20",
920            "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
921        ] {
922            assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
923        }
924
925        for command in [
926            "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
927            "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
928            "rm .vtcode/context/tool_outputs/run-1.txt",
929            "cat \"$VTCODE_SPOOL\"",
930            "cat .vtcode/context/tool_outputs/run-1.txt |",
931            "cat notes-.vtcode/context/tool_outputs/run-1.txt",
932            "cat .vtcode/context/tool_outputs-extra/run-1.txt",
933        ] {
934            assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
935        }
936
937        assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
938        assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
939    }
940}