Skip to main content

vtcode_webmcp/
filesystem.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, Seek, SeekFrom, Write};
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::diff::{DiffHunk, DiffLineKind, DiffOptions, compute_diff};
20use vtcode_commons::exclusions::{SENSITIVE_FILES, is_sensitive_file};
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
64/// Bounds applied by the headless filesystem adapter.
65#[derive(Debug, Clone, Copy)]
66pub struct FilesystemLimits {
67    /// Maximum number of files returned by a listing.
68    pub max_files: usize,
69    /// Maximum size of one UTF-8 file.
70    pub max_file_bytes: usize,
71    /// Maximum total bytes read by one listing or one proposal.
72    pub max_total_bytes: usize,
73    /// Maximum files in one proposal.
74    pub max_changes: usize,
75    /// Maximum proposal content bytes per file.
76    pub max_change_bytes: usize,
77}
78
79impl Default for FilesystemLimits {
80    fn default() -> Self {
81        Self {
82            max_files: 8192,
83            max_file_bytes: 2 * 1024 * 1024,
84            max_total_bytes: 64 * 1024 * 1024,
85            max_changes: 32,
86            max_change_bytes: 2 * 1024 * 1024,
87        }
88    }
89}
90
91#[derive(Debug, Clone)]
92struct StoredProposal {
93    proposal: PatchProposal,
94    before: Vec<FileSnapshot>,
95    size_bytes: usize,
96}
97
98#[derive(Debug, Clone)]
99struct StoredChange {
100    change_id: String,
101    before: Vec<FileSnapshot>,
102    after: Vec<FileSnapshot>,
103}
104
105#[derive(Debug, Default)]
106struct FilesystemState {
107    proposals: std::collections::HashMap<String, StoredProposal>,
108    proposal_bytes: usize,
109    last_change: Option<StoredChange>,
110}
111
112/// A safe headless adapter rooted at one canonical workspace directory.
113#[derive(Clone)]
114pub struct FilesystemWorkspace {
115    root: Arc<PathBuf>,
116    root_dir: Arc<std::fs::File>,
117    allowed_roots: Arc<Vec<PathBuf>>,
118    limits: FilesystemLimits,
119    mutations_allowed: bool,
120    checks_allowed: bool,
121    allowed_commands: Arc<HashSet<String>>,
122    state: Arc<Mutex<FilesystemState>>,
123    mutation_lock: Arc<AsyncMutex<()>>,
124}
125
126impl std::fmt::Debug for FilesystemWorkspace {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        formatter
129            .debug_struct("FilesystemWorkspace")
130            .field("root", &self.root)
131            .field("allowed_roots", &self.allowed_roots)
132            .field("limits", &self.limits)
133            .field("mutations_allowed", &self.mutations_allowed)
134            .field("checks_allowed", &self.checks_allowed)
135            .finish_non_exhaustive()
136    }
137}
138
139impl FilesystemWorkspace {
140    /// Construct an adapter. The current adapter exposes one canonical root;
141    /// an empty allowlist means only the supplied root is visible.
142    pub async fn new<I>(root: impl AsRef<Path>, allowed_roots: I, mutations_allowed: bool) -> Result<Self>
143    where
144        I: IntoIterator<Item = PathBuf>,
145    {
146        let root = vtcode_commons::canonicalize_async(root.as_ref().to_path_buf()).await?;
147        if !tokio::fs::metadata(&root).await?.is_dir() {
148            return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
149        }
150
151        let allowed_roots = allowed_roots.into_iter().collect::<Vec<_>>();
152        if allowed_roots.len() > 1 {
153            return Err(WebmcpError::InvalidRequest(
154                "headless WebMCP currently supports one workspace root".to_string(),
155            ));
156        }
157        let roots = if allowed_roots.is_empty() {
158            vec![root.clone()]
159        } else {
160            let mut roots = Vec::with_capacity(allowed_roots.len());
161            for allowed_root in allowed_roots {
162                let canonical = vtcode_commons::canonicalize_async(allowed_root).await?;
163                if !tokio::fs::metadata(&canonical).await?.is_dir() {
164                    return Err(WebmcpError::PathRejected("allowed root is not a directory".to_string()));
165                }
166                roots.push(canonical);
167            }
168            roots
169        };
170        if !roots.iter().any(|allowed| root.starts_with(allowed)) {
171            return Err(WebmcpError::PathRejected("workspace root is not in the allowed roots".to_string()));
172        }
173        let root_dir = open_root_directory(&root)?;
174        if !root_dir.metadata()?.is_dir() {
175            return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
176        }
177
178        Ok(Self {
179            root: Arc::new(root),
180            root_dir: Arc::new(root_dir),
181            allowed_roots: Arc::new(roots),
182            limits: FilesystemLimits::default(),
183            mutations_allowed,
184            checks_allowed: mutations_allowed,
185            allowed_commands: Arc::new(["cargo"].into_iter().map(str::to_string).collect()),
186            state: Arc::new(Mutex::new(FilesystemState::default())),
187            mutation_lock: Arc::new(AsyncMutex::new(())),
188        })
189    }
190
191    /// Replace the default workspace limits.
192    pub fn with_limits(mut self, limits: FilesystemLimits) -> Self {
193        self.limits = limits;
194        self
195    }
196
197    /// Replace the allowlisted check executables.
198    pub fn with_allowed_commands<I, S>(mut self, commands: I) -> Self
199    where
200        I: IntoIterator<Item = S>,
201        S: Into<String>,
202    {
203        self.allowed_commands = Arc::new(commands.into_iter().map(Into::into).collect());
204        self
205    }
206
207    /// Configure whether the runtime may execute the allowlisted checks.
208    pub fn with_checks_allowed(mut self, checks_allowed: bool) -> Self {
209        self.checks_allowed = checks_allowed;
210        self
211    }
212
213    /// Return a still-current proposal for an active runtime turn handoff.
214    ///
215    /// Rechecking the snapshots here prevents a browser proposal from being
216    /// handed to the agent after an external edit occurred between proposal
217    /// creation and turn submission.
218    pub async fn proposal_for_turn(&self, proposal_id: &str) -> Result<PatchProposal> {
219        let _mutation_guard = self.mutation_lock.lock().await;
220        let stored = {
221            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
222            state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
223        };
224        for expected in &stored.before {
225            let current = self.read_snapshot(&expected.path).await?;
226            if current.digest != expected.digest {
227                return Err(WebmcpError::Conflict {
228                    path: expected.path.clone(),
229                    expected: expected.digest.clone(),
230                    actual: current.digest,
231                });
232            }
233        }
234        Ok(stored.proposal)
235    }
236
237    /// Returns the canonical primary workspace path.
238    pub fn root(&self) -> &Path {
239        self.root.as_ref()
240    }
241
242    async fn read_snapshot(&self, path: &str) -> Result<FileSnapshot> {
243        let root = self.root.clone();
244        let root_dir = self.root_dir.clone();
245        let path = path.to_string();
246        let limits = self.limits;
247        tokio::task::spawn_blocking(move || read_snapshot_blocking(&root, &root_dir, &path, limits))
248            .await
249            .map_err(|error| WebmcpError::Adapter(format!("file read task failed: {error}")))?
250    }
251
252    async fn replace_snapshot_if_current(&self, update: &FileSnapshot, expected: &FileSnapshot) -> Result<()> {
253        let root = self.root.clone();
254        let root_dir = self.root_dir.clone();
255        let update = update.clone();
256        let expected = expected.clone();
257        tokio::task::spawn_blocking(move || replace_snapshot_if_current_blocking(&root, &root_dir, &update, &expected))
258            .await
259            .map_err(|error| WebmcpError::Adapter(format!("compare-and-replace task failed: {error}")))?
260    }
261
262    async fn apply_snapshots_if_current(&self, updates: &[FileSnapshot], expected: &[FileSnapshot]) -> Result<()> {
263        if updates.len() != expected.len() {
264            return Err(WebmcpError::Adapter("snapshot compare-and-swap lengths do not match".to_string()));
265        }
266        let mut applied = Vec::with_capacity(updates.len());
267        for (index, (update, expected_snapshot)) in updates.iter().zip(expected).enumerate() {
268            if let Err(error) = self.replace_snapshot_if_current(update, expected_snapshot).await {
269                let rollback_error = self
270                    .rollback_snapshots(&applied, expected.get(..index).unwrap_or(&[]))
271                    .await
272                    .err();
273                return Err(rollback_error.unwrap_or(error));
274            }
275            applied.push(update.clone());
276        }
277        Ok(())
278    }
279
280    async fn apply_proposal_inner(&self, proposal_id: &str) -> Result<AppliedChange> {
281        let _mutation_guard = self.mutation_lock.lock().await;
282        let stored = {
283            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
284            state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
285        };
286
287        let after = stored
288            .proposal
289            .changes
290            .iter()
291            .map(|change| FileSnapshot {
292                path: change.path.clone(),
293                content: change.content.clone(),
294                digest: digest_text(&change.content),
295            })
296            .collect::<Vec<_>>();
297        self.apply_snapshots_if_current(&after, &stored.before).await?;
298
299        let change_id = Uuid::new_v4().simple().to_string();
300        let result = AppliedChange {
301            change_id: change_id.clone(),
302            paths: after.iter().map(|snapshot| snapshot.path.clone()).collect(),
303        };
304        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
305        if let Some(removed) = state.proposals.remove(proposal_id) {
306            state.proposal_bytes = state.proposal_bytes.saturating_sub(removed.size_bytes);
307        }
308        state.last_change = Some(StoredChange { change_id, before: stored.before, after });
309        Ok(result)
310    }
311
312    async fn revert_last_change_inner(&self, change_id: &str) -> Result<AppliedChange> {
313        let _mutation_guard = self.mutation_lock.lock().await;
314        let stored = {
315            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
316            state.last_change.clone().ok_or(WebmcpError::ChangeNotFound)?
317        };
318        if stored.change_id != change_id {
319            return Err(WebmcpError::ChangeNotFound);
320        }
321        self.apply_snapshots_if_current(&stored.before, &stored.after).await?;
322        let reverted = AppliedChange {
323            change_id: stored.change_id.clone(),
324            paths: stored.before.iter().map(|snapshot| snapshot.path.clone()).collect(),
325        };
326        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
327        state.last_change = None;
328        Ok(reverted)
329    }
330
331    async fn rollback_snapshots(&self, applied: &[FileSnapshot], originals: &[FileSnapshot]) -> Result<()> {
332        let mut failed = false;
333        for (applied_snapshot, original) in applied.iter().zip(originals).rev() {
334            if let Err(error) = self.replace_snapshot_if_current(original, applied_snapshot).await {
335                tracing::error!(
336                    path = %original.path,
337                    error = %error,
338                    "failed to roll back a partially applied WebMCP change"
339                );
340                failed = true;
341            }
342        }
343        if failed { Err(WebmcpError::PartialApply) } else { Ok(()) }
344    }
345
346    fn proposal_diff(before: &[FileSnapshot], changes: &[FileChange]) -> String {
347        let mut diff = String::new();
348        for (snapshot, change) in before.iter().zip(changes) {
349            let bundle = compute_diff(
350                &snapshot.content,
351                &change.content,
352                DiffOptions { context_lines: 3, ..DiffOptions::default() },
353                |hunks, _| format_unified_hunks(hunks),
354            );
355            if bundle.is_empty {
356                continue;
357            }
358            diff.push_str("--- a/");
359            diff.push_str(&snapshot.path);
360            diff.push('\n');
361            diff.push_str("+++ b/");
362            diff.push_str(&change.path);
363            diff.push('\n');
364            diff.push_str(&bundle.formatted);
365        }
366        diff
367    }
368}
369
370fn format_unified_hunks(hunks: &[DiffHunk]) -> String {
371    let mut output = String::new();
372    for hunk in hunks {
373        output.push_str("@@ -");
374        output.push_str(&format_diff_range(hunk.old_start, hunk.old_lines));
375        output.push_str(" +");
376        output.push_str(&format_diff_range(hunk.new_start, hunk.new_lines));
377        output.push_str(" @@\n");
378        for line in &hunk.lines {
379            let prefix = match line.kind {
380                DiffLineKind::Context => ' ',
381                DiffLineKind::Addition => '+',
382                DiffLineKind::Deletion => '-',
383            };
384            output.push(prefix);
385            let has_line_terminator = if let Some(content) = line.text.strip_suffix("\r\n") {
386                output.push_str(content);
387                output.push('\n');
388                true
389            } else if let Some(content) = line.text.strip_suffix('\n') {
390                output.push_str(content);
391                output.push('\n');
392                true
393            } else if let Some(content) = line.text.strip_suffix('\r') {
394                output.push_str(content);
395                output.push('\n');
396                true
397            } else {
398                output.push_str(&line.text);
399                output.push('\n');
400                false
401            };
402            if !has_line_terminator {
403                output.push_str(r"\ No newline at end of file");
404                output.push('\n');
405            }
406        }
407    }
408    output
409}
410
411fn format_diff_range(start: usize, count: usize) -> String {
412    if count == 0 {
413        return format!("{},0", start.saturating_sub(1));
414    }
415    if count == 1 {
416        return start.to_string();
417    }
418    format!("{start},{count}")
419}
420
421#[async_trait]
422impl RuntimeAdapter for FilesystemWorkspace {
423    async fn status(&self) -> Result<RuntimeStatus> {
424        Ok(RuntimeStatus {
425            workspace_root: self.root.display().to_string(),
426            connected: true,
427            turns_available: false,
428            mutations_allowed: self.mutations_allowed,
429            checks_allowed: self.checks_allowed,
430            approval_authority: if self.mutations_allowed {
431                "headless full-auto allowlist".into()
432            } else {
433                "headless policy (mutations disabled)".into()
434            },
435        })
436    }
437
438    async fn list_files(&self) -> Result<Vec<WorkspaceFile>> {
439        let root = self.root.clone();
440        let root_dir = self.root_dir.clone();
441        let limits = self.limits;
442        tokio::task::spawn_blocking(move || list_files_blocking(&root, &root_dir, limits))
443            .await
444            .map_err(|error| WebmcpError::Adapter(format!("file listing task failed: {error}")))?
445    }
446
447    async fn read_file(&self, path: &str) -> Result<FileSnapshot> {
448        self.read_snapshot(path).await
449    }
450
451    async fn propose_changes(&self, changes: Vec<FileChange>) -> Result<PatchProposal> {
452        let _mutation_guard = self.mutation_lock.lock().await;
453        if changes.is_empty() || changes.len() > self.limits.max_changes {
454            return Err(WebmcpError::LimitExceeded);
455        }
456        {
457            let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
458            if state.proposals.len() >= MAX_STORED_PROPOSALS {
459                return Err(WebmcpError::LimitExceeded);
460            }
461        }
462        let mut paths = HashSet::with_capacity(changes.len());
463        let mut before = Vec::with_capacity(changes.len());
464        let mut proposal_bytes = 0usize;
465        for change in &changes {
466            if change.content.len() > self.limits.max_change_bytes || !paths.insert(change.path.clone()) {
467                return Err(WebmcpError::LimitExceeded);
468            }
469            let snapshot = self.read_snapshot(&change.path).await?;
470            if snapshot.digest != change.base_digest {
471                return Err(WebmcpError::Conflict {
472                    path: change.path.clone(),
473                    expected: change.base_digest.clone(),
474                    actual: snapshot.digest,
475                });
476            }
477            proposal_bytes = proposal_bytes
478                .checked_add(snapshot.content.len())
479                .and_then(|bytes| bytes.checked_add(change.content.len()))
480                .ok_or(WebmcpError::LimitExceeded)?;
481            if proposal_bytes > self.limits.max_total_bytes {
482                return Err(WebmcpError::LimitExceeded);
483            }
484            before.push(snapshot);
485        }
486
487        let unified_diff = Self::proposal_diff(&before, &changes);
488        if unified_diff.len() > self.limits.max_total_bytes {
489            return Err(WebmcpError::LimitExceeded);
490        }
491        let stored_size_bytes = proposal_bytes
492            .checked_add(unified_diff.len())
493            .ok_or(WebmcpError::LimitExceeded)?;
494        let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
495        if state
496            .proposal_bytes
497            .checked_add(stored_size_bytes)
498            .is_none_or(|bytes| bytes > MAX_STORED_PROPOSAL_BYTES)
499        {
500            return Err(WebmcpError::LimitExceeded);
501        }
502        let proposal = PatchProposal {
503            proposal_id: Uuid::new_v4().simple().to_string(),
504            unified_diff,
505            changes,
506        };
507        state.proposal_bytes += stored_size_bytes;
508        drop(state.proposals.insert(
509            proposal.proposal_id.clone(),
510            StoredProposal {
511                proposal: proposal.clone(),
512                before,
513                size_bytes: stored_size_bytes,
514            },
515        ));
516        Ok(proposal)
517    }
518
519    async fn apply_proposal(&self, proposal_id: &str) -> Result<AppliedChange> {
520        if !self.mutations_allowed {
521            return Err(WebmcpError::ApprovalRequired);
522        }
523        let workspace = self.clone();
524        let proposal_id = proposal_id.to_string();
525        tokio::spawn(async move { workspace.apply_proposal_inner(&proposal_id).await })
526            .await
527            .map_err(|error| WebmcpError::Adapter(format!("WebMCP apply task failed: {error}")))?
528    }
529
530    async fn run_checks(&self, command: &str) -> Result<CheckResult> {
531        let args = parse_safe_command(command, &self.allowed_commands)?;
532        if !self.checks_allowed {
533            return Err(WebmcpError::ApprovalRequired);
534        }
535        let _mutation_guard = self.mutation_lock.lock().await;
536        let (program, arguments) = args
537            .split_first()
538            .ok_or_else(|| WebmcpError::InvalidRequest("check command cannot be empty".to_string()))?;
539        let executable = resolve_check_executable(program, self.root.as_ref())?;
540        // Capture host toolchain locations before replacing HOME with the
541        // workspace sandbox HOME. This keeps checks reproducible without
542        // allowing the child to inherit the caller's complete environment.
543        let host_home = std::env::var_os("HOME")
544            .or_else(|| std::env::var_os("USERPROFILE"))
545            .map(PathBuf::from);
546        let mut environment = SandboxEnvironment::new();
547        let _ = environment.insert("PATH".to_string(), trusted_executable_path(&executable)?);
548        let _ = environment.insert("HOME".to_string(), self.root.display().to_string());
549        let _ = environment.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
550        let _ = environment.insert("CARGO_TERM_COLOR".to_string(), "never".to_string());
551        let cargo_home = host_home
552            .as_ref()
553            .and_then(|home| trusted_toolchain_directory(&home.join(".cargo"), self.root.as_ref()));
554        if let Some(cargo_home) = cargo_home {
555            let _ = environment.insert("CARGO_HOME".to_string(), cargo_home.to_string_lossy().into_owned());
556        }
557        let rustup_home = host_home
558            .as_ref()
559            .and_then(|home| trusted_toolchain_directory(&home.join(".rustup"), self.root.as_ref()));
560        if let Some(rustup_home) = rustup_home {
561            let _ = environment.insert("RUSTUP_HOME".to_string(), rustup_home.to_string_lossy().into_owned());
562        }
563        let spec = CommandSpec::new(executable)
564            .with_args(arguments.iter().cloned())
565            .with_cwd(self.root.as_ref().to_path_buf())
566            .with_env(environment)
567            .with_expiration(ExecExpiration::Timeout(CHECK_TIMEOUT));
568        let linux_launcher = LinuxSandboxLauncher::resolve();
569        let check_policy = check_sandbox_policy(self.root.as_ref())
570            .map_err(|error| WebmcpError::Adapter(format!("failed to build WebMCP check sandbox: {error}")))?;
571        let exec_env = SandboxManager::new()
572            .transform(spec, &check_policy, self.root.as_ref(), linux_launcher.as_ref())
573            .map_err(|error| WebmcpError::Adapter(format!("failed to sandbox WebMCP check: {error}")))?;
574        let mut child = Command::new(exec_env.program)
575            .args(exec_env.args)
576            .current_dir(exec_env.cwd)
577            .env_clear()
578            .envs(exec_env.env)
579            .stdin(Stdio::null())
580            .stdout(Stdio::piped())
581            .stderr(Stdio::piped())
582            .kill_on_drop(true)
583            .spawn()?;
584        let stdout = child
585            .stdout
586            .take()
587            .ok_or_else(|| WebmcpError::Adapter("check process stdout was not captured".to_string()))?;
588        let stderr = child
589            .stderr
590            .take()
591            .ok_or_else(|| WebmcpError::Adapter("check process stderr was not captured".to_string()))?;
592        let output = Box::pin(tokio::time::timeout(CHECK_TIMEOUT, async {
593            let (status, stdout, stderr) =
594                tokio::join!(child.wait(), read_process_output(stdout), read_process_output(stderr));
595            Ok::<_, WebmcpError>((status?, stdout?, stderr?))
596        }))
597        .await;
598        let (status, stdout, stderr) = match output {
599            Ok(result) => result?,
600            Err(_elapsed) => {
601                drop(child.kill().await);
602                drop(child.wait().await);
603                return Err(WebmcpError::Timeout(CHECK_TIMEOUT));
604            }
605        };
606        Ok(CheckResult {
607            command: command.to_string(),
608            exit_code: status.code(),
609            stdout: String::from_utf8_lossy(&stdout).into_owned(),
610            stderr: String::from_utf8_lossy(&stderr).into_owned(),
611        })
612    }
613
614    async fn revert_last_change(&self, change_id: &str) -> Result<AppliedChange> {
615        if !self.mutations_allowed {
616            return Err(WebmcpError::ApprovalRequired);
617        }
618        let workspace = self.clone();
619        let change_id = change_id.to_string();
620        tokio::spawn(async move { workspace.revert_last_change_inner(&change_id).await })
621            .await
622            .map_err(|error| WebmcpError::Adapter(format!("WebMCP revert task failed: {error}")))?
623    }
624
625    async fn request_turn(&self, prompt: &str, _proposal_id: Option<&str>) -> Result<TurnResult> {
626        if prompt.trim().is_empty() {
627            return Err(WebmcpError::InvalidRequest("agent turn prompt cannot be empty".to_string()));
628        }
629        if prompt.len() > 16 * 1024 {
630            return Err(WebmcpError::LimitExceeded);
631        }
632        Err(WebmcpError::Unsupported(
633            "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(),
634        ))
635    }
636}
637
638fn validate_relative_path(path: &str) -> Result<PathBuf> {
639    if path.is_empty() || path.len() > 4096 || path.contains('\0') {
640        return Err(WebmcpError::PathRejected(path.to_string()));
641    }
642    let path = Path::new(path);
643    if path.is_absolute() {
644        return Err(WebmcpError::PathRejected(path.display().to_string()));
645    }
646    let mut validated = PathBuf::new();
647    for component in path.components() {
648        match component {
649            Component::Normal(part) => validated.push(part),
650            Component::CurDir => {}
651            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
652                return Err(WebmcpError::PathRejected(path.display().to_string()));
653            }
654        }
655    }
656    if validated.as_os_str().is_empty() {
657        return Err(WebmcpError::PathRejected(path.display().to_string()));
658    }
659    Ok(validated)
660}
661
662fn reject_sensitive_relative_path(relative: &Path, original: &str) -> Result<()> {
663    if is_sensitive_relative_path(relative) {
664        return Err(WebmcpError::PathRejected(format!("sensitive workspace path is not exposed: {original}")));
665    }
666    Ok(())
667}
668
669fn is_sensitive_relative_path(relative: &Path) -> bool {
670    relative.components().any(|component| {
671        let Component::Normal(name) = component else {
672            return false;
673        };
674        let Some(name) = name.to_str() else {
675            return true;
676        };
677        is_sensitive_file(name)
678            || SENSITIVE_DIRECTORY_NAMES
679                .iter()
680                .any(|sensitive| name.eq_ignore_ascii_case(sensitive))
681    })
682}
683
684#[cfg(unix)]
685fn has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
686    use std::os::unix::fs::MetadataExt;
687
688    metadata.nlink() > 1
689}
690
691#[cfg(not(unix))]
692const fn has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
693    false
694}
695
696fn reject_hard_links(metadata: &std::fs::Metadata, path: &str) -> Result<()> {
697    if has_multiple_hard_links(metadata) {
698        return Err(WebmcpError::PathRejected(format!("hard-linked file is not allowed: {path}")));
699    }
700    Ok(())
701}
702
703fn digest_text(content: &str) -> String {
704    let digest = Sha256::digest(content.as_bytes());
705    let mut encoded = String::with_capacity(7 + digest.len() * 2);
706    encoded.push_str("sha256:");
707    for byte in digest {
708        encoded.push_str(&format!("{byte:02x}"));
709    }
710    encoded
711}
712
713#[cfg(all(unix, not(any(target_os = "redox", target_os = "solaris"))))]
714fn open_root_directory(path: &Path) -> std::io::Result<std::fs::File> {
715    use nix::fcntl::{OFlag, openat};
716    use nix::sys::stat::Mode;
717    use std::os::unix::fs::OpenOptionsExt;
718
719    let mut current = std::fs::OpenOptions::new()
720        .read(true)
721        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
722        .open("/")?;
723    for component in path.components() {
724        let Component::Normal(name) = component else {
725            if matches!(component, Component::RootDir | Component::CurDir) {
726                continue;
727            }
728            return Err(std::io::Error::new(
729                std::io::ErrorKind::InvalidInput,
730                "canonical workspace root contains an unsupported path component",
731            ));
732        };
733        let directory = openat(
734            &current,
735            name,
736            OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
737            Mode::empty(),
738        )
739        .map_err(std::io::Error::from)?;
740        current = std::fs::File::from(directory);
741    }
742    Ok(current)
743}
744
745#[cfg(not(all(unix, not(any(target_os = "redox", target_os = "solaris")))))]
746fn open_root_directory(_path: &Path) -> Result<std::fs::File> {
747    Err(WebmcpError::Unsupported(
748        "WebMCP filesystem access requires directory-handle file operations on this platform".to_string(),
749    ))
750}
751
752fn read_snapshot_blocking(
753    root: &Path,
754    root_dir: &std::fs::File,
755    path: &str,
756    limits: FilesystemLimits,
757) -> Result<FileSnapshot> {
758    let relative = validate_relative_path(path)?;
759    reject_sensitive_relative_path(&relative, path)?;
760    let file = open_workspace_file(root, root_dir, &relative, false)?;
761    let metadata = file.metadata()?;
762    if !metadata.is_file() {
763        return Err(WebmcpError::PathRejected(path.to_string()));
764    }
765    reject_hard_links(&metadata, path)?;
766    let max_file_bytes = u64::try_from(limits.max_file_bytes).unwrap_or(u64::MAX);
767    if metadata.len() > max_file_bytes {
768        return Err(WebmcpError::LimitExceeded);
769    }
770    let mut file = file;
771    let content = read_bounded_content(&mut file, limits.max_file_bytes)?;
772    let digest = digest_text(&content);
773    Ok(FileSnapshot { path: path.to_string(), content, digest })
774}
775
776fn read_bounded_content<R>(reader: &mut R, max_file_bytes: usize) -> Result<String>
777where
778    R: Read,
779{
780    let max_file_bytes_u64 = u64::try_from(max_file_bytes).unwrap_or(u64::MAX);
781    let mut bytes = Vec::with_capacity(max_file_bytes.min(64 * 1024));
782    let _bytes_read = reader.take(max_file_bytes_u64.saturating_add(1)).read_to_end(&mut bytes)?;
783    if bytes.len() > max_file_bytes {
784        return Err(WebmcpError::LimitExceeded);
785    }
786    String::from_utf8(bytes).map_err(|_error| WebmcpError::Adapter("file is not valid UTF-8".to_string()))
787}
788
789#[cfg(all(unix, not(any(target_os = "redox", target_os = "solaris"))))]
790fn open_workspace_file(_root: &Path, root_dir: &std::fs::File, relative: &Path, write: bool) -> Result<std::fs::File> {
791    use nix::fcntl::{OFlag, openat};
792    use nix::sys::stat::Mode;
793
794    let (parent, name) = open_parent_directory(root_dir, relative)?;
795    let access = if write { OFlag::O_RDWR } else { OFlag::O_RDONLY };
796    let file = openat(&parent, name.as_os_str(), access | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, Mode::empty())
797        .map_err(|error| map_secure_open_error(relative, error))?;
798    Ok(std::fs::File::from(file))
799}
800
801#[cfg(all(unix, not(any(target_os = "redox", target_os = "solaris"))))]
802fn open_parent_directory(root_dir: &std::fs::File, relative: &Path) -> Result<(std::fs::File, std::ffi::OsString)> {
803    use nix::fcntl::{OFlag, openat};
804    use nix::sys::stat::Mode;
805
806    let mut components = relative.components();
807    let Some(Component::Normal(name)) = components.next_back() else {
808        return Err(WebmcpError::PathRejected(relative.display().to_string()));
809    };
810    let mut current = root_dir.try_clone()?;
811    for component in components {
812        let Component::Normal(name) = component else {
813            return Err(WebmcpError::PathRejected(relative.display().to_string()));
814        };
815        let directory = openat(
816            &current,
817            name,
818            OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW,
819            Mode::empty(),
820        )
821        .map_err(|error| map_secure_open_error(relative, error))?;
822        current = std::fs::File::from(directory);
823    }
824    Ok((current, name.to_os_string()))
825}
826
827#[cfg(all(unix, not(any(target_os = "redox", target_os = "solaris"))))]
828fn map_secure_open_error(relative: &Path, error: nix::errno::Errno) -> WebmcpError {
829    if matches!(error, nix::errno::Errno::ELOOP | nix::errno::Errno::ENOTDIR) {
830        WebmcpError::PathRejected(format!("symlink path is not allowed: {}", relative.display()))
831    } else {
832        WebmcpError::Io(error.into())
833    }
834}
835
836#[cfg(not(all(unix, not(any(target_os = "redox", target_os = "solaris")))))]
837fn open_workspace_file(
838    _root: &Path,
839    _root_dir: &std::fs::File,
840    _relative: &Path,
841    _write: bool,
842) -> Result<std::fs::File> {
843    Err(WebmcpError::Unsupported(
844        "WebMCP filesystem access requires directory-handle file operations on this platform".to_string(),
845    ))
846}
847
848fn replace_snapshot_if_current_blocking(
849    root: &Path,
850    root_dir: &std::fs::File,
851    update: &FileSnapshot,
852    expected: &FileSnapshot,
853) -> Result<()> {
854    let relative = validate_relative_path(&expected.path)?;
855    reject_sensitive_relative_path(&relative, &expected.path)?;
856    if update.path != expected.path {
857        return Err(WebmcpError::Adapter("compare-and-replace paths do not match".to_string()));
858    }
859    if digest_text(&update.content) != update.digest {
860        return Err(WebmcpError::Adapter("snapshot digest does not match its content".to_string()));
861    }
862    let file = open_workspace_file(root, root_dir, &relative, true)?;
863    let metadata = file.metadata()?;
864    if !metadata.is_file() {
865        return Err(WebmcpError::PathRejected(expected.path.clone()));
866    }
867    reject_hard_links(&metadata, &expected.path)?;
868    replace_open_file(file, update, expected)
869}
870
871#[cfg(all(unix, not(any(target_os = "redox", target_os = "solaris"))))]
872fn replace_open_file(file: std::fs::File, update: &FileSnapshot, expected: &FileSnapshot) -> Result<()> {
873    use nix::fcntl::{Flock, FlockArg};
874
875    let mut file =
876        Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, error)| WebmcpError::Io(error.into()))?;
877    let metadata_before = file.metadata()?;
878    if !metadata_before.is_file() {
879        return Err(WebmcpError::PathRejected(expected.path.clone()));
880    }
881    reject_hard_links(&metadata_before, &expected.path)?;
882    let expected_size = u64::try_from(expected.content.len()).unwrap_or(u64::MAX);
883    if metadata_before.len() > expected_size {
884        return Err(WebmcpError::Conflict {
885            path: expected.path.clone(),
886            expected: expected.digest.clone(),
887            actual: format!("size:{}", metadata_before.len()),
888        });
889    }
890    let _ = file.seek(SeekFrom::Start(0))?;
891    let current = match read_bounded_content(&mut *file, expected.content.len()) {
892        Ok(current) => current,
893        Err(WebmcpError::LimitExceeded) => {
894            return Err(WebmcpError::Conflict {
895                path: expected.path.clone(),
896                expected: expected.digest.clone(),
897                actual: format!("size:>{expected_size}"),
898            });
899        }
900        Err(error) => return Err(error),
901    };
902    let metadata_after_read = file.metadata()?;
903    if metadata_after_read.len() > expected_size || metadata_after_read.len() != metadata_before.len() {
904        return Err(WebmcpError::Conflict {
905            path: expected.path.clone(),
906            expected: expected.digest.clone(),
907            actual: format!("size:{}", metadata_after_read.len()),
908        });
909    }
910    if metadata_after_read.modified().ok() != metadata_before.modified().ok() {
911        return Err(WebmcpError::Conflict {
912            path: expected.path.clone(),
913            expected: expected.digest.clone(),
914            actual: "metadata-changed".to_string(),
915        });
916    }
917    let actual = digest_text(&current);
918    if actual != expected.digest {
919        return Err(WebmcpError::Conflict {
920            path: expected.path.clone(),
921            expected: expected.digest.clone(),
922            actual,
923        });
924    }
925    let _ = file.seek(SeekFrom::Start(0))?;
926    file.set_len(0)?;
927    file.write_all(update.content.as_bytes())?;
928    file.sync_all()?;
929    Ok(())
930}
931
932#[cfg(not(all(unix, not(any(target_os = "redox", target_os = "solaris")))))]
933fn replace_open_file(_file: std::fs::File, _update: &FileSnapshot, _expected: &FileSnapshot) -> Result<()> {
934    Err(WebmcpError::Unsupported(
935        "WebMCP compare-and-replace requires a platform with directory-handle file operations".to_string(),
936    ))
937}
938
939fn list_files_blocking(root: &Path, root_dir: &std::fs::File, limits: FilesystemLimits) -> Result<Vec<WorkspaceFile>> {
940    let mut files = Vec::new();
941    let mut total_bytes = 0usize;
942    let mut visited_directories = 0usize;
943    visit_directory(root, root_dir, root, 0, limits, &mut total_bytes, &mut visited_directories, &mut files)?;
944    files.sort_by(|left, right| left.path.cmp(&right.path));
945    Ok(files)
946}
947
948fn visit_directory(
949    root: &Path,
950    root_dir: &std::fs::File,
951    directory: &Path,
952    depth: usize,
953    limits: FilesystemLimits,
954    total_bytes: &mut usize,
955    visited_directories: &mut usize,
956    files: &mut Vec<WorkspaceFile>,
957) -> Result<()> {
958    if depth > MAX_DIRECTORY_DEPTH {
959        return Err(WebmcpError::LimitExceeded);
960    }
961    *visited_directories = visited_directories.saturating_add(1);
962    if *visited_directories > MAX_VISITED_DIRECTORIES {
963        return Err(WebmcpError::LimitExceeded);
964    }
965    let entries = std::fs::read_dir(directory)?;
966    for entry in entries {
967        if files.len() >= limits.max_files {
968            return Err(WebmcpError::LimitExceeded);
969        }
970        let entry = entry?;
971        let path = entry.path();
972        let relative_path = path
973            .strip_prefix(root)
974            .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
975        if is_sensitive_relative_path(relative_path) {
976            continue;
977        }
978        let metadata = std::fs::symlink_metadata(&path)?;
979        if metadata.file_type().is_symlink() {
980            continue;
981        }
982        if metadata.is_dir() {
983            if entry
984                .file_name()
985                .to_str()
986                .is_some_and(|name| IGNORED_DIRECTORY_NAMES.contains(&name))
987            {
988                continue;
989            }
990            visit_directory(root, root_dir, &path, depth + 1, limits, total_bytes, visited_directories, files)?;
991            continue;
992        }
993        if !metadata.is_file() {
994            continue;
995        }
996        if has_multiple_hard_links(&metadata) {
997            continue;
998        }
999        let max_file_bytes = u64::try_from(limits.max_file_bytes).unwrap_or(u64::MAX);
1000        if metadata.len() > max_file_bytes {
1001            continue;
1002        }
1003        let file = open_workspace_file(root, root_dir, relative_path, false)?;
1004        let opened_metadata = file.metadata()?;
1005        if !opened_metadata.is_file()
1006            || has_multiple_hard_links(&opened_metadata)
1007            || opened_metadata.len() > max_file_bytes
1008        {
1009            continue;
1010        }
1011        let mut reader = file.take(max_file_bytes.saturating_add(1));
1012        let mut bytes = Vec::with_capacity(limits.max_file_bytes.min(64 * 1024));
1013        let _bytes_read = reader.read_to_end(&mut bytes)?;
1014        if bytes.len() > limits.max_file_bytes {
1015            continue;
1016        }
1017        let Ok(content) = String::from_utf8(bytes) else {
1018            continue;
1019        };
1020        *total_bytes = total_bytes.saturating_add(content.len());
1021        if *total_bytes > limits.max_total_bytes {
1022            return Err(WebmcpError::LimitExceeded);
1023        }
1024        let relative = relative_path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
1025        files.push(WorkspaceFile {
1026            path: relative,
1027            size_bytes: content.len() as u64,
1028            digest: digest_text(&content),
1029        });
1030    }
1031    Ok(())
1032}
1033
1034fn trusted_toolchain_directory(path: &Path, workspace: &Path) -> Option<PathBuf> {
1035    let canonical = vtcode_commons::canonicalize(path).ok()?;
1036    (canonical.is_dir() && !canonical.starts_with(workspace)).then_some(canonical)
1037}
1038
1039fn resolve_check_executable(program: &str, workspace: &Path) -> Result<PathBuf> {
1040    let executable_name = if cfg!(windows) {
1041        format!("{program}.exe")
1042    } else {
1043        program.to_string()
1044    };
1045    let mut candidates = Vec::new();
1046
1047    #[cfg(unix)]
1048    {
1049        if program == "cargo" {
1050            if let Some(home) = std::env::var_os("HOME") {
1051                candidates.push(PathBuf::from(home).join(".cargo/bin").join(&executable_name));
1052            }
1053        }
1054        for directory in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"] {
1055            candidates.push(PathBuf::from(directory).join(&executable_name));
1056        }
1057    }
1058
1059    #[cfg(windows)]
1060    {
1061        if program == "cargo"
1062            && let Some(profile) = std::env::var_os("USERPROFILE")
1063        {
1064            candidates.push(PathBuf::from(profile).join(".cargo/bin").join(&executable_name));
1065        }
1066        if let Some(system_root) = std::env::var_os("SystemRoot") {
1067            candidates.push(PathBuf::from(system_root).join("System32").join(&executable_name));
1068        }
1069    }
1070
1071    for candidate in candidates {
1072        let Ok(canonical) = vtcode_commons::canonicalize(&candidate) else {
1073            continue;
1074        };
1075        if canonical.starts_with(workspace) {
1076            continue;
1077        }
1078        let Ok(metadata) = std::fs::metadata(&canonical) else {
1079            continue;
1080        };
1081        if metadata.is_file() && is_executable_file(&metadata) {
1082            return Ok(canonical);
1083        }
1084    }
1085
1086    Err(WebmcpError::InvalidRequest(format!(
1087        "allowlisted check executable is not installed in a trusted location: {program}"
1088    )))
1089}
1090
1091fn is_executable_file(metadata: &std::fs::Metadata) -> bool {
1092    #[cfg(unix)]
1093    {
1094        use std::os::unix::fs::PermissionsExt;
1095
1096        metadata.permissions().mode() & 0o111 != 0
1097    }
1098    #[cfg(not(unix))]
1099    {
1100        metadata.is_file()
1101    }
1102}
1103
1104fn trusted_executable_path(executable: &Path) -> Result<String> {
1105    let mut directories = Vec::new();
1106    if let Some(parent) = executable.parent() {
1107        directories.push(parent.to_path_buf());
1108    }
1109    #[cfg(unix)]
1110    directories.extend([
1111        PathBuf::from("/usr/local/bin"),
1112        PathBuf::from("/opt/homebrew/bin"),
1113        PathBuf::from("/usr/bin"),
1114        PathBuf::from("/bin"),
1115    ]);
1116    #[cfg(windows)]
1117    if let Some(system_root) = std::env::var_os("SystemRoot") {
1118        directories.push(PathBuf::from(system_root).join("System32"));
1119    }
1120    directories.dedup();
1121    std::env::join_paths(directories)
1122        .map(|path| path.to_string_lossy().into_owned())
1123        .map_err(|error| WebmcpError::Adapter(format!("failed to build trusted check PATH: {error}")))
1124}
1125
1126fn sensitive_path_for_policy(path: &Path) -> Result<SensitivePath> {
1127    let path_string = path.to_str().ok_or_else(|| {
1128        WebmcpError::Adapter(format!("cannot sandbox a non-UTF-8 sensitive path: {}", path.display()))
1129    })?;
1130    if path_string
1131        .chars()
1132        .any(|character| character == '"' || character == '\\' || character.is_control())
1133    {
1134        return Err(WebmcpError::Adapter(format!(
1135            "cannot sandbox a sensitive path containing an unsafe character: {path_string}"
1136        )));
1137    }
1138    Ok(SensitivePath::new(path_string))
1139}
1140
1141fn collect_workspace_sensitive_paths(root: &Path) -> Result<Vec<SensitivePath>> {
1142    let mut sensitive_paths = Vec::new();
1143    let mut pending = vec![(root.to_path_buf(), 0usize)];
1144    let mut visited_directories = 0usize;
1145
1146    while let Some((directory, depth)) = pending.pop() {
1147        if depth > MAX_DIRECTORY_DEPTH {
1148            return Err(WebmcpError::LimitExceeded);
1149        }
1150        visited_directories = visited_directories.saturating_add(1);
1151        if visited_directories > MAX_VISITED_DIRECTORIES {
1152            return Err(WebmcpError::LimitExceeded);
1153        }
1154
1155        for entry in std::fs::read_dir(&directory)? {
1156            let entry = entry?;
1157            let path = entry.path();
1158            let relative_path = path
1159                .strip_prefix(root)
1160                .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
1161            if is_sensitive_relative_path(relative_path) {
1162                if sensitive_paths.len() >= MAX_CHECK_SENSITIVE_PATHS {
1163                    return Err(WebmcpError::LimitExceeded);
1164                }
1165                sensitive_paths.push(sensitive_path_for_policy(&path)?);
1166                continue;
1167            }
1168
1169            let metadata = std::fs::symlink_metadata(&path)?;
1170            if metadata.file_type().is_symlink() {
1171                continue;
1172            }
1173            if metadata.is_dir() {
1174                pending.push((path, depth + 1));
1175            }
1176        }
1177    }
1178
1179    Ok(sensitive_paths)
1180}
1181
1182fn check_sandbox_policy(root: &Path) -> Result<SandboxPolicy> {
1183    let mut sensitive_paths = default_sensitive_paths();
1184    for name in SENSITIVE_FILES.iter().copied().chain(SENSITIVE_DIRECTORY_NAMES.iter().copied()) {
1185        sensitive_paths.push(sensitive_path_for_policy(&root.join(name))?);
1186    }
1187    sensitive_paths.extend(collect_workspace_sensitive_paths(root)?);
1188    Ok(SandboxPolicy::workspace_write_with_sensitive_paths(vec![root.to_path_buf()], sensitive_paths))
1189}
1190
1191fn parse_safe_command(command: &str, allowed_commands: &HashSet<String>) -> Result<Vec<String>> {
1192    if command.len() > 512
1193        || command
1194            .chars()
1195            .any(|character| matches!(character, ';' | '|' | '&' | '$' | '`' | '>' | '<' | '\n' | '\r'))
1196    {
1197        return Err(WebmcpError::InvalidRequest("check command contains shell syntax".to_string()));
1198    }
1199    let args = shell_words::split(command)
1200        .map_err(|error| WebmcpError::InvalidRequest(format!("invalid check command: {error}")))?;
1201    let Some(program) = args.first() else {
1202        return Err(WebmcpError::InvalidRequest("check command cannot be empty".to_string()));
1203    };
1204    if Path::new(program).components().count() != 1 || !allowed_commands.contains(program) {
1205        return Err(WebmcpError::InvalidRequest("check executable is not allowlisted".to_string()));
1206    }
1207    if args.iter().any(|argument| argument.contains('\0')) {
1208        return Err(WebmcpError::InvalidRequest("check command contains an invalid argument".to_string()));
1209    }
1210    match program.as_str() {
1211        "cargo"
1212            if args.get(1).is_some_and(|subcommand| subcommand == "check")
1213                && args.iter().skip(2).all(|argument| {
1214                    matches!(
1215                        argument.as_str(),
1216                        "--locked" | "--offline" | "--workspace" | "--all-targets" | "--all-features"
1217                    )
1218                }) =>
1219        {
1220            Ok(args)
1221        }
1222        "printf" => Ok(args),
1223        _ => Err(WebmcpError::InvalidRequest(
1224            "only the bounded cargo check and printf commands are supported".to_string(),
1225        )),
1226    }
1227}
1228
1229async fn read_process_output<R>(mut reader: R) -> Result<Vec<u8>>
1230where
1231    R: AsyncRead + Unpin,
1232{
1233    let mut captured = Vec::with_capacity(MAX_CHECK_OUTPUT_BYTES.min(8192));
1234    let mut buffer = [0u8; 8192];
1235    let mut exceeded = false;
1236    loop {
1237        let bytes_read = reader.read(&mut buffer).await?;
1238        if bytes_read == 0 {
1239            break;
1240        }
1241        let remaining = MAX_CHECK_OUTPUT_BYTES.saturating_sub(captured.len());
1242        exceeded |= bytes_read > remaining;
1243        if remaining > 0 {
1244            let chunk = buffer
1245                .get(..bytes_read.min(remaining))
1246                .ok_or_else(|| WebmcpError::Adapter("check output read exceeded its buffer".to_string()))?;
1247            captured.extend_from_slice(chunk);
1248        }
1249    }
1250    if exceeded {
1251        Err(WebmcpError::LimitExceeded)
1252    } else {
1253        Ok(captured)
1254    }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    use super::*;
1260    use tempfile::TempDir;
1261    use tokio::io::{AsyncWriteExt, duplex};
1262
1263    #[cfg(unix)]
1264    use std::os::unix::fs::symlink;
1265    #[cfg(windows)]
1266    use std::os::windows::fs::symlink_file as symlink;
1267
1268    async fn workspace(mutations_allowed: bool) -> (TempDir, FilesystemWorkspace) {
1269        let temp = TempDir::new().expect("temp dir");
1270        tokio::fs::write(temp.path().join("main.js"), "console.log('old');\n")
1271            .await
1272            .expect("seed");
1273        let adapter = FilesystemWorkspace::new(temp.path(), [], mutations_allowed)
1274            .await
1275            .expect("adapter");
1276        (temp, adapter)
1277    }
1278
1279    #[tokio::test]
1280    async fn proposal_apply_check_and_revert_validate_current_digests() {
1281        let (_temp, adapter) = workspace(true).await;
1282        let adapter = adapter.with_allowed_commands(["cargo", "printf"]);
1283        let snapshot = adapter.read_file("main.js").await.expect("read");
1284        let proposal = adapter
1285            .propose_changes(vec![FileChange {
1286                path: "main.js".to_string(),
1287                base_digest: snapshot.digest,
1288                content: "console.log('new');\n".to_string(),
1289            }])
1290            .await
1291            .expect("propose");
1292        let applied = adapter.apply_proposal(&proposal.proposal_id).await.expect("apply");
1293        let result = adapter.run_checks("printf ok").await.expect("check");
1294        assert!(
1295            result.exit_code == Some(0)
1296                || (result.exit_code == Some(71) && result.stderr.contains("sandbox_apply: Operation not permitted")),
1297            "unexpected check result: {result:?}"
1298        );
1299        assert_eq!(adapter.read_file("main.js").await.expect("read").content, "console.log('new');\n");
1300        let _ = adapter.revert_last_change(&applied.change_id).await.expect("revert");
1301        assert_eq!(adapter.read_file("main.js").await.expect("read").content, "console.log('old');\n");
1302    }
1303
1304    #[test]
1305    fn proposal_diff_contains_context_and_correct_file_ranges() {
1306        let before = FileSnapshot {
1307            path: "src/main.js".to_string(),
1308            content: (1..=12).map(|line| format!("line-{line}\n")).collect(),
1309            digest: String::new(),
1310        };
1311        let after = (1..=12)
1312            .map(|line| match line {
1313                2 => "changed-2\n".to_string(),
1314                10 => "changed-10\n".to_string(),
1315                _ => format!("line-{line}\n"),
1316            })
1317            .collect::<String>();
1318        let diff = FilesystemWorkspace::proposal_diff(
1319            &[before],
1320            &[FileChange {
1321                path: "src/main.js".to_string(),
1322                base_digest: String::new(),
1323                content: after,
1324            }],
1325        );
1326
1327        assert_eq!(
1328            diff,
1329            "--- a/src/main.js\n+++ b/src/main.js\n@@ -1,5 +1,5 @@\n line-1\n-line-2\n+changed-2\n line-3\n line-4\n line-5\n@@ -7,6 +7,6 @@\n line-7\n line-8\n line-9\n-line-10\n+changed-10\n line-11\n line-12\n"
1330        );
1331    }
1332
1333    #[test]
1334    fn proposal_diff_handles_empty_files_and_missing_final_newlines() {
1335        let empty_before = FileSnapshot {
1336            path: "new.txt".to_string(),
1337            content: String::new(),
1338            digest: String::new(),
1339        };
1340        let empty_diff = FilesystemWorkspace::proposal_diff(
1341            &[empty_before],
1342            &[FileChange {
1343                path: "new.txt".to_string(),
1344                base_digest: String::new(),
1345                content: "first\nsecond\n".to_string(),
1346            }],
1347        );
1348        assert_eq!(empty_diff, "--- a/new.txt\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+first\n+second\n");
1349
1350        let no_newline_before = FileSnapshot {
1351            path: "line.txt".to_string(),
1352            content: "old".to_string(),
1353            digest: String::new(),
1354        };
1355        let no_newline_diff = FilesystemWorkspace::proposal_diff(
1356            &[no_newline_before],
1357            &[FileChange {
1358                path: "line.txt".to_string(),
1359                base_digest: String::new(),
1360                content: "new".to_string(),
1361            }],
1362        );
1363        assert_eq!(
1364            no_newline_diff,
1365            "--- a/line.txt\n+++ b/line.txt\n@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n"
1366        );
1367
1368        let cr_diff = FilesystemWorkspace::proposal_diff(
1369            &[FileSnapshot {
1370                path: "cr.txt".to_string(),
1371                content: "one\rtwo\r".to_string(),
1372                digest: String::new(),
1373            }],
1374            &[FileChange {
1375                path: "cr.txt".to_string(),
1376                base_digest: String::new(),
1377                content: "one\rchanged\r".to_string(),
1378            }],
1379        );
1380        assert_eq!(cr_diff, "--- a/cr.txt\n+++ b/cr.txt\n@@ -1,2 +1,2 @@\n one\n-two\n+changed\n");
1381    }
1382
1383    #[tokio::test]
1384    async fn proposal_for_turn_rechecks_the_staged_snapshot() {
1385        let (temp, adapter) = workspace(false).await;
1386        let snapshot = adapter.read_file("main.js").await.expect("snapshot");
1387        let proposal = adapter
1388            .propose_changes(vec![FileChange {
1389                path: "main.js".to_string(),
1390                base_digest: snapshot.digest,
1391                content: "console.log('new');\n".to_string(),
1392            }])
1393            .await
1394            .expect("proposal");
1395
1396        let handed_off = adapter
1397            .proposal_for_turn(&proposal.proposal_id)
1398            .await
1399            .expect("current proposal");
1400        assert_eq!(handed_off.unified_diff, proposal.unified_diff);
1401
1402        tokio::fs::write(temp.path().join("main.js"), "external\n")
1403            .await
1404            .expect("external edit");
1405        assert!(matches!(
1406            adapter.proposal_for_turn(&proposal.proposal_id).await,
1407            Err(WebmcpError::Conflict { path, .. }) if path == "main.js"
1408        ));
1409    }
1410
1411    #[tokio::test]
1412    async fn headless_adapter_does_not_fake_agent_turns() {
1413        let (_temp, adapter) = workspace(false).await;
1414        let status = adapter.status().await.expect("status");
1415        assert!(!status.turns_available);
1416        assert!(matches!(
1417            adapter.request_turn("review the draft", None).await,
1418            Err(WebmcpError::Unsupported(message)) if message.contains("active VT Code runtime")
1419        ));
1420    }
1421
1422    #[tokio::test]
1423    async fn listing_skips_dependency_and_generated_directories() {
1424        let temp = TempDir::new().expect("temp dir");
1425        tokio::fs::write(temp.path().join("visible.txt"), "visible")
1426            .await
1427            .expect("visible file");
1428        tokio::fs::write(temp.path().join(".env"), "TOKEN=secret")
1429            .await
1430            .expect("dotenv file");
1431        tokio::fs::write(temp.path().join(".npmrc"), "//registry.example/:_authToken=secret")
1432            .await
1433            .expect("credential file");
1434        tokio::fs::create_dir_all(temp.path().join(".ssh"))
1435            .await
1436            .expect("credential directory");
1437        tokio::fs::write(temp.path().join(".ssh/id_ed25519"), "private key")
1438            .await
1439            .expect("private key");
1440        tokio::fs::create_dir_all(temp.path().join(".env.secrets"))
1441            .await
1442            .expect("dotenv directory");
1443        tokio::fs::write(temp.path().join(".env.secrets/token.txt"), "secret")
1444            .await
1445            .expect("dotenv secret");
1446        for directory in ["node_modules", "target", ".git", ".worktrees", "dist"] {
1447            let directory = temp.path().join(directory);
1448            tokio::fs::create_dir_all(&directory).await.expect("directory");
1449            tokio::fs::write(directory.join("hidden.txt"), "hidden")
1450                .await
1451                .expect("hidden file");
1452        }
1453
1454        let adapter = FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter");
1455        let files = adapter.list_files().await.expect("list files");
1456        assert_eq!(files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(), vec!["visible.txt"]);
1457        assert!(matches!(adapter.read_file(".env").await, Err(WebmcpError::PathRejected(_))));
1458        assert!(matches!(adapter.read_file(".npmrc").await, Err(WebmcpError::PathRejected(_))));
1459        assert!(matches!(adapter.read_file(".ssh/id_ed25519").await, Err(WebmcpError::PathRejected(_))));
1460        assert!(matches!(adapter.read_file(".env.secrets/token.txt").await, Err(WebmcpError::PathRejected(_))));
1461    }
1462
1463    #[test]
1464    fn webmcp_check_sandbox_blocks_case_variants_of_sensitive_files() {
1465        let temp = TempDir::new().expect("workspace");
1466        for name in ["ID_ECDSA", ".ENV", "credentials.JSON"] {
1467            std::fs::write(temp.path().join(name), "secret").expect("sensitive file");
1468        }
1469        let nested = temp.path().join("project");
1470        std::fs::create_dir_all(nested.join(".ssh")).expect("nested credential directory");
1471        std::fs::write(nested.join(".ssh/id_ed25519"), "private key").expect("nested private key");
1472        std::fs::write(nested.join(".env"), "TOKEN=secret").expect("nested dotenv file");
1473
1474        let policy = check_sandbox_policy(temp.path()).expect("sandbox policy");
1475
1476        for path in [
1477            temp.path().join("ID_ECDSA"),
1478            temp.path().join(".ENV"),
1479            temp.path().join("credentials.JSON"),
1480            nested.join(".ssh/id_ed25519"),
1481            nested.join(".env"),
1482        ] {
1483            assert!(!policy.is_path_readable(&path), "check sandbox read allowed: {path:?}");
1484            assert!(!policy.is_path_writable(&path, temp.path()), "check sandbox write allowed: {path:?}");
1485        }
1486    }
1487
1488    #[cfg(any(unix, windows))]
1489    #[tokio::test]
1490    async fn stale_and_traversal_requests_fail_closed() {
1491        let (temp, adapter) = workspace(false).await;
1492        let stale = FileChange {
1493            path: "main.js".to_string(),
1494            base_digest: "sha256:stale".to_string(),
1495            content: "new".to_string(),
1496        };
1497        assert!(matches!(adapter.propose_changes(vec![stale]).await, Err(WebmcpError::Conflict { .. })));
1498        assert!(matches!(adapter.read_file("../outside").await, Err(WebmcpError::PathRejected(_))));
1499        assert!(matches!(
1500            adapter.run_checks("printf safe; echo injected").await,
1501            Err(WebmcpError::InvalidRequest(_))
1502        ));
1503        assert!(matches!(adapter.run_checks("printf safe").await, Err(WebmcpError::InvalidRequest(_))));
1504        assert!(matches!(adapter.run_checks("cargo run").await, Err(WebmcpError::InvalidRequest(_))));
1505        assert!(matches!(adapter.run_checks("npm run check").await, Err(WebmcpError::InvalidRequest(_))));
1506        assert!(matches!(adapter.run_checks("python3 -c 'print(1)'").await, Err(WebmcpError::InvalidRequest(_))));
1507        assert!(matches!(adapter.run_checks("env").await, Err(WebmcpError::InvalidRequest(_))));
1508        assert!(matches!(adapter.run_checks("cargo check").await, Err(WebmcpError::ApprovalRequired)));
1509
1510        let (_checks_temp, checks_disabled) = workspace(true).await;
1511        let checks_disabled = checks_disabled.with_checks_allowed(false);
1512        assert!(matches!(checks_disabled.run_checks("cargo check").await, Err(WebmcpError::ApprovalRequired)));
1513
1514        let outside = temp.path().join("outside.txt");
1515        tokio::fs::write(&outside, "secret").await.expect("outside");
1516        symlink(&outside, temp.path().join("link.txt")).expect("symlink");
1517        assert!(matches!(adapter.read_file("link.txt").await, Err(WebmcpError::PathRejected(_))));
1518        assert!(matches!(adapter.apply_proposal("missing").await, Err(WebmcpError::ApprovalRequired)));
1519    }
1520
1521    #[cfg(unix)]
1522    #[tokio::test]
1523    async fn nested_symlink_components_are_not_read() {
1524        let (_temp, adapter) = workspace(false).await;
1525        let outside = TempDir::new().expect("outside temp dir");
1526        tokio::fs::write(outside.path().join("secret.txt"), "secret")
1527            .await
1528            .expect("outside file");
1529        symlink(outside.path(), adapter.root().join("linked")).expect("directory symlink");
1530
1531        let result = adapter.read_file("linked/secret.txt").await;
1532        assert!(matches!(&result, Err(WebmcpError::PathRejected(_))), "result={result:?}");
1533    }
1534
1535    #[tokio::test]
1536    async fn invalid_explicit_allowed_roots_are_rejected() {
1537        let temp = TempDir::new().expect("temp dir");
1538        let file_root = temp.path().join("not-a-directory");
1539        tokio::fs::write(&file_root, "file").await.expect("seed file");
1540        assert!(matches!(
1541            FilesystemWorkspace::new(temp.path(), [file_root], false).await,
1542            Err(WebmcpError::PathRejected(_))
1543        ));
1544    }
1545
1546    #[tokio::test]
1547    async fn multiple_allowed_roots_are_rejected_until_root_selection_exists() {
1548        let temp = TempDir::new().expect("temp dir");
1549        let first = temp.path().join("first");
1550        let second = temp.path().join("second");
1551        tokio::fs::create_dir_all(&first).await.expect("first root");
1552        tokio::fs::create_dir_all(&second).await.expect("second root");
1553        assert!(matches!(
1554            FilesystemWorkspace::new(&first, [first.clone(), second], false).await,
1555            Err(WebmcpError::InvalidRequest(_))
1556        ));
1557    }
1558
1559    #[tokio::test]
1560    async fn reads_and_check_output_are_bounded() {
1561        let (_temp, adapter) = workspace(true).await;
1562        let limited = adapter.with_limits(FilesystemLimits { max_file_bytes: 4, ..FilesystemLimits::default() });
1563        assert!(matches!(limited.read_file("main.js").await, Err(WebmcpError::LimitExceeded)));
1564
1565        let (mut writer, reader) = duplex(8192);
1566        let writer_task = tokio::spawn(async move {
1567            writer
1568                .write_all(&vec![b'x'; MAX_CHECK_OUTPUT_BYTES + 1])
1569                .await
1570                .expect("write test output");
1571        });
1572        assert!(matches!(read_process_output(reader).await, Err(WebmcpError::LimitExceeded)));
1573        writer_task.await.expect("test output writer");
1574    }
1575
1576    #[tokio::test]
1577    async fn rollback_does_not_overwrite_an_external_change() {
1578        let (_temp, adapter) = workspace(true).await;
1579        let original = adapter.read_file("main.js").await.expect("original");
1580        let applied = FileSnapshot {
1581            path: original.path.clone(),
1582            content: "applied\n".to_string(),
1583            digest: digest_text("applied\n"),
1584        };
1585        let proposal = adapter
1586            .propose_changes(vec![FileChange {
1587                path: applied.path.clone(),
1588                base_digest: original.digest.clone(),
1589                content: applied.content.clone(),
1590            }])
1591            .await
1592            .expect("proposal");
1593        let _ = adapter.apply_proposal(&proposal.proposal_id).await.expect("apply snapshot");
1594        tokio::fs::write(adapter.root().join("main.js"), "external\n")
1595            .await
1596            .expect("external change");
1597
1598        assert!(matches!(
1599            adapter
1600                .rollback_snapshots(std::slice::from_ref(&applied), std::slice::from_ref(&original))
1601                .await,
1602            Err(WebmcpError::PartialApply)
1603        ));
1604
1605        assert_eq!(adapter.read_file("main.js").await.expect("current").content, "external\n");
1606    }
1607
1608    #[tokio::test]
1609    async fn apply_rejects_an_external_change_after_proposal() {
1610        let (_temp, adapter) = workspace(true).await;
1611        let original = adapter.read_file("main.js").await.expect("original");
1612        let proposal = adapter
1613            .propose_changes(vec![FileChange {
1614                path: original.path.clone(),
1615                base_digest: original.digest.clone(),
1616                content: "proposed\n".to_string(),
1617            }])
1618            .await
1619            .expect("proposal");
1620        tokio::fs::write(adapter.root().join("main.js"), "external\n")
1621            .await
1622            .expect("external change");
1623
1624        assert!(matches!(
1625            adapter.apply_proposal(&proposal.proposal_id).await,
1626            Err(WebmcpError::Conflict { path, .. }) if path == "main.js"
1627        ));
1628        assert_eq!(adapter.read_file("main.js").await.expect("current").content, "external\n");
1629    }
1630
1631    #[tokio::test]
1632    async fn apply_reports_a_grown_file_as_a_conflict() {
1633        let (_temp, adapter) = workspace(true).await;
1634        let original = adapter.read_file("main.js").await.expect("original");
1635        let proposal = adapter
1636            .propose_changes(vec![FileChange {
1637                path: original.path.clone(),
1638                base_digest: original.digest.clone(),
1639                content: "proposed\n".to_string(),
1640            }])
1641            .await
1642            .expect("proposal");
1643        let mut file = tokio::fs::OpenOptions::new()
1644            .append(true)
1645            .open(adapter.root().join("main.js"))
1646            .await
1647            .expect("external append");
1648        file.write_all(b"external append\n").await.expect("append");
1649        file.flush().await.expect("flush");
1650
1651        let result = adapter.apply_proposal(&proposal.proposal_id).await;
1652        assert!(matches!(result, Err(WebmcpError::Conflict { actual, .. }) if actual.starts_with("size:")));
1653        assert!(
1654            tokio::fs::read_to_string(adapter.root().join("main.js"))
1655                .await
1656                .expect("current")
1657                .ends_with("external append\n")
1658        );
1659    }
1660
1661    #[cfg(unix)]
1662    #[tokio::test]
1663    async fn apply_rejects_a_path_replaced_with_a_symlink() {
1664        let (_temp, adapter) = workspace(true).await;
1665        let original = adapter.read_file("main.js").await.expect("original");
1666        let proposal = adapter
1667            .propose_changes(vec![FileChange {
1668                path: original.path.clone(),
1669                base_digest: original.digest,
1670                content: "proposed\n".to_string(),
1671            }])
1672            .await
1673            .expect("proposal");
1674        let outside = TempDir::new().expect("outside temp dir");
1675        let outside_file = outside.path().join("outside.txt");
1676        tokio::fs::write(&outside_file, "outside\n").await.expect("outside file");
1677        tokio::fs::remove_file(adapter.root().join("main.js"))
1678            .await
1679            .expect("remove workspace file");
1680        symlink(&outside_file, adapter.root().join("main.js")).expect("replacement symlink");
1681
1682        assert!(matches!(adapter.apply_proposal(&proposal.proposal_id).await, Err(WebmcpError::PathRejected(_))));
1683        assert_eq!(tokio::fs::read_to_string(outside_file).await.expect("outside content"), "outside\n");
1684    }
1685}