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