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