Skip to main content

vtcode_core/tools/tool_intent/
mutation.rs

1//! Shared prose and planning exemptions for mutation accounting.
2
3use std::path::{Component, Path, PathBuf};
4
5/// Staging root for workspace-less plan artifacts (`$TMPDIR/vtcode-plans`).
6/// Single source of truth shared by mutation accounting, planning admission,
7/// and plan-state staging, so the exemption always matches the real temp dir
8/// (e.g. `/var/folders/...` on macOS) instead of a `/tmp` literal.
9pub fn tmp_plans_dir() -> PathBuf {
10    std::env::temp_dir().join("vtcode-plans")
11}
12
13fn tmp_plans_root_normalized() -> String {
14    tmp_plans_dir().to_string_lossy().replace('\\', "/")
15}
16
17fn path_targets_plan_artifact(path: &str) -> bool {
18    let normalized = path.trim().replace('\\', "/");
19    // This helper has no workspace to resolve traversal or symlinks against.
20    // Parent traversal cannot establish that the target is a planning artifact.
21    if Path::new(&normalized).components().any(|part| part == Component::ParentDir) {
22        return false;
23    }
24    if normalized == ".vtcode/plans"
25        || normalized.starts_with(".vtcode/plans/")
26        || normalized.contains("/.vtcode/plans/")
27    {
28        return true;
29    }
30    let tmp_root = tmp_plans_root_normalized();
31    normalized == tmp_root
32        || normalized.starts_with(&format!("{tmp_root}/"))
33        || normalized.contains(&format!("{tmp_root}/"))
34}
35
36fn path_is_docs_only(path: &str) -> bool {
37    let normalized = path.trim().replace('\\', "/");
38    let lower = normalized.to_ascii_lowercase();
39    let file_name = lower.rsplit('/').next().unwrap_or_default();
40    if matches!(file_name, "cmakelists.txt" | "requirements.txt") {
41        return false;
42    }
43    // Well-known prose filenames count only with a docs extension or no
44    // extension at all, so `README.py` and `docs/script.py` stay code edits.
45    let stem = file_name.split('.').next().unwrap_or_default();
46    if matches!(stem, "readme" | "changelog" | "license") {
47        let ext = file_name.rsplit('.').next().unwrap_or_default();
48        if ext == stem || matches!(ext, "md" | "mdx" | "txt" | "rst") {
49            return true;
50        }
51    }
52    matches!(file_name.rsplit('.').next(), Some("md" | "mdx" | "txt" | "rst"))
53}
54
55fn file_mutation_paths(name: &str, args: &serde_json::Value) -> Vec<PathBuf> {
56    let mut paths = crate::tools::apply_patch::mutation_target_paths(name, args);
57    // Include the camelCase alias used by file adapters as well as canonical keys.
58    if let Some(extra) = args.get("filePath").and_then(|value| value.as_str()) {
59        let trimmed = extra.trim();
60        if !trimmed.is_empty() {
61            paths.push(PathBuf::from(trimmed));
62        }
63    }
64    paths
65}
66
67/// Docs-only writes are low-risk prose edits. They neither trip nor clear the
68/// anti-blind gate: while pending they stay allowed (warn-only), and they never
69/// increment `consecutive_mutations`. Mixed docs+code patches, empty path sets,
70/// and exec-tool mutations fail closed as code edits.
71pub fn is_docs_only_write(name: &str, args: &serde_json::Value) -> bool {
72    use crate::config::constants::tools as tool_names;
73    use crate::tools::names::canonical_tool_name;
74    use crate::tools::tool_intent::file_operation_action;
75
76    let canonical = canonical_tool_name(name);
77    let is_file_write = matches!(
78        canonical,
79        tool_names::APPLY_PATCH
80            | tool_names::WRITE_FILE
81            | tool_names::EDIT_FILE
82            | tool_names::CREATE_FILE
83            | tool_names::SEARCH_REPLACE
84            | tool_names::DELETE_FILE
85            | tool_names::MOVE_FILE
86            | tool_names::COPY_FILE
87            | tool_names::UNIFIED_FILE
88    );
89    if !is_file_write {
90        return false;
91    }
92    if canonical == tool_names::UNIFIED_FILE
93        && file_operation_action(args)
94            .map(|action| action.eq_ignore_ascii_case("read"))
95            .unwrap_or(false)
96    {
97        return false;
98    }
99    let paths = file_mutation_paths(canonical, args);
100    if paths.is_empty() {
101        return false;
102    }
103    paths.iter().all(|path| path.to_str().is_some_and(path_is_docs_only))
104}
105
106pub fn is_plan_artifact_write(name: &str, args: &serde_json::Value) -> bool {
107    use crate::config::constants::tools as tool_names;
108    use crate::tools::names::canonical_tool_name;
109    use crate::tools::tool_intent::file_operation_action;
110
111    let canonical = canonical_tool_name(name);
112    if canonical == tool_names::TASK_TRACKER {
113        return true;
114    }
115    if !matches!(
116        canonical,
117        tool_names::UNIFIED_FILE
118            | tool_names::WRITE_FILE
119            | tool_names::EDIT_FILE
120            | tool_names::CREATE_FILE
121            | tool_names::SEARCH_REPLACE
122    ) || (canonical == tool_names::UNIFIED_FILE
123        && file_operation_action(args).is_some_and(|action| action.eq_ignore_ascii_case("read")))
124    {
125        return false;
126    }
127    let paths = file_mutation_paths(canonical, args);
128    !paths.is_empty() && paths.iter().all(|path| path.to_str().is_some_and(path_targets_plan_artifact))
129}