vtcode-core 0.146.9

Core library for VT Code - a Rust-based terminal coding agent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
use std::path::Path;

use crate::tools::command_args::{
    command_words_after_environment_prefix, has_unsafe_readonly_options, raw_command_text,
};
use serde_json::Value;

const TOOL_OUTPUT_SPOOL_DIRECTORY: &str = ".vtcode/context/tool_outputs";

/// Conservative allow-list of read-only inspection commands used by
/// `command_session`. Any command that could write, move, or delete must be
/// rejected so it is not cached or parallelized as read-only.
///
/// `cd` is included because changing the working directory mutates nothing;
/// models habitually prefix exploration with `cd <workspace> && …` and plan
/// mode must not reject that pattern (checkpoint turn_810).
const READONLY_UNIFIED_EXEC_COMMANDS: &[&str] = &[
    "rg", "ls", "cat", "diff", "find", "wc", "grep", "egrep", "fgrep", "head", "tail", "sort", "uniq", "bat", "sed",
    "cut", "tr", "ast-grep", "sg", "echo", "pwd", "printf", "true", "false", "test", "cd", "fd", "tree", "which",
    "stat", "file", "du", "df", "realpath", "basename", "dirname", "nl", "column", "jq", "date", "whoami", "uname",
];

pub fn is_readonly_base_command(command: &str) -> bool {
    READONLY_UNIFIED_EXEC_COMMANDS.contains(&command)
}

/// Read-only subcommand allow-list for multi-word tools whose base command is
/// not inherently safe (`git`, `cargo`, package managers). Only inspection
/// subcommands are listed; anything that can mutate the worktree, index,
/// refs, or lockfiles must stay out.
fn is_readonly_subcommand(first: &str, second: Option<&str>, third: Option<&str>) -> bool {
    match first {
        "git" => matches!(
            second,
            Some(
                "status"
                    | "log"
                    | "diff"
                    | "show"
                    | "blame"
                    | "ls-files"
                    | "rev-parse"
                    | "describe"
                    | "shortlog"
                    | "grep"
            )
        ),
        "cargo" => match second {
            Some("check" | "test" | "metadata" | "tree" | "clippy") => true,
            Some("nextest") => matches!(third, Some("run" | "list")),
            _ => false,
        },
        "npm" | "pnpm" | "yarn" => match second {
            Some("test") => true,
            Some("run") => matches!(third, Some("test")),
            _ => false,
        },
        _ => false,
    }
}

/// Parse a shell script into simple command words after proving that it has no
/// dynamic expansion, file redirection, or unsupported background operator.
///
/// The tree-sitter parser is the single shell-grammar boundary used by the
/// safety subsystem. Callers receive already-tokenized command words and must
/// still apply their own command-policy predicate; an unknown command never
/// becomes read-only merely because parsing succeeded.
pub(crate) fn static_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
    let sanitized = sanitize_static_shell_command(command)?;
    parse_static_shell_command_words(&sanitized)
}

/// Parse a command whose only shell operators outside quotes are output
/// redirections. This is intentionally separate from the read-only parser:
/// writing a build log is still a mutating command for the authoritative tool
/// intent, but it should not hide the verification nature of `cargo check` in
/// progress accounting.
pub(crate) fn static_shell_command_words_with_output_plumbing(command: &str) -> Option<Vec<Vec<String>>> {
    if !crate::command_safety::shell_parser::has_only_output_redirections(command) {
        return None;
    }
    parse_static_shell_command_words(command)
}

fn parse_static_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
    let commands = crate::command_safety::shell_parser::parse_shell_commands_tree_sitter(command).ok()?;
    if commands.is_empty() {
        return None;
    }

    commands
        .into_iter()
        .map(|command| {
            let mut words = Vec::new();
            for word in command {
                let tokens = shell_words::split(&word).ok()?;
                words.extend(tokens);
            }
            (!words.is_empty()).then_some(words)
        })
        .collect()
}

/// Returns whether one parsed command is an allow-listed read-only command.
/// Option-level guards are shared with raw argument validation so activity
/// accounting cannot accidentally become less strict than tool intent.
pub(crate) fn command_words_are_readonly(words: &[String]) -> bool {
    let command_words = command_words_after_environment_prefix(words);
    let Some(first) = command_words
        .first()
        .and_then(|word| Path::new(word).file_name())
        .and_then(|name| name.to_str())
    else {
        return false;
    };
    let lowered_words = command_words
        .iter()
        .filter(|word| !word.starts_with('-') && !word.contains('='))
        .take(3)
        .map(|word| word.to_ascii_lowercase())
        .collect::<Vec<_>>();
    let first = first.to_ascii_lowercase();

    if has_unsafe_readonly_options(words) {
        return false;
    }

    is_readonly_base_command(&first)
        || is_known_readonly_dry_run(command_words, &first)
        || is_readonly_subcommand(
            &first,
            lowered_words.get(1).map(String::as_str),
            lowered_words.get(2).map(String::as_str),
        )
}

fn is_known_readonly_dry_run(words: &[String], program: &str) -> bool {
    if !matches!(program, "npm" | "pnpm" | "yarn") {
        return false;
    }

    let Some(subcommand) = words.iter().skip(1).find(|word| !word.starts_with('-') && !word.contains('=')) else {
        return false;
    };

    subcommand == "install" && words.iter().any(|word| word == "--dry-run")
}

fn matches_at(chars: &[char], index: usize, pattern: &[char]) -> bool {
    chars
        .get(index..index.saturating_add(pattern.len()))
        .is_some_and(|candidate| candidate == pattern)
}

/// Remove only stderr plumbing that cannot write workspace state and reject
/// all other redirection/background syntax. Operators inside quoted arguments
/// remain ordinary data and are never rewritten.
fn sanitize_static_shell_command(command: &str) -> Option<String> {
    if crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command) {
        return None;
    }

    let chars = command.chars().collect::<Vec<_>>();
    let mut sanitized = String::with_capacity(command.len());
    let mut index = 0;
    let mut in_single_quote = false;
    let mut in_double_quote = false;

    while index < chars.len() {
        let character = chars[index];
        if character == '\'' && !in_double_quote {
            in_single_quote = !in_single_quote;
            sanitized.push(character);
            index += 1;
            continue;
        }
        if character == '"' && !in_single_quote {
            in_double_quote = !in_double_quote;
            sanitized.push(character);
            index += 1;
            continue;
        }
        if in_single_quote || in_double_quote {
            sanitized.push(character);
            index += 1;
            continue;
        }

        let preceded_by_boundary = index == 0
            || chars
                .get(index.saturating_sub(1))
                .is_some_and(|previous| previous.is_whitespace() || matches!(previous, '|' | '&' | ';'));
        if preceded_by_boundary && matches_at(&chars, index, &['2', '>', '&', '1']) {
            index += 4;
            continue;
        }
        if cfg!(unix)
            && preceded_by_boundary
            && matches_at(&chars, index, &['2', '>', '/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'])
        {
            index += 11;
            continue;
        }

        match character {
            '<' | '>' => return None,
            '&' if matches_at(&chars, index, &['&', '&']) => {
                sanitized.push('&');
                sanitized.push('&');
                index += 2;
            }
            '&' => return None,
            _ => {
                sanitized.push(character);
                index += 1;
            }
        }
    }

    (!sanitized.trim().is_empty()).then_some(sanitized)
}

pub fn is_readonly_command_session_command(args: &Value) -> bool {
    let Some(raw) = raw_command_text(args) else {
        return false;
    };

    // `is_readonly_command_string` intentionally rejects compound separators
    // for its conservative raw-string API. The static parser above is the
    // stricter structured boundary for this allow-list and accepts a compound
    // command only when every parsed command is independently safe.
    static_shell_command_words(&raw)
        .is_some_and(|commands| commands.iter().all(|words| command_words_are_readonly(words)))
}

/// Returns `true` when a safe shell inspection command reads the internal tool
/// output spool directory. Such reads must stay inline: spooling their output
/// again would create a recursive chain of spool references.
pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
    if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
        return false;
    }

    if !is_readonly_command_session_command(args) {
        return false;
    }

    raw_command_text(args).is_some_and(|command| command.replace('\\', "/").contains(TOOL_OUTPUT_SPOOL_DIRECTORY))
}

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

    fn run_cmd(command: &str) -> Value {
        json!({"action": "run", "command": command})
    }

    #[test]
    fn and_chain_allows_readonly_segments() {
        assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
        assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
        assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
    }

    #[test]
    fn compound_inspection_commands_share_the_readonly_policy() {
        for command in [
            "cat README.md; rg -n '^#' README.md",
            "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
            "cat README.md\nrg -n '^version' Cargo.toml",
        ] {
            assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
        }
    }

    #[test]
    fn and_chain_rejects_destructive_segments() {
        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
        assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
        assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
    }

    #[test]
    fn and_chain_rejects_non_allowlisted_segments() {
        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
        assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
        assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
    }

    #[test]
    fn and_chain_allows_pipeline_within_segment() {
        assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
    }

    #[test]
    fn and_chain_single_command_passes() {
        assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
        assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
    }

    #[test]
    fn readonly_command_session_allows_and_chain() {
        // The exact pattern from checkpoint turn_726 that was blocked.
        assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
    }

    #[test]
    fn readonly_command_session_rejects_destructive_and_chain() {
        assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
    }

    #[test]
    fn cd_prefixed_exploration_is_readonly() {
        // Exact patterns from checkpoint turn_810 that plan mode rejected:
        // the model habitually prefixes exploration with `cd <workspace> &&`.
        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
        assert!(is_readonly_command_session_command(&run_cmd(
            "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
        )));
        // `cd` must not become a bypass for mutating chains.
        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
    }

    #[test]
    fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
        assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
        assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
        assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
        assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
        // Mutating git subcommands stay rejected.
        assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
        assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
        assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
    }

    #[test]
    fn readonly_git_and_inspection_options_stay_fail_closed() {
        for command in [
            "git diff -o output.txt",
            "git diff '--output=output.txt'",
            "git diff -ooutput.txt",
            "git log --output=output.txt",
            "git show --textconv",
            "git -C /external/repo=alt status",
            "find . -fprint output.txt",
            "find . -fprintf output.txt '%p'",
            "rg --hostname-bin sh pattern",
            "rg --search-zip pattern",
            "rg -z pattern",
            "sort -o output.txt README.md",
            "sort --compress-program=sh README.md",
            "date -s now",
            "awk -i inplace '{print}' README.md",
            "sed -n 's/a/b/e' README.md",
            "fd --exec sh -c 'touch out'",
            "tree -o out.txt",
            "ast-grep -r 'README.md'",
            "sed -n -fmalicious.sed -e '1p' src/main.rs",
            "sed -I '' 's/a/b/' src/main.rs",
            "sed -n '1p\nw leaked.txt' src/main.rs",
            "sed -n 'woutput.txt' src/main.rs",
            "cargo check & rm output",
        ] {
            assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
        }
    }

    #[test]
    fn cargo_readonly_subcommands_allowed() {
        assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
        assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
        assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
        assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
        assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
    }

    #[test]
    fn stderr_merge_is_not_a_write_redirection() {
        assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
        // A real file redirection is still rejected.
        assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
    }

    #[test]
    fn extra_inspection_base_commands_are_readonly() {
        assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
        assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
        assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
        assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
    }

    #[test]
    fn spool_file_reads_are_detected_only_for_readonly_commands() {
        for command in [
            "cat .vtcode/context/tool_outputs/run-1.txt",
            "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
            "rg error .vtcode/context/tool_outputs",
        ] {
            assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
        }

        for command in [
            "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
            "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
            "rm .vtcode/context/tool_outputs/run-1.txt",
        ] {
            assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
        }

        assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
        assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
    }
}