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