Skip to main content

vtcode_core/tools/
source_inspection.rs

1//! Session-local source exposure. Internal reads and proposed patches confer no authority.
2//!
3//! Ranges use zero-based, half-open line indices. Only retained tool-call/result
4//! pairs activate read evidence; summaries and replayed sessions cannot create it.
5mod exposure;
6pub(crate) mod mutations;
7mod shell;
8pub use shell::TrustedCommandExemption;
9
10use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
11use std::ops::Range;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16use parking_lot::Mutex;
17use serde_json::{Value, json};
18use vtcode_commons::utils::calculate_sha256;
19use vtcode_config::core::agent::InspectionMode;
20
21const MAX_FILES: usize = 128;
22const MAX_CREDIT_LINES: usize = 16_384;
23const MAX_SUPPORTS: usize = 128;
24type Credit = Arc<BTreeSet<String>>;
25
26#[derive(Default)]
27struct FileEvidence {
28    revision: String,
29    line_count: usize,
30    lines: HashMap<usize, Credit>,
31    empty: Option<Credit>,
32}
33
34struct Support {
35    signature: String,
36    call_id: Option<String>,
37}
38
39#[derive(Default)]
40struct Ledger {
41    mode: InspectionMode,
42    files: HashMap<PathBuf, FileEvidence>,
43    order: VecDeque<PathBuf>,
44    active: HashSet<String>,
45    exemptions: HashSet<String>,
46    supports: HashMap<String, Support>,
47    previous_calls: HashSet<String>,
48}
49
50/// Shared by registry and direct file handlers. Never persisted or restored.
51#[derive(Clone, Default)]
52pub struct SourceInspection(Arc<Mutex<Ledger>>);
53
54/// Existing tool error transport serializes this object without a new event type.
55#[derive(Debug, thiserror::Error)]
56#[error("source inspection required: {details}")]
57pub(crate) struct InspectionRequired {
58    pub(crate) details: Value,
59}
60
61fn revision(content: &str) -> String {
62    calculate_sha256(content.as_bytes())
63}
64
65pub(super) fn call_key(name: &str, args: &Value) -> String {
66    use crate::config::constants::tools;
67    let mut name = crate::tools::names::canonical_tool_name(name);
68    if name == tools::UNIFIED_FILE {
69        name = match crate::tools::tool_intent::file_operation_action(args) {
70            Some("read") => tools::READ_FILE,
71            Some("write") => tools::WRITE_FILE,
72            Some("edit") => tools::EDIT_FILE,
73            Some("patch") => tools::APPLY_PATCH,
74            Some("create") => tools::CREATE_FILE,
75            Some("move") => tools::MOVE_FILE,
76            Some("copy") => tools::COPY_FILE,
77            _ => name,
78        };
79    }
80    let mut args = if name == tools::APPLY_PATCH {
81        crate::tools::apply_patch::decode_apply_patch_input(args)
82            .ok()
83            .flatten()
84            .map(|input| json!({"input": input.text}))
85            .unwrap_or_else(|| args.clone())
86    } else if crate::tools::tool_intent::is_command_run_tool_call(name, args) {
87        name = tools::EXEC_COMMAND;
88        crate::tools::command_args::normalize_shell_args(args).unwrap_or_else(|_| args.clone())
89    } else {
90        args.clone()
91    };
92    if name == tools::CREATE_FILE {
93        name = tools::WRITE_FILE;
94        if let Some(object) = args.as_object_mut() {
95            object.insert("mode".into(), json!("fail_if_exists"));
96        }
97    }
98    if let Some(object) = args.as_object_mut() {
99        object.remove("action");
100        object.remove("max_output_tokens");
101        object.remove(crate::tools::file_ops::PATCH_READ_CACHE_NONCE);
102        object.remove(crate::tools::edited_file_monitor::FILE_CONFLICT_OVERRIDE_ARG);
103    }
104    revision(&format!("{}:{args}", crate::tools::names::canonical_tool_name(name)))
105}
106
107impl SourceInspection {
108    pub fn set_mode(&self, mode: InspectionMode) {
109        self.0.lock().mode = mode;
110    }
111
112    pub fn mode(&self) -> InspectionMode {
113        self.0.lock().mode
114    }
115
116    pub(crate) fn require(&self, path: &Path, content: &str, ranges: &[Range<usize>]) -> Result<()> {
117        if self.mode() == InspectionMode::Off {
118            return Ok(());
119        }
120        let digest = revision(content);
121        let mut ledger = self.0.lock();
122        let stale = ledger.files.get(path).is_some_and(|file| file.revision != digest);
123        if stale {
124            ledger.files.remove(path);
125            ledger.order.retain(|entry| entry != path);
126        }
127        let file = ledger.files.get(path);
128        let valid = |credit: &Credit| credit.iter().all(|support| ledger.active.contains(support));
129        let missing = ranges
130            .iter()
131            .find_map(|range| (range.clone()).find(|line| !file.and_then(|f| f.lines.get(line)).is_some_and(valid)));
132        let empty_missing = content.is_empty() && !file.and_then(|f| f.empty.as_ref()).is_some_and(valid);
133        if missing.is_none() && !empty_missing {
134            return Ok(());
135        }
136        let start = missing.unwrap_or(0) + 1;
137        let reason = if stale {
138            "stale_revision"
139        } else {
140            "source_not_retained_or_not_exposed"
141        };
142        let details = json!({
143            "failure_kind": "source_inspection_required", "path": path,
144            "reason": reason, "missing_ranges": [{"start_line": start, "end_line": start}],
145            "next_read_args": {"path": path, "offset": start, "limit": 200, "max_tokens": 6000, "condense": false},
146            "message": "Read the missing source, then retry a localized patch. Verification does not replace inspection."
147        });
148        if ledger.mode == InspectionMode::Warn {
149            tracing::warn!(%details, "Source inspection warning");
150            return Ok(());
151        }
152        Err(InspectionRequired { details }.into())
153    }
154
155    pub(crate) fn require_full(&self, path: &Path, content: &str) -> Result<()> {
156        self.require(path, content, std::slice::from_ref(&(0..content.lines().count())))
157    }
158
159    /// Inspect existing text for destructive operations. Missing files contain no
160    /// source to destroy; advisory inspection cannot impose text-only admission.
161    pub(crate) async fn require_existing_file(&self, path: &Path) -> Result<()> {
162        if self.mode() == InspectionMode::Off {
163            return Ok(());
164        }
165        match tokio::fs::read_to_string(path).await {
166            Ok(content) => self.require_full(path, &content),
167            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
168            Err(error) if self.mode() == InspectionMode::Warn => {
169                tracing::warn!(path = %path.display(), %error, "Source inspection unavailable");
170                Ok(())
171            }
172            Err(error) => Err(error).with_context(|| format!("Failed to inspect existing source {}", path.display())),
173        }
174    }
175
176    fn register_support(&self, signature: String) -> String {
177        let mut ledger = self.0.lock();
178        if ledger.supports.len() >= MAX_FILES * MAX_SUPPORTS {
179            ledger.supports.clear();
180            ledger.files.clear();
181            ledger.order.clear();
182            ledger.active.clear();
183        }
184        let key = uuid::Uuid::new_v4().to_string();
185        ledger.supports.insert(key.clone(), Support { signature, call_id: None });
186        key
187    }
188
189    fn insert_lines(&self, path: PathBuf, content: &str, lines: Vec<usize>, support: String, empty: bool) {
190        let mut ledger = self.0.lock();
191        if ledger.files.len() >= MAX_FILES
192            && !ledger.files.contains_key(&path)
193            && let Some(oldest) = ledger.order.pop_front()
194        {
195            ledger.files.remove(&oldest);
196        }
197        if !ledger.files.contains_key(&path) {
198            ledger.order.retain(|entry| entry != &path);
199            ledger.order.push_back(path.clone());
200        }
201        let digest = revision(content);
202        let file = ledger.files.entry(path).or_default();
203        if file.revision != digest {
204            *file = FileEvidence {
205                revision: digest,
206                line_count: content.lines().count(),
207                ..FileEvidence::default()
208            };
209        }
210        let credit = Arc::new(BTreeSet::from([support]));
211        if empty {
212            file.empty = Some(Arc::clone(&credit));
213        }
214        for line in lines {
215            if file.lines.len() >= MAX_CREDIT_LINES {
216                break;
217            }
218            file.lines.insert(line, Arc::clone(&credit));
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests;