Skip to main content

vtcode_webmcp/filesystem/
mod.rs

1use crate::error::{Result, WebmcpError};
2use crate::protocol::FileChange;
3use crate::runtime::{
4    AppliedChange, CheckResult, FileSnapshot, PatchProposal, RuntimeAdapter, RuntimeStatus, TurnResult, WorkspaceFile,
5};
6use async_trait::async_trait;
7use hashbrown::HashMap as SandboxEnvironment;
8use sha2::{Digest, Sha256};
9use std::collections::HashSet;
10use std::io::Read;
11use std::path::{Component, Path, PathBuf};
12use std::process::Stdio;
13use std::sync::{Arc, Mutex};
14use std::time::Duration;
15use tokio::io::{AsyncRead, AsyncReadExt};
16use tokio::process::Command;
17use tokio::sync::Mutex as AsyncMutex;
18use uuid::Uuid;
19use vtcode_commons::exclusions::{SENSITIVE_FILES, is_sensitive_file};
20use vtcode_diff::{DiffDocument, DiffOptions, format_unified_hunks};
21use vtcode_safety::sandboxing::{
22    CommandSpec, ExecExpiration, LinuxSandboxLauncher, SandboxManager, SandboxPolicy, SensitivePath,
23    default_sensitive_paths,
24};
25
26const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
27const MAX_CHECK_OUTPUT_BYTES: usize = 512 * 1024;
28const MAX_DIRECTORY_DEPTH: usize = 64;
29const MAX_VISITED_DIRECTORIES: usize = 16_384;
30const MAX_CHECK_SENSITIVE_PATHS: usize = 4096;
31const MAX_STORED_PROPOSALS: usize = 64;
32const MAX_STORED_PROPOSAL_BYTES: usize = 128 * 1024 * 1024;
33const IGNORED_DIRECTORY_NAMES: &[&str] = &[
34    ".cargo",
35    ".cache",
36    ".codegraph",
37    ".git",
38    ".mypy_cache",
39    ".opencode",
40    ".pytest_cache",
41    ".ruff_cache",
42    ".superpowers",
43    ".vscode",
44    ".worktrees",
45    ".vtcode",
46    "__pycache__",
47    "dist",
48    "node_modules",
49    "target",
50];
51const SENSITIVE_DIRECTORY_NAMES: &[&str] = &[
52    ".aws",
53    ".azure",
54    ".config",
55    ".docker",
56    ".gnupg",
57    ".kube",
58    ".pki",
59    ".secrets",
60    ".ssh",
61    ".terraform.d",
62];
63
64mod secure_open;
65
66use secure_open::{
67    open_root_directory, open_workspace_file, read_snapshot_blocking, replace_snapshot_if_current_blocking,
68};
69
70/// Bounds applied by the headless filesystem adapter.
71#[derive(Debug, Clone, Copy)]
72pub struct FilesystemLimits {
73    /// Maximum number of files returned by a listing.
74    pub max_files: usize,
75    /// Maximum size of one UTF-8 file.
76    pub max_file_bytes: usize,
77    /// Maximum total bytes read by one listing or one proposal.
78    pub max_total_bytes: usize,
79    /// Maximum files in one proposal.
80    pub max_changes: usize,
81    /// Maximum proposal content bytes per file.
82    pub max_change_bytes: usize,
83}
84
85impl Default for FilesystemLimits {
86    fn default() -> Self {
87        Self {
88            max_files: 8192,
89            max_file_bytes: 2 * 1024 * 1024,
90            max_total_bytes: 64 * 1024 * 1024,
91            max_changes: 32,
92            max_change_bytes: 2 * 1024 * 1024,
93        }
94    }
95}
96
97#[derive(Debug, Clone)]
98struct StoredProposal {
99    proposal: PatchProposal,
100    before: Vec<FileSnapshot>,
101    size_bytes: usize,
102}
103
104#[derive(Debug, Clone)]
105struct StoredChange {
106    change_id: String,
107    before: Vec<FileSnapshot>,
108    after: Vec<FileSnapshot>,
109}
110
111#[derive(Debug, Default)]
112struct FilesystemState {
113    proposals: std::collections::HashMap<String, StoredProposal>,
114    proposal_bytes: usize,
115    last_change: Option<StoredChange>,
116}
117
118/// A safe headless adapter rooted at one canonical workspace directory.
119#[derive(Clone)]
120pub struct FilesystemWorkspace {
121    root: Arc<PathBuf>,
122    root_dir: Arc<std::fs::File>,
123    allowed_roots: Arc<Vec<PathBuf>>,
124    limits: FilesystemLimits,
125    mutations_allowed: bool,
126    checks_allowed: bool,
127    allowed_commands: Arc<HashSet<String>>,
128    state: Arc<Mutex<FilesystemState>>,
129    mutation_lock: Arc<AsyncMutex<()>>,
130}
131
132impl std::fmt::Debug for FilesystemWorkspace {
133    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        formatter
135            .debug_struct("FilesystemWorkspace")
136            .field("root", &self.root)
137            .field("allowed_roots", &self.allowed_roots)
138            .field("limits", &self.limits)
139            .field("mutations_allowed", &self.mutations_allowed)
140            .field("checks_allowed", &self.checks_allowed)
141            .finish_non_exhaustive()
142    }
143}
144
145impl FilesystemWorkspace {
146    /// Construct an adapter. The current adapter exposes one canonical root;
147    /// an empty allowlist means only the supplied root is visible.
148    pub async fn new<I>(root: impl AsRef<Path>, allowed_roots: I, mutations_allowed: bool) -> Result<Self>
149    where
150        I: IntoIterator<Item = PathBuf>,
151    {
152        let root = vtcode_commons::canonicalize_async(root.as_ref().to_path_buf()).await?;
153        if !tokio::fs::metadata(&root).await?.is_dir() {
154            return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
155        }
156
157        let allowed_roots = allowed_roots.into_iter().collect::<Vec<_>>();
158        if allowed_roots.len() > 1 {
159            return Err(WebmcpError::InvalidRequest(
160                "headless WebMCP currently supports one workspace root".to_string(),
161            ));
162        }
163        let roots = if allowed_roots.is_empty() {
164            vec![root.clone()]
165        } else {
166            let mut roots = Vec::with_capacity(allowed_roots.len());
167            for allowed_root in allowed_roots {
168                let canonical = vtcode_commons::canonicalize_async(allowed_root).await?;
169                if !tokio::fs::metadata(&canonical).await?.is_dir() {
170                    return Err(WebmcpError::PathRejected("allowed root is not a directory".to_string()));
171                }
172                roots.push(canonical);
173            }
174            roots
175        };
176        if !roots.iter().any(|allowed| root.starts_with(allowed)) {
177            return Err(WebmcpError::PathRejected("workspace root is not in the allowed roots".to_string()));
178        }
179        let root_dir = open_root_directory(&root)?;
180        if !root_dir.metadata()?.is_dir() {
181            return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
182        }
183
184        Ok(Self {
185            root: Arc::new(root),
186            root_dir: Arc::new(root_dir),
187            allowed_roots: Arc::new(roots),
188            limits: FilesystemLimits::default(),
189            mutations_allowed,
190            checks_allowed: mutations_allowed,
191            allowed_commands: Arc::new(["cargo"].into_iter().map(str::to_string).collect()),
192            state: Arc::new(Mutex::new(FilesystemState::default())),
193            mutation_lock: Arc::new(AsyncMutex::new(())),
194        })
195    }
196
197    /// Replace the default workspace limits.
198    pub fn with_limits(mut self, limits: FilesystemLimits) -> Self {
199        self.limits = limits;
200        self
201    }
202
203    /// Replace the allowlisted check executables.
204    pub fn with_allowed_commands<I, S>(mut self, commands: I) -> Self
205    where
206        I: IntoIterator<Item = S>,
207        S: Into<String>,
208    {
209        self.allowed_commands = Arc::new(commands.into_iter().map(Into::into).collect());
210        self
211    }
212
213    /// Configure whether the runtime may execute the allowlisted checks.
214    pub fn with_checks_allowed(mut self, checks_allowed: bool) -> Self {
215        self.checks_allowed = checks_allowed;
216        self
217    }
218
219    /// Return a still-current proposal for an active runtime turn handoff.
220    ///
221    /// Rechecking the snapshots here prevents a browser proposal from being
222    /// handed to the agent after an external edit occurred between proposal
223    /// creation and turn submission.
224    pub async fn proposal_for_turn(&self, proposal_id: &str) -> Result<PatchProposal> {
225        let _mutation_guard = self.mutation_lock.lock().await;
226        let stored = {
227            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
228            state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
229        };
230        for expected in &stored.before {
231            let current = self.read_snapshot(&expected.path).await?;
232            if current.digest != expected.digest {
233                return Err(WebmcpError::Conflict {
234                    path: expected.path.clone(),
235                    expected: expected.digest.clone(),
236                    actual: current.digest,
237                });
238            }
239        }
240        Ok(stored.proposal)
241    }
242
243    /// Returns the canonical primary workspace path.
244    pub fn root(&self) -> &Path {
245        self.root.as_ref()
246    }
247
248    async fn read_snapshot(&self, path: &str) -> Result<FileSnapshot> {
249        let root = self.root.clone();
250        let root_dir = self.root_dir.clone();
251        let path = path.to_string();
252        let limits = self.limits;
253        tokio::task::spawn_blocking(move || read_snapshot_blocking(&root, &root_dir, &path, limits))
254            .await
255            .map_err(|error| WebmcpError::Adapter(format!("file read task failed: {error}")))?
256    }
257
258    async fn replace_snapshot_if_current(&self, update: &FileSnapshot, expected: &FileSnapshot) -> Result<()> {
259        let root = self.root.clone();
260        let root_dir = self.root_dir.clone();
261        let update = update.clone();
262        let expected = expected.clone();
263        tokio::task::spawn_blocking(move || replace_snapshot_if_current_blocking(&root, &root_dir, &update, &expected))
264            .await
265            .map_err(|error| WebmcpError::Adapter(format!("compare-and-replace task failed: {error}")))?
266    }
267
268    async fn apply_snapshots_if_current(&self, updates: &[FileSnapshot], expected: &[FileSnapshot]) -> Result<()> {
269        if updates.len() != expected.len() {
270            return Err(WebmcpError::Adapter("snapshot compare-and-swap lengths do not match".to_string()));
271        }
272        let mut applied = Vec::with_capacity(updates.len());
273        for (index, (update, expected_snapshot)) in updates.iter().zip(expected).enumerate() {
274            if let Err(error) = self.replace_snapshot_if_current(update, expected_snapshot).await {
275                let rollback_error = self
276                    .rollback_snapshots(&applied, expected.get(..index).unwrap_or(&[]))
277                    .await
278                    .err();
279                return Err(rollback_error.unwrap_or(error));
280            }
281            applied.push(update.clone());
282        }
283        Ok(())
284    }
285
286    async fn apply_proposal_inner(&self, proposal_id: &str) -> Result<AppliedChange> {
287        let _mutation_guard = self.mutation_lock.lock().await;
288        let stored = {
289            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
290            state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
291        };
292
293        let after = stored
294            .proposal
295            .changes
296            .iter()
297            .map(|change| FileSnapshot {
298                path: change.path.clone(),
299                content: change.content.clone(),
300                digest: digest_text(&change.content),
301            })
302            .collect::<Vec<_>>();
303        self.apply_snapshots_if_current(&after, &stored.before).await?;
304
305        let change_id = Uuid::new_v4().simple().to_string();
306        let result = AppliedChange {
307            change_id: change_id.clone(),
308            paths: after.iter().map(|snapshot| snapshot.path.clone()).collect(),
309        };
310        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
311        if let Some(removed) = state.proposals.remove(proposal_id) {
312            state.proposal_bytes = state.proposal_bytes.saturating_sub(removed.size_bytes);
313        }
314        state.last_change = Some(StoredChange { change_id, before: stored.before, after });
315        Ok(result)
316    }
317
318    async fn revert_last_change_inner(&self, change_id: &str) -> Result<AppliedChange> {
319        let _mutation_guard = self.mutation_lock.lock().await;
320        let stored = {
321            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
322            state.last_change.clone().ok_or(WebmcpError::ChangeNotFound)?
323        };
324        if stored.change_id != change_id {
325            return Err(WebmcpError::ChangeNotFound);
326        }
327        self.apply_snapshots_if_current(&stored.before, &stored.after).await?;
328        let reverted = AppliedChange {
329            change_id: stored.change_id.clone(),
330            paths: stored.before.iter().map(|snapshot| snapshot.path.clone()).collect(),
331        };
332        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
333        state.last_change = None;
334        Ok(reverted)
335    }
336
337    async fn rollback_snapshots(&self, applied: &[FileSnapshot], originals: &[FileSnapshot]) -> Result<()> {
338        let mut failed = false;
339        for (applied_snapshot, original) in applied.iter().zip(originals).rev() {
340            if let Err(error) = self.replace_snapshot_if_current(original, applied_snapshot).await {
341                tracing::error!(
342                    path = %original.path,
343                    error = %error,
344                    "failed to roll back a partially applied WebMCP change"
345                );
346                failed = true;
347            }
348        }
349        if failed { Err(WebmcpError::PartialApply) } else { Ok(()) }
350    }
351
352    fn proposal_diff(before: &[FileSnapshot], changes: &[FileChange]) -> String {
353        let mut diff = String::new();
354        for (snapshot, change) in before.iter().zip(changes) {
355            let old_label = format!("a/{}", snapshot.path);
356            let new_label = format!("b/{}", change.path);
357            let options = DiffOptions {
358                context_lines: 3,
359                old_label: Some(old_label.as_str()),
360                new_label: Some(new_label.as_str()),
361                ..DiffOptions::default()
362            };
363            let document = DiffDocument::between(&snapshot.content, &change.content, options.clone());
364            if document.hunks.is_empty() {
365                continue;
366            }
367            diff.push_str(&format_unified_hunks(&document.hunks, &options));
368        }
369        diff
370    }
371}
372
373#[async_trait]
374impl RuntimeAdapter for FilesystemWorkspace {
375    async fn status(&self) -> Result<RuntimeStatus> {
376        Ok(RuntimeStatus {
377            explanations_available: false,
378            workspace_root: self.root.display().to_string(),
379            connected: true,
380            turns_available: false,
381            mutations_allowed: self.mutations_allowed,
382            checks_allowed: self.checks_allowed,
383            approval_authority: if self.mutations_allowed {
384                "headless full-auto allowlist".into()
385            } else {
386                "headless policy (mutations disabled)".into()
387            },
388        })
389    }
390
391    async fn list_files(&self) -> Result<Vec<WorkspaceFile>> {
392        let root = self.root.clone();
393        let root_dir = self.root_dir.clone();
394        let limits = self.limits;
395        tokio::task::spawn_blocking(move || list_files_blocking(&root, &root_dir, limits))
396            .await
397            .map_err(|error| WebmcpError::Adapter(format!("file listing task failed: {error}")))?
398    }
399
400    async fn read_file(&self, path: &str) -> Result<FileSnapshot> {
401        self.read_snapshot(path).await
402    }
403
404    async fn propose_changes(&self, changes: Vec<FileChange>) -> Result<PatchProposal> {
405        let _mutation_guard = self.mutation_lock.lock().await;
406        if changes.is_empty() || changes.len() > self.limits.max_changes {
407            return Err(WebmcpError::LimitExceeded);
408        }
409        {
410            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
411            if state.proposals.len() >= MAX_STORED_PROPOSALS {
412                return Err(WebmcpError::LimitExceeded);
413            }
414        }
415        let mut paths = HashSet::with_capacity(changes.len());
416        let mut before = Vec::with_capacity(changes.len());
417        let mut proposal_bytes = 0usize;
418        for change in &changes {
419            if change.content.len() > self.limits.max_change_bytes || !paths.insert(change.path.clone()) {
420                return Err(WebmcpError::LimitExceeded);
421            }
422            let snapshot = self.read_snapshot(&change.path).await?;
423            if snapshot.digest != change.base_digest {
424                return Err(WebmcpError::Conflict {
425                    path: change.path.clone(),
426                    expected: change.base_digest.clone(),
427                    actual: snapshot.digest,
428                });
429            }
430            proposal_bytes = proposal_bytes
431                .checked_add(snapshot.content.len())
432                .and_then(|bytes| bytes.checked_add(change.content.len()))
433                .ok_or(WebmcpError::LimitExceeded)?;
434            if proposal_bytes > self.limits.max_total_bytes {
435                return Err(WebmcpError::LimitExceeded);
436            }
437            before.push(snapshot);
438        }
439
440        let unified_diff = Self::proposal_diff(&before, &changes);
441        if unified_diff.len() > self.limits.max_total_bytes {
442            return Err(WebmcpError::LimitExceeded);
443        }
444        let stored_size_bytes = proposal_bytes
445            .checked_add(unified_diff.len())
446            .ok_or(WebmcpError::LimitExceeded)?;
447        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
448        if state
449            .proposal_bytes
450            .checked_add(stored_size_bytes)
451            .is_none_or(|bytes| bytes > MAX_STORED_PROPOSAL_BYTES)
452        {
453            return Err(WebmcpError::LimitExceeded);
454        }
455        let proposal = PatchProposal {
456            proposal_id: Uuid::new_v4().simple().to_string(),
457            unified_diff,
458            changes,
459        };
460        state.proposal_bytes += stored_size_bytes;
461        drop(state.proposals.insert(
462            proposal.proposal_id.clone(),
463            StoredProposal {
464                proposal: proposal.clone(),
465                before,
466                size_bytes: stored_size_bytes,
467            },
468        ));
469        Ok(proposal)
470    }
471
472    async fn apply_proposal(&self, proposal_id: &str) -> Result<AppliedChange> {
473        if !self.mutations_allowed {
474            return Err(WebmcpError::ApprovalRequired);
475        }
476        let workspace = self.clone();
477        let proposal_id = proposal_id.to_string();
478        tokio::spawn(async move { workspace.apply_proposal_inner(&proposal_id).await })
479            .await
480            .map_err(|error| WebmcpError::Adapter(format!("WebMCP apply task failed: {error}")))?
481    }
482
483    async fn run_checks(&self, command: &str) -> Result<CheckResult> {
484        let args = parse_safe_command(command, &self.allowed_commands)?;
485        if !self.checks_allowed {
486            return Err(WebmcpError::ApprovalRequired);
487        }
488        let _mutation_guard = self.mutation_lock.lock().await;
489        let (program, arguments) = args
490            .split_first()
491            .ok_or_else(|| WebmcpError::InvalidRequest("check command cannot be empty".to_string()))?;
492        let executable = resolve_check_executable(program, self.root.as_ref())?;
493        // Capture host toolchain locations before replacing HOME with the
494        // workspace sandbox HOME. This keeps checks reproducible without
495        // allowing the child to inherit the caller's complete environment.
496        let host_home = std::env::var_os("HOME")
497            .or_else(|| std::env::var_os("USERPROFILE"))
498            .map(PathBuf::from);
499        let mut environment = SandboxEnvironment::new();
500        let _ = environment.insert("PATH".to_string(), trusted_executable_path(&executable)?);
501        let _ = environment.insert("HOME".to_string(), self.root.display().to_string());
502        let _ = environment.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
503        let _ = environment.insert("CARGO_TERM_COLOR".to_string(), "never".to_string());
504        let cargo_home = host_home
505            .as_ref()
506            .and_then(|home| trusted_toolchain_directory(&home.join(".cargo"), self.root.as_ref()));
507        if let Some(cargo_home) = cargo_home {
508            let _ = environment.insert("CARGO_HOME".to_string(), cargo_home.to_string_lossy().into_owned());
509        }
510        let rustup_home = host_home
511            .as_ref()
512            .and_then(|home| trusted_toolchain_directory(&home.join(".rustup"), self.root.as_ref()));
513        if let Some(rustup_home) = rustup_home {
514            let _ = environment.insert("RUSTUP_HOME".to_string(), rustup_home.to_string_lossy().into_owned());
515        }
516        let spec = CommandSpec::new(executable)
517            .with_args(arguments.iter().cloned())
518            .with_cwd(self.root.as_ref().to_path_buf())
519            .with_env(environment)
520            .with_expiration(ExecExpiration::Timeout(CHECK_TIMEOUT));
521        let linux_launcher = LinuxSandboxLauncher::resolve();
522        let check_policy = check_sandbox_policy(self.root.as_ref())
523            .map_err(|error| WebmcpError::Adapter(format!("failed to build WebMCP check sandbox: {error}")))?;
524        let exec_env = SandboxManager::new()
525            .transform(spec, &check_policy, self.root.as_ref(), linux_launcher.as_ref())
526            .map_err(|error| WebmcpError::Adapter(format!("failed to sandbox WebMCP check: {error}")))?;
527        let mut child = Command::new(exec_env.program)
528            .args(exec_env.args)
529            .current_dir(exec_env.cwd)
530            .env_clear()
531            .envs(exec_env.env)
532            .stdin(Stdio::null())
533            .stdout(Stdio::piped())
534            .stderr(Stdio::piped())
535            .kill_on_drop(true)
536            .spawn()?;
537        let stdout = child
538            .stdout
539            .take()
540            .ok_or_else(|| WebmcpError::Adapter("check process stdout was not captured".to_string()))?;
541        let stderr = child
542            .stderr
543            .take()
544            .ok_or_else(|| WebmcpError::Adapter("check process stderr was not captured".to_string()))?;
545        let output = Box::pin(tokio::time::timeout(CHECK_TIMEOUT, async {
546            let (status, stdout, stderr) =
547                tokio::join!(child.wait(), read_process_output(stdout), read_process_output(stderr));
548            Ok::<_, WebmcpError>((status?, stdout?, stderr?))
549        }))
550        .await;
551        let (status, stdout, stderr) = match output {
552            Ok(result) => result?,
553            Err(_elapsed) => {
554                drop(child.kill().await);
555                drop(child.wait().await);
556                return Err(WebmcpError::Timeout(CHECK_TIMEOUT));
557            }
558        };
559        Ok(CheckResult {
560            command: command.to_string(),
561            exit_code: status.code(),
562            stdout: String::from_utf8_lossy(&stdout).into_owned(),
563            stderr: String::from_utf8_lossy(&stderr).into_owned(),
564        })
565    }
566
567    async fn revert_last_change(&self, change_id: &str) -> Result<AppliedChange> {
568        if !self.mutations_allowed {
569            return Err(WebmcpError::ApprovalRequired);
570        }
571        let workspace = self.clone();
572        let change_id = change_id.to_string();
573        tokio::spawn(async move { workspace.revert_last_change_inner(&change_id).await })
574            .await
575            .map_err(|error| WebmcpError::Adapter(format!("WebMCP revert task failed: {error}")))?
576    }
577
578    async fn request_turn(&self, prompt: &str, _proposal_id: Option<&str>) -> Result<TurnResult> {
579        if prompt.trim().is_empty() {
580            return Err(WebmcpError::InvalidRequest("agent turn prompt cannot be empty".to_string()));
581        }
582        if prompt.len() > 16 * 1024 {
583            return Err(WebmcpError::LimitExceeded);
584        }
585        Err(WebmcpError::Unsupported(
586            "agent turns require an active VT Code runtime; start `vtcode chat` and run `/webmcp pair <origin>` in that same session. The standalone `vtcode webmcp serve` command exposes workspace operations only".to_string(),
587        ))
588    }
589}
590
591fn validate_relative_path(path: &str) -> Result<PathBuf> {
592    if path.is_empty() || path.len() > 4096 || path.contains('\0') {
593        return Err(WebmcpError::PathRejected(path.to_string()));
594    }
595    let path = Path::new(path);
596    if path.is_absolute() {
597        return Err(WebmcpError::PathRejected(path.display().to_string()));
598    }
599    let mut validated = PathBuf::new();
600    for component in path.components() {
601        match component {
602            Component::Normal(part) => validated.push(part),
603            Component::CurDir => {}
604            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
605                return Err(WebmcpError::PathRejected(path.display().to_string()));
606            }
607        }
608    }
609    if validated.as_os_str().is_empty() {
610        return Err(WebmcpError::PathRejected(path.display().to_string()));
611    }
612    Ok(validated)
613}
614
615fn reject_sensitive_relative_path(relative: &Path, original: &str) -> Result<()> {
616    if is_sensitive_relative_path(relative) {
617        return Err(WebmcpError::PathRejected(format!("sensitive workspace path is not exposed: {original}")));
618    }
619    Ok(())
620}
621
622/// Classify recorded paths before exposing change evidence. This lexical filter
623/// does not authorize filesystem access; reads still require bound directory handles.
624pub fn is_public_evidence_path(path: &str) -> bool {
625    validate_relative_path(path).is_ok_and(|relative| {
626        !is_sensitive_relative_path(&relative)
627            && !relative.components().any(|component| {
628                matches!(component, Component::Normal(name) if name.to_str().is_none_or(|name| IGNORED_DIRECTORY_NAMES.iter().any(|ignored| name.eq_ignore_ascii_case(ignored))))
629            })
630    })
631}
632
633fn is_sensitive_relative_path(relative: &Path) -> bool {
634    relative.components().any(|component| {
635        let Component::Normal(name) = component else {
636            return false;
637        };
638        let Some(name) = name.to_str() else {
639            return true;
640        };
641        is_sensitive_file(name)
642            || SENSITIVE_DIRECTORY_NAMES
643                .iter()
644                .any(|sensitive| name.eq_ignore_ascii_case(sensitive))
645    })
646}
647
648#[cfg(unix)]
649fn has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
650    use std::os::unix::fs::MetadataExt;
651
652    metadata.nlink() > 1
653}
654
655#[cfg(not(unix))]
656const fn has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
657    false
658}
659
660fn reject_hard_links(metadata: &std::fs::Metadata, path: &str) -> Result<()> {
661    if has_multiple_hard_links(metadata) {
662        return Err(WebmcpError::PathRejected(format!("hard-linked file is not allowed: {path}")));
663    }
664    Ok(())
665}
666
667fn digest_text(content: &str) -> String {
668    let digest = Sha256::digest(content.as_bytes());
669    let mut encoded = String::with_capacity(7 + digest.len() * 2);
670    encoded.push_str("sha256:");
671    for byte in digest {
672        encoded.push_str(&format!("{byte:02x}"));
673    }
674    encoded
675}
676
677fn list_files_blocking(root: &Path, root_dir: &std::fs::File, limits: FilesystemLimits) -> Result<Vec<WorkspaceFile>> {
678    let mut files = Vec::new();
679    let mut total_bytes = 0usize;
680    let mut visited_directories = 0usize;
681    visit_directory(root, root_dir, root, 0, limits, &mut total_bytes, &mut visited_directories, &mut files)?;
682    files.sort_by(|left, right| left.path.cmp(&right.path));
683    Ok(files)
684}
685
686fn visit_directory(
687    root: &Path,
688    root_dir: &std::fs::File,
689    directory: &Path,
690    depth: usize,
691    limits: FilesystemLimits,
692    total_bytes: &mut usize,
693    visited_directories: &mut usize,
694    files: &mut Vec<WorkspaceFile>,
695) -> Result<()> {
696    if depth > MAX_DIRECTORY_DEPTH {
697        return Err(WebmcpError::LimitExceeded);
698    }
699    *visited_directories = visited_directories.saturating_add(1);
700    if *visited_directories > MAX_VISITED_DIRECTORIES {
701        return Err(WebmcpError::LimitExceeded);
702    }
703    let entries = std::fs::read_dir(directory)?;
704    for entry in entries {
705        if files.len() >= limits.max_files {
706            return Err(WebmcpError::LimitExceeded);
707        }
708        let entry = entry?;
709        let path = entry.path();
710        let relative_path = path
711            .strip_prefix(root)
712            .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
713        if is_sensitive_relative_path(relative_path) {
714            continue;
715        }
716        let metadata = std::fs::symlink_metadata(&path)?;
717        if metadata.file_type().is_symlink() {
718            continue;
719        }
720        if metadata.is_dir() {
721            if entry
722                .file_name()
723                .to_str()
724                .is_some_and(|name| IGNORED_DIRECTORY_NAMES.contains(&name))
725            {
726                continue;
727            }
728            visit_directory(root, root_dir, &path, depth + 1, limits, total_bytes, visited_directories, files)?;
729            continue;
730        }
731        if !metadata.is_file() {
732            continue;
733        }
734        if has_multiple_hard_links(&metadata) {
735            continue;
736        }
737        let max_file_bytes = u64::try_from(limits.max_file_bytes).unwrap_or(u64::MAX);
738        if metadata.len() > max_file_bytes {
739            continue;
740        }
741        let file = open_workspace_file(root, root_dir, relative_path, false)?;
742        let opened_metadata = file.metadata()?;
743        if !opened_metadata.is_file()
744            || has_multiple_hard_links(&opened_metadata)
745            || opened_metadata.len() > max_file_bytes
746        {
747            continue;
748        }
749        let mut reader = file.take(max_file_bytes.saturating_add(1));
750        let mut bytes = Vec::with_capacity(limits.max_file_bytes.min(64 * 1024));
751        let _bytes_read = reader.read_to_end(&mut bytes)?;
752        if bytes.len() > limits.max_file_bytes {
753            continue;
754        }
755        let Ok(content) = String::from_utf8(bytes) else {
756            continue;
757        };
758        *total_bytes = total_bytes.saturating_add(content.len());
759        if *total_bytes > limits.max_total_bytes {
760            return Err(WebmcpError::LimitExceeded);
761        }
762        let relative = relative_path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
763        files.push(WorkspaceFile {
764            path: relative,
765            size_bytes: content.len() as u64,
766            digest: digest_text(&content),
767        });
768    }
769    Ok(())
770}
771
772fn trusted_toolchain_directory(path: &Path, workspace: &Path) -> Option<PathBuf> {
773    let canonical = vtcode_commons::canonicalize(path).ok()?;
774    (canonical.is_dir() && !canonical.starts_with(workspace)).then_some(canonical)
775}
776
777fn resolve_check_executable(program: &str, workspace: &Path) -> Result<PathBuf> {
778    let executable_name = if cfg!(windows) {
779        format!("{program}.exe")
780    } else {
781        program.to_string()
782    };
783    let mut candidates = Vec::new();
784
785    #[cfg(unix)]
786    {
787        if program == "cargo" {
788            if let Some(home) = std::env::var_os("HOME") {
789                candidates.push(PathBuf::from(home).join(".cargo/bin").join(&executable_name));
790            }
791        }
792        for directory in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"] {
793            candidates.push(PathBuf::from(directory).join(&executable_name));
794        }
795    }
796
797    #[cfg(windows)]
798    {
799        if program == "cargo"
800            && let Some(profile) = std::env::var_os("USERPROFILE")
801        {
802            candidates.push(PathBuf::from(profile).join(".cargo/bin").join(&executable_name));
803        }
804        if let Some(system_root) = std::env::var_os("SystemRoot") {
805            candidates.push(PathBuf::from(system_root).join("System32").join(&executable_name));
806        }
807    }
808
809    for candidate in candidates {
810        let Ok(canonical) = vtcode_commons::canonicalize(&candidate) else {
811            continue;
812        };
813        if canonical.starts_with(workspace) {
814            continue;
815        }
816        let Ok(metadata) = std::fs::metadata(&canonical) else {
817            continue;
818        };
819        if metadata.is_file() && is_executable_file(&metadata) {
820            return Ok(canonical);
821        }
822    }
823
824    Err(WebmcpError::InvalidRequest(format!(
825        "allowlisted check executable is not installed in a trusted location: {program}"
826    )))
827}
828
829fn is_executable_file(metadata: &std::fs::Metadata) -> bool {
830    #[cfg(unix)]
831    {
832        use std::os::unix::fs::PermissionsExt;
833
834        metadata.permissions().mode() & 0o111 != 0
835    }
836    #[cfg(not(unix))]
837    {
838        metadata.is_file()
839    }
840}
841
842fn trusted_executable_path(executable: &Path) -> Result<String> {
843    let mut directories = Vec::new();
844    if let Some(parent) = executable.parent() {
845        directories.push(parent.to_path_buf());
846    }
847    #[cfg(unix)]
848    directories.extend([
849        PathBuf::from("/usr/local/bin"),
850        PathBuf::from("/opt/homebrew/bin"),
851        PathBuf::from("/usr/bin"),
852        PathBuf::from("/bin"),
853    ]);
854    #[cfg(windows)]
855    if let Some(system_root) = std::env::var_os("SystemRoot") {
856        directories.push(PathBuf::from(system_root).join("System32"));
857    }
858    directories.dedup();
859    std::env::join_paths(directories)
860        .map(|path| path.to_string_lossy().into_owned())
861        .map_err(|error| WebmcpError::Adapter(format!("failed to build trusted check PATH: {error}")))
862}
863
864fn sensitive_path_for_policy(path: &Path) -> Result<SensitivePath> {
865    let path_string = path.to_str().ok_or_else(|| {
866        WebmcpError::Adapter(format!("cannot sandbox a non-UTF-8 sensitive path: {}", path.display()))
867    })?;
868    if path_string
869        .chars()
870        .any(|character| character == '"' || character == '\\' || character.is_control())
871    {
872        return Err(WebmcpError::Adapter(format!(
873            "cannot sandbox a sensitive path containing an unsafe character: {path_string}"
874        )));
875    }
876    Ok(SensitivePath::new(path_string))
877}
878
879fn collect_workspace_sensitive_paths(root: &Path) -> Result<Vec<SensitivePath>> {
880    let mut sensitive_paths = Vec::new();
881    let mut pending = vec![(root.to_path_buf(), 0usize)];
882    let mut visited_directories = 0usize;
883
884    while let Some((directory, depth)) = pending.pop() {
885        if depth > MAX_DIRECTORY_DEPTH {
886            return Err(WebmcpError::LimitExceeded);
887        }
888        visited_directories = visited_directories.saturating_add(1);
889        if visited_directories > MAX_VISITED_DIRECTORIES {
890            return Err(WebmcpError::LimitExceeded);
891        }
892
893        for entry in std::fs::read_dir(&directory)? {
894            let entry = entry?;
895            let path = entry.path();
896            let relative_path = path
897                .strip_prefix(root)
898                .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
899            if is_sensitive_relative_path(relative_path) {
900                if sensitive_paths.len() >= MAX_CHECK_SENSITIVE_PATHS {
901                    return Err(WebmcpError::LimitExceeded);
902                }
903                sensitive_paths.push(sensitive_path_for_policy(&path)?);
904                continue;
905            }
906
907            let metadata = std::fs::symlink_metadata(&path)?;
908            if metadata.file_type().is_symlink() {
909                continue;
910            }
911            if metadata.is_dir() {
912                pending.push((path, depth + 1));
913            }
914        }
915    }
916
917    Ok(sensitive_paths)
918}
919
920fn check_sandbox_policy(root: &Path) -> Result<SandboxPolicy> {
921    let mut sensitive_paths = default_sensitive_paths();
922    for name in SENSITIVE_FILES.iter().copied().chain(SENSITIVE_DIRECTORY_NAMES.iter().copied()) {
923        sensitive_paths.push(sensitive_path_for_policy(&root.join(name))?);
924    }
925    sensitive_paths.extend(collect_workspace_sensitive_paths(root)?);
926    Ok(SandboxPolicy::workspace_write_with_sensitive_paths(vec![root.to_path_buf()], sensitive_paths))
927}
928
929fn parse_safe_command(command: &str, allowed_commands: &HashSet<String>) -> Result<Vec<String>> {
930    if command.len() > 512
931        || command
932            .chars()
933            .any(|character| matches!(character, ';' | '|' | '&' | '$' | '`' | '>' | '<' | '\n' | '\r'))
934    {
935        return Err(WebmcpError::InvalidRequest("check command contains shell syntax".to_string()));
936    }
937    let args = shell_words::split(command)
938        .map_err(|error| WebmcpError::InvalidRequest(format!("invalid check command: {error}")))?;
939    let Some(program) = args.first() else {
940        return Err(WebmcpError::InvalidRequest("check command cannot be empty".to_string()));
941    };
942    if Path::new(program).components().count() != 1 || !allowed_commands.contains(program) {
943        return Err(WebmcpError::InvalidRequest("check executable is not allowlisted".to_string()));
944    }
945    if args.iter().any(|argument| argument.contains('\0')) {
946        return Err(WebmcpError::InvalidRequest("check command contains an invalid argument".to_string()));
947    }
948    match program.as_str() {
949        "cargo"
950            if args.get(1).is_some_and(|subcommand| subcommand == "check")
951                && args.iter().skip(2).all(|argument| {
952                    matches!(
953                        argument.as_str(),
954                        "--locked" | "--offline" | "--workspace" | "--all-targets" | "--all-features"
955                    )
956                }) =>
957        {
958            Ok(args)
959        }
960        "printf" => Ok(args),
961        _ => Err(WebmcpError::InvalidRequest(
962            "only the bounded cargo check and printf commands are supported".to_string(),
963        )),
964    }
965}
966
967async fn read_process_output<R>(mut reader: R) -> Result<Vec<u8>>
968where
969    R: AsyncRead + Unpin,
970{
971    let mut captured = Vec::with_capacity(MAX_CHECK_OUTPUT_BYTES.min(8192));
972    let mut buffer = [0u8; 8192];
973    let mut exceeded = false;
974    loop {
975        let bytes_read = reader.read(&mut buffer).await?;
976        if bytes_read == 0 {
977            break;
978        }
979        let remaining = MAX_CHECK_OUTPUT_BYTES.saturating_sub(captured.len());
980        exceeded |= bytes_read > remaining;
981        if remaining > 0 {
982            let chunk = buffer
983                .get(..bytes_read.min(remaining))
984                .ok_or_else(|| WebmcpError::Adapter("check output read exceeded its buffer".to_string()))?;
985            captured.extend_from_slice(chunk);
986        }
987    }
988    if exceeded {
989        Err(WebmcpError::LimitExceeded)
990    } else {
991        Ok(captured)
992    }
993}
994
995#[cfg(test)]
996mod tests;