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
438pub fn is_readonly_command_session_command(args: &Value) -> bool {
439    let Some(raw) = shell_command_text_with_args(args) else {
440        return false;
441    };
442
443    // `sanitize_static_shell_command` rejects `;`, `||`, redirections, and
444    // background `&` up front; a `&&` chain is accepted only when every
445    // parsed segment independently passes the allow-list below.
446    static_shell_command_words(&raw)
447        .is_some_and(|commands| commands.iter().all(|words| command_words_are_readonly(words)))
448}
449
450/// A stricter read-only command shape that may run concurrently after hooks,
451/// command admission, and preflight have all succeeded.
452pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
453    if args.get("session_id").is_some()
454        || args.get("chars").is_some()
455        || args.get("input").is_some()
456        || args.get("tty").and_then(Value::as_bool) == Some(true)
457        || args
458            .get("sandbox_permissions")
459            .and_then(Value::as_str)
460            .is_some_and(|value| value != "use_default")
461    {
462        return false;
463    }
464    let Some(raw) = shell_command_text_with_args(args) else {
465        return false;
466    };
467    static_shell_command_words(&raw)
468        .is_some_and(|commands| commands.len() == 1 && command_words_are_parallel_safe(&commands[0]))
469}
470
471/// Returns `true` when a safe shell inspection command reads the internal tool
472/// output spool directory. Such reads must stay inline: spooling their output
473/// again would create a recursive chain of spool references.
474pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
475    if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
476        return false;
477    }
478
479    // Parse once: the same segments feed both the read-only gate and the
480    // spool-path scan. Delegating the gate to
481    // `is_readonly_command_session_command` and then parsing again repeats
482    // the extract + sanitize + tree-sitter pipeline on every completed
483    // command output (see `prepare_execution_output`).
484    let Some(raw) = shell_command_text_with_args(args) else {
485        return false;
486    };
487    let Some(commands) = static_shell_command_words(&raw) else {
488        return false;
489    };
490    if !commands.iter().all(|words| command_words_are_readonly(words)) {
491        return false;
492    }
493    commands
494        .iter()
495        .flatten()
496        .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use serde_json::json;
503
504    fn run_cmd(command: &str) -> Value {
505        json!({"action": "run", "command": command})
506    }
507
508    #[test]
509    fn command_availability_probes_do_not_admit_execution() {
510        for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
511            assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
512        }
513        for command in [
514            "command rm file",
515            "command -p rm file",
516            "command -v",
517            "command -v -- rm",
518            "command -v $(touch file)",
519        ] {
520            assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
521        }
522    }
523
524    #[test]
525    fn and_chain_allows_readonly_segments() {
526        assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
527        assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
528        assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
529    }
530
531    #[test]
532    fn compound_inspection_commands_share_the_readonly_policy() {
533        for command in [
534            "cat README.md; rg -n '^#' README.md",
535            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
536            "cat README.md\nrg -n '^version' Cargo.toml",
537        ] {
538            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
539        }
540    }
541
542    #[test]
543    fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
544        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"#;
545
546        assert!(is_readonly_command_session_command(&run_cmd(command)));
547    }
548
549    #[test]
550    fn and_chain_rejects_destructive_segments() {
551        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
552        assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
553        assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
554    }
555
556    #[test]
557    fn and_chain_rejects_non_allowlisted_segments() {
558        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
559        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
560        assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
561        assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
562    }
563
564    #[test]
565    fn and_chain_allows_pipeline_within_segment() {
566        assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
567    }
568
569    #[test]
570    fn and_chain_single_command_passes() {
571        assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
572        assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
573    }
574
575    #[test]
576    fn readonly_command_session_allows_and_chain() {
577        // The exact pattern from checkpoint turn_726 that was blocked.
578        assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
579    }
580
581    #[test]
582    fn readonly_command_session_rejects_destructive_and_chain() {
583        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
584    }
585
586    #[test]
587    fn awk_range_print_is_readonly() {
588        // Regression shapes from the blocked README session: `awk` paging
589        // must not count as blind-editing mutations.
590        for command in [
591            "awk 'NR>=40 && NR<=140' README.md",
592            "awk 'NR>=297 && NR<=312' README.md",
593            "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
594            "awk -F: '{print $1}' README.md",
595            "awk -F, '{print $2}' data.csv",
596            "awk -v limit=10 'NR<=limit' README.md",
597            "awk -- '{print $1}' README.md",
598            "awk -- '{print $1}' -- -weird",
599            // `systime()` only reads the clock — must not be confused with
600            // `system()`. Absolute paths resolve via file_name().
601            "awk 'BEGIN{print systime()}' README.md",
602            "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
603            "awk 'NR>=1 && NR<=5' README.md | sort",
604            // Blocked-session regression: numeric `>` comparisons without
605            // `print` redirection are reads, not writes.
606            "awk '$3>100' README.md",
607            "awk '{if(p2==0 && i>1) print}' README.md",
608            "awk '{if(NR>1 && $2>10) print $1}' README.md",
609            "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
610            "awk '{print (a>b)}' README.md",
611        ] {
612            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
613        }
614    }
615
616    #[test]
617    fn awk_write_primitives_stay_mutating() {
618        for command in [
619            "awk '{print > \"out.txt\"}' README.md",
620            "awk '{print >> \"out.txt\"}' README.md",
621            "awk '{print x > \"out.txt\"}' README.md",
622            "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
623            "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
624            "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
625            "awk '{print | \"sort\"}' README.md",
626            "awk '\"sort\" | getline line' README.md",
627            "awk 'BEGIN{system(\"touch out\")}' README.md",
628            "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
629            "awk 'BEGIN{System(\"id\")}' README.md",
630            "awk 'BEGIN{system\t(\"id\")}' README.md",
631            // gawk indirect calls and directives can execute or load code,
632            // including a `system` name smuggled via `-v`. Fail closed.
633            "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
634            "awk 'BEGIN{@s(\"id\")}' README.md",
635            "awk '@include \"x.awk\"' README.md",
636            "awk '@load \"ext\"' README.md",
637            "awk '{print \"a@b\"}' README.md",
638            // `|` alternations inside regex stay conservative without a full
639            // parser; single `>` without `print` is now a comparison above.
640            "awk '/error|warning/' README.md",
641            "awk -i inplace '{print}' README.md",
642            "awk -f program.awk README.md",
643            "awk --source '{print}' README.md",
644            "awk -l injail '{print}' README.md",
645            "awk --profile '{print}' README.md",
646            "awk -W dump-variables '{print}' README.md",
647            "awk --posix '{print}' README.md",
648            "awk -F:",
649            "awk",
650            "awk -v",
651            // Shell-level redirection stays mutating even with a safe program.
652            "awk 'NR>=1' README.md > out.txt",
653        ] {
654            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
655        }
656    }
657
658    #[test]
659    fn awk_string_literal_operators_are_readonly() {
660        // The reported false positive: a quoted `"|"` passed to `index()` is a
661        // string literal, not a command pipe, so the lexer must not reject it.
662        for command in [
663            r#"awk '{n=index(rest,"|"); print n}' README.md"#,
664            r#"awk 'index($0,">")' README.md"#,
665            // `>=` comparison and `||` boolean operators are not redirection
666            // or a pipe.
667            r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
668            // Division must not be mistaken for a regex literal.
669            r#"awk '{print $1 / $2}' README.md"#,
670            "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
671            // Exact reported shape: a multi-line program whose `index(rest,"|")`
672            // carries a quoted pipe and a `# ...` comment line.
673            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
674rest=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"#,
675        ] {
676            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
677        }
678    }
679
680    #[test]
681    fn awk_regex_with_quote_cannot_hide_pipe() {
682        // Adversarial shape for a naive quote-toggling scanner: `/a"b/` opens
683        // a "string" that stays open, hiding the real `|`; the later `/c"d/`
684        // rebalances the quote count, so an unbalanced-quote guard never
685        // fires. The lexer treats quotes as regex content and still catches
686        // the pipe.
687        for command in [
688            r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
689            // Regex alternation stays mutating (pinned conservative policy).
690            r#"awk '/a|b/ {print}' README.md"#,
691        ] {
692            assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
693        }
694    }
695
696    #[test]
697    fn cd_prefixed_exploration_is_readonly() {
698        // Exact patterns from checkpoint turn_810 that plan mode rejected:
699        // the model habitually prefixes exploration with `cd <workspace> &&`.
700        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
701        assert!(is_readonly_command_session_command(&run_cmd(
702            "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
703        )));
704        // `cd` must not become a bypass for mutating chains.
705        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
706        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
707    }
708
709    #[test]
710    fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
711        assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
712        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
713        assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
714        assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
715        // Mutating git subcommands stay rejected.
716        assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
717        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
718        assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
719    }
720
721    #[test]
722    fn env_value_injection_is_not_readonly() {
723        // A4: `command_words_after_environment_prefix` strips assignments, so
724        // values must still be inspected. `GIT_CONFIG_*` reproduces the blocked
725        // `git -c core.fsmonitor` injection without any `-c` token.
726        for command in [
727            "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
728            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
729            "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
730            "env GIT_EXTERNAL_DIFF=evil git diff",
731            "env GIT_TEXTCONV=evil git show HEAD",
732            "env LD_PRELOAD=./evil.so ls",
733            "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
734            "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
735            "env NODE_OPTIONS='--require ./evil.js' node --version",
736            "env EDITOR=evil sed -n '1,10p' README.md",
737        ] {
738            assert!(
739                !is_readonly_command_session_command(&run_cmd(command)),
740                "expected mutating env-injection command: {command}"
741            );
742        }
743
744        // Ordinary env prefixes stay read-only.
745        for command in [
746            "env LANG=C.UTF-8 rg 'TODO' src",
747            "FOO=bar git status",
748            "env FOO=bar git log --oneline",
749            "env -u GIT_CONFIG_COUNT git status",
750            "env -C /repo git status",
751        ] {
752            assert!(
753                is_readonly_command_session_command(&run_cmd(command)),
754                "expected readonly env-prefixed command: {command}"
755            );
756        }
757    }
758
759    #[test]
760    fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
761        // turn-1030-class exploration: read-only plumbing subcommands the
762        // plan gate used to deny outright.
763        for command in [
764            "git rev-list --count HEAD",
765            "git ls-tree HEAD crates/",
766            "git cat-file -p HEAD:Cargo.toml",
767            "git diff-tree -r HEAD",
768            "git merge-base main feat",
769            "git range-diff main~2..main feat~2..feat",
770            "git whatchanged -5",
771            "git count-objects -v",
772            "git reflog | head",
773            "git worktree list",
774            "git stash list",
775            "git stash show",
776            "git branch --show-current",
777            "git --no-pager log --oneline | head -5",
778            "git remote -v",
779            "cd /repo && git -C . branch -a && git tag",
780        ] {
781            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
782        }
783    }
784
785    #[test]
786    fn version_and_help_probes_are_readonly_for_any_program() {
787        for command in [
788            "cargo --version",
789            "node --version",
790            "python3 -V",
791            "npm --version",
792            "git --version",
793            "bash --help",
794        ] {
795            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
796        }
797        // Only an all-flags probe is a version/help query; real operands keep
798        // the command outside the allow-list. Lowercase `-v` is excluded: it
799        // means verbose for most tools and reads stdin for shells.
800        assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
801        assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
802        assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
803    }
804
805    #[test]
806    fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
807        // `-C <dir>` only redirects which repository is read; plan mode must
808        // not reject worktree-style exploration.
809        assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
810        assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
811        // Config injection (`-c`) stays denied — the `-C`/`-c` pair must not
812        // be relaxed together.
813        assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
814        // A read-only subcommand with a write option stays denied.
815        assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
816    }
817
818    #[test]
819    fn readonly_git_and_inspection_options_stay_fail_closed() {
820        for command in [
821            "git diff -o output.txt",
822            "git diff '--output=output.txt'",
823            "git diff -ooutput.txt",
824            "git log --output=output.txt",
825            "git show --textconv",
826            // Config injection is a command-execution vector and stays denied.
827            "git -c core.fsmonitor=touch status",
828            "git --exec-path=/tmp/sh status",
829            // Bare `git stash` is `git stash push` — it stashes uncommitted
830            // work — and flag-only push forms carry no operand to betray
831            // them. Only `list`/`show` are reads.
832            "git stash",
833            "git stash -u",
834            "git stash -k",
835            // Inline-value and glued short mutators are invisible to the
836            // operand finder and must stay denied.
837            "git branch --unset-upstream",
838            "git branch --set-upstream-to=origin/main",
839            "git branch --edit-description",
840            "git branch -mnew-name",
841            "git branch -u=origin/main",
842            // An unknown value-taking global must not smuggle a mutating
843            // subcommand past the allow-list.
844            "git --shallow-file log commit -m x",
845            // Operand forms of flag-shaped subcommands mutate and stay denied.
846            "git tag v1.0.0",
847            "git branch feature-x",
848            "git branch -D stale",
849            "git reflog expire --expire=now --all",
850            "git stash pop",
851            "git remote add origin https://example.com/r.git",
852            "git worktree add ../side feat",
853            "find . -fprint output.txt",
854            "find . -fprintf output.txt '%p'",
855            "rg --hostname-bin sh pattern",
856            "rg --search-zip pattern",
857            "rg -z pattern",
858            "sort -o output.txt README.md",
859            "sort --compress-program=sh README.md",
860            "date -s now",
861            "awk -i inplace '{print}' README.md",
862            "sed -n 's/a/b/e' README.md",
863            "fd --exec sh -c 'touch out'",
864            "tree -o out.txt",
865            "ast-grep -r 'README.md'",
866            "sed -n -fmalicious.sed -e '1p' src/main.rs",
867            "sed -I '' 's/a/b/' src/main.rs",
868            "sed -n '1p\nw leaked.txt' src/main.rs",
869            "sed -n 'woutput.txt' src/main.rs",
870            "cargo check & rm output",
871        ] {
872            assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
873        }
874    }
875
876    #[test]
877    fn cargo_readonly_subcommands_allowed() {
878        assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
879        assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
880        assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
881        assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
882        assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
883    }
884
885    #[test]
886    fn parallel_commands_are_limited_to_side_effect_free_inspection() {
887        for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
888            assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
889        }
890        for command in [
891            "cargo check",
892            "cargo test",
893            "cargo nextest run",
894            "cargo clippy",
895            "npm test",
896            "pnpm run test",
897            "git diff | head -20",
898        ] {
899            assert!(
900                !is_parallel_safe_command_session_command(&run_cmd(command)),
901                "command can touch shared state or crosses a sequential boundary: {command}"
902            );
903        }
904    }
905
906    #[test]
907    fn shared_command_head_fails_closed_on_env_only_words() {
908        // Asymmetric pair: env assignments alone leave no program to
909        // classify (fail closed), while the same prefix with a program
910        // classifies on both paths. Guards the shared helper's strip
911        // order: prefix strip first, program resolution second.
912        let env_only: Vec<String> = vec!["FOO=bar".to_string()];
913        assert!(!command_words_are_readonly(&env_only));
914        assert!(!command_words_are_parallel_safe(&env_only));
915        assert!(readonly_command_head(&env_only).is_none());
916
917        let env_ls: Vec<String> = vec!["FOO=bar".to_string(), "ls".to_string()];
918        assert!(command_words_are_readonly(&env_ls));
919        assert!(command_words_are_parallel_safe(&env_ls));
920        let (stripped, first) = readonly_command_head(&env_ls).expect("env-prefixed ls classifies");
921        assert_eq!(first, "ls");
922        assert_eq!(stripped, ["ls".to_string()].as_slice());
923    }
924
925    #[test]
926    fn parallel_safe_path_rejects_env_injection_like_readonly() {
927        // The shared head must pass the ORIGINAL words (env prefix
928        // included) to the option guard. Stripped `["ls"]` looks clean;
929        // only the unstripped view carries the injection.
930        let injected: Vec<String> = vec!["LD_PRELOAD=./evil.so".to_string(), "ls".to_string()];
931        assert!(!command_words_are_parallel_safe(&injected));
932        assert!(!command_words_are_readonly(&injected));
933        assert!(readonly_command_head(&injected).is_none());
934    }
935
936    #[test]
937    fn stderr_merge_is_not_a_write_redirection() {
938        assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
939        // A real file redirection is still rejected.
940        assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
941    }
942
943    #[test]
944    fn extra_inspection_base_commands_are_readonly() {
945        assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
946        assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
947        assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
948        assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
949    }
950
951    #[test]
952    fn spool_file_reads_are_detected_only_for_readonly_commands() {
953        for command in [
954            "cat .vtcode/context/tool_outputs/run-1.txt",
955            "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
956            "rg error .vtcode/context/tool_outputs",
957            "rg -n error .vtcode/context/tool_outputs | head -20",
958            "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
959        ] {
960            assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
961        }
962
963        for command in [
964            "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
965            "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
966            "rm .vtcode/context/tool_outputs/run-1.txt",
967            "cat \"$VTCODE_SPOOL\"",
968            "cat .vtcode/context/tool_outputs/run-1.txt |",
969            "cat notes-.vtcode/context/tool_outputs/run-1.txt",
970            "cat .vtcode/context/tool_outputs-extra/run-1.txt",
971        ] {
972            assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
973        }
974
975        assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
976        assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
977    }
978
979    #[test]
980    fn spool_file_read_uses_one_parse_for_gate_and_scan() {
981        // Equivalence with the old parse-twice form: env prefixes and
982        // `&&` chains must resolve identically in the gate and the scan.
983        // A dropped `all(readonly)` gate would flip the mutating row to
984        // true (spooling a mutation); a reordered extract would flip the
985        // env-prefixed row to false.
986        assert!(is_spool_file_read_command(
987            "exec_command",
988            &run_cmd("FOO=bar cat .vtcode/context/tool_outputs/run-1.txt")
989        ));
990        assert!(is_spool_file_read_command(
991            "exec_command",
992            &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && echo done")
993        ));
994        assert!(!is_spool_file_read_command(
995            "exec_command",
996            &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt")
997        ));
998    }
999}