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#[derive(Debug, Clone, Copy)]
68pub struct FilesystemLimits {
69 pub max_files: usize,
71 pub max_file_bytes: usize,
73 pub max_total_bytes: usize,
75 pub max_changes: usize,
77 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#[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 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 pub fn with_limits(mut self, limits: FilesystemLimits) -> Self {
195 self.limits = limits;
196 self
197 }
198
199 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 pub fn with_checks_allowed(mut self, checks_allowed: bool) -> Self {
211 self.checks_allowed = checks_allowed;
212 self
213 }
214
215 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 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 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
618pub 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 ¤t,
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 ¤t,
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(¤t);
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}