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