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;
11use std::path::{Component, Path, PathBuf};
12use std::process::Stdio;
13use std::sync::{Arc, Mutex};
14use std::time::Duration;
15use tokio::io::{AsyncRead, AsyncReadExt};
16use tokio::process::Command;
17use tokio::sync::Mutex as AsyncMutex;
18use uuid::Uuid;
19use vtcode_commons::exclusions::{SENSITIVE_FILES, is_sensitive_file};
20use vtcode_diff::{DiffDocument, DiffOptions, format_unified_hunks};
21use vtcode_safety::sandboxing::{
22 CommandSpec, ExecExpiration, LinuxSandboxLauncher, SandboxManager, SandboxPolicy, SensitivePath,
23 default_sensitive_paths,
24};
25
26const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
27const MAX_CHECK_OUTPUT_BYTES: usize = 512 * 1024;
28const MAX_DIRECTORY_DEPTH: usize = 64;
29const MAX_VISITED_DIRECTORIES: usize = 16_384;
30const MAX_CHECK_SENSITIVE_PATHS: usize = 4096;
31const MAX_STORED_PROPOSALS: usize = 64;
32const MAX_STORED_PROPOSAL_BYTES: usize = 128 * 1024 * 1024;
33const IGNORED_DIRECTORY_NAMES: &[&str] = &[
34 ".cargo",
35 ".cache",
36 ".codegraph",
37 ".git",
38 ".mypy_cache",
39 ".opencode",
40 ".pytest_cache",
41 ".ruff_cache",
42 ".superpowers",
43 ".vscode",
44 ".worktrees",
45 ".vtcode",
46 "__pycache__",
47 "dist",
48 "node_modules",
49 "target",
50];
51const SENSITIVE_DIRECTORY_NAMES: &[&str] = &[
52 ".aws",
53 ".azure",
54 ".config",
55 ".docker",
56 ".gnupg",
57 ".kube",
58 ".pki",
59 ".secrets",
60 ".ssh",
61 ".terraform.d",
62];
63
64mod secure_open;
65
66use secure_open::{
67 open_root_directory, open_workspace_file, read_snapshot_blocking, replace_snapshot_if_current_blocking,
68};
69
70#[derive(Debug, Clone, Copy)]
72pub struct FilesystemLimits {
73 pub max_files: usize,
75 pub max_file_bytes: usize,
77 pub max_total_bytes: usize,
79 pub max_changes: usize,
81 pub max_change_bytes: usize,
83}
84
85impl Default for FilesystemLimits {
86 fn default() -> Self {
87 Self {
88 max_files: 8192,
89 max_file_bytes: 2 * 1024 * 1024,
90 max_total_bytes: 64 * 1024 * 1024,
91 max_changes: 32,
92 max_change_bytes: 2 * 1024 * 1024,
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
98struct StoredProposal {
99 proposal: PatchProposal,
100 before: Vec<FileSnapshot>,
101 size_bytes: usize,
102}
103
104#[derive(Debug, Clone)]
105struct StoredChange {
106 change_id: String,
107 before: Vec<FileSnapshot>,
108 after: Vec<FileSnapshot>,
109}
110
111#[derive(Debug, Default)]
112struct FilesystemState {
113 proposals: std::collections::HashMap<String, StoredProposal>,
114 proposal_bytes: usize,
115 last_change: Option<StoredChange>,
116}
117
118#[derive(Clone)]
120pub struct FilesystemWorkspace {
121 root: Arc<PathBuf>,
122 root_dir: Arc<std::fs::File>,
123 allowed_roots: Arc<Vec<PathBuf>>,
124 limits: FilesystemLimits,
125 mutations_allowed: bool,
126 checks_allowed: bool,
127 allowed_commands: Arc<HashSet<String>>,
128 state: Arc<Mutex<FilesystemState>>,
129 mutation_lock: Arc<AsyncMutex<()>>,
130}
131
132impl std::fmt::Debug for FilesystemWorkspace {
133 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 formatter
135 .debug_struct("FilesystemWorkspace")
136 .field("root", &self.root)
137 .field("allowed_roots", &self.allowed_roots)
138 .field("limits", &self.limits)
139 .field("mutations_allowed", &self.mutations_allowed)
140 .field("checks_allowed", &self.checks_allowed)
141 .finish_non_exhaustive()
142 }
143}
144
145impl FilesystemWorkspace {
146 pub async fn new<I>(root: impl AsRef<Path>, allowed_roots: I, mutations_allowed: bool) -> Result<Self>
149 where
150 I: IntoIterator<Item = PathBuf>,
151 {
152 let root = vtcode_commons::canonicalize_async(root.as_ref().to_path_buf()).await?;
153 if !tokio::fs::metadata(&root).await?.is_dir() {
154 return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
155 }
156
157 let allowed_roots = allowed_roots.into_iter().collect::<Vec<_>>();
158 if allowed_roots.len() > 1 {
159 return Err(WebmcpError::InvalidRequest(
160 "headless WebMCP currently supports one workspace root".to_string(),
161 ));
162 }
163 let roots = if allowed_roots.is_empty() {
164 vec![root.clone()]
165 } else {
166 let mut roots = Vec::with_capacity(allowed_roots.len());
167 for allowed_root in allowed_roots {
168 let canonical = vtcode_commons::canonicalize_async(allowed_root).await?;
169 if !tokio::fs::metadata(&canonical).await?.is_dir() {
170 return Err(WebmcpError::PathRejected("allowed root is not a directory".to_string()));
171 }
172 roots.push(canonical);
173 }
174 roots
175 };
176 if !roots.iter().any(|allowed| root.starts_with(allowed)) {
177 return Err(WebmcpError::PathRejected("workspace root is not in the allowed roots".to_string()));
178 }
179 let root_dir = open_root_directory(&root)?;
180 if !root_dir.metadata()?.is_dir() {
181 return Err(WebmcpError::PathRejected("workspace root is not a directory".to_string()));
182 }
183
184 Ok(Self {
185 root: Arc::new(root),
186 root_dir: Arc::new(root_dir),
187 allowed_roots: Arc::new(roots),
188 limits: FilesystemLimits::default(),
189 mutations_allowed,
190 checks_allowed: mutations_allowed,
191 allowed_commands: Arc::new(["cargo"].into_iter().map(str::to_string).collect()),
192 state: Arc::new(Mutex::new(FilesystemState::default())),
193 mutation_lock: Arc::new(AsyncMutex::new(())),
194 })
195 }
196
197 pub fn with_limits(mut self, limits: FilesystemLimits) -> Self {
199 self.limits = limits;
200 self
201 }
202
203 pub fn with_allowed_commands<I, S>(mut self, commands: I) -> Self
205 where
206 I: IntoIterator<Item = S>,
207 S: Into<String>,
208 {
209 self.allowed_commands = Arc::new(commands.into_iter().map(Into::into).collect());
210 self
211 }
212
213 pub fn with_checks_allowed(mut self, checks_allowed: bool) -> Self {
215 self.checks_allowed = checks_allowed;
216 self
217 }
218
219 pub async fn proposal_for_turn(&self, proposal_id: &str) -> Result<PatchProposal> {
225 let _mutation_guard = self.mutation_lock.lock().await;
226 let stored = {
227 let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
228 state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
229 };
230 for expected in &stored.before {
231 let current = self.read_snapshot(&expected.path).await?;
232 if current.digest != expected.digest {
233 return Err(WebmcpError::Conflict {
234 path: expected.path.clone(),
235 expected: expected.digest.clone(),
236 actual: current.digest,
237 });
238 }
239 }
240 Ok(stored.proposal)
241 }
242
243 pub fn root(&self) -> &Path {
245 self.root.as_ref()
246 }
247
248 async fn read_snapshot(&self, path: &str) -> Result<FileSnapshot> {
249 let root = self.root.clone();
250 let root_dir = self.root_dir.clone();
251 let path = path.to_string();
252 let limits = self.limits;
253 tokio::task::spawn_blocking(move || read_snapshot_blocking(&root, &root_dir, &path, limits))
254 .await
255 .map_err(|error| WebmcpError::Adapter(format!("file read task failed: {error}")))?
256 }
257
258 async fn replace_snapshot_if_current(&self, update: &FileSnapshot, expected: &FileSnapshot) -> Result<()> {
259 let root = self.root.clone();
260 let root_dir = self.root_dir.clone();
261 let update = update.clone();
262 let expected = expected.clone();
263 tokio::task::spawn_blocking(move || replace_snapshot_if_current_blocking(&root, &root_dir, &update, &expected))
264 .await
265 .map_err(|error| WebmcpError::Adapter(format!("compare-and-replace task failed: {error}")))?
266 }
267
268 async fn apply_snapshots_if_current(&self, updates: &[FileSnapshot], expected: &[FileSnapshot]) -> Result<()> {
269 if updates.len() != expected.len() {
270 return Err(WebmcpError::Adapter("snapshot compare-and-swap lengths do not match".to_string()));
271 }
272 let mut applied = Vec::with_capacity(updates.len());
273 for (index, (update, expected_snapshot)) in updates.iter().zip(expected).enumerate() {
274 if let Err(error) = self.replace_snapshot_if_current(update, expected_snapshot).await {
275 let rollback_error = self
276 .rollback_snapshots(&applied, expected.get(..index).unwrap_or(&[]))
277 .await
278 .err();
279 return Err(rollback_error.unwrap_or(error));
280 }
281 applied.push(update.clone());
282 }
283 Ok(())
284 }
285
286 async fn apply_proposal_inner(&self, proposal_id: &str) -> Result<AppliedChange> {
287 let _mutation_guard = self.mutation_lock.lock().await;
288 let stored = {
289 let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
290 state.proposals.get(proposal_id).cloned().ok_or(WebmcpError::ProposalNotFound)?
291 };
292
293 let after = stored
294 .proposal
295 .changes
296 .iter()
297 .map(|change| FileSnapshot {
298 path: change.path.clone(),
299 content: change.content.clone(),
300 digest: digest_text(&change.content),
301 })
302 .collect::<Vec<_>>();
303 self.apply_snapshots_if_current(&after, &stored.before).await?;
304
305 let change_id = Uuid::new_v4().simple().to_string();
306 let result = AppliedChange {
307 change_id: change_id.clone(),
308 paths: after.iter().map(|snapshot| snapshot.path.clone()).collect(),
309 };
310 let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
311 if let Some(removed) = state.proposals.remove(proposal_id) {
312 state.proposal_bytes = state.proposal_bytes.saturating_sub(removed.size_bytes);
313 }
314 state.last_change = Some(StoredChange { change_id, before: stored.before, after });
315 Ok(result)
316 }
317
318 async fn revert_last_change_inner(&self, change_id: &str) -> Result<AppliedChange> {
319 let _mutation_guard = self.mutation_lock.lock().await;
320 let stored = {
321 let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
322 state.last_change.clone().ok_or(WebmcpError::ChangeNotFound)?
323 };
324 if stored.change_id != change_id {
325 return Err(WebmcpError::ChangeNotFound);
326 }
327 self.apply_snapshots_if_current(&stored.before, &stored.after).await?;
328 let reverted = AppliedChange {
329 change_id: stored.change_id.clone(),
330 paths: stored.before.iter().map(|snapshot| snapshot.path.clone()).collect(),
331 };
332 let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
333 state.last_change = None;
334 Ok(reverted)
335 }
336
337 async fn rollback_snapshots(&self, applied: &[FileSnapshot], originals: &[FileSnapshot]) -> Result<()> {
338 let mut failed = false;
339 for (applied_snapshot, original) in applied.iter().zip(originals).rev() {
340 if let Err(error) = self.replace_snapshot_if_current(original, applied_snapshot).await {
341 tracing::error!(
342 path = %original.path,
343 error = %error,
344 "failed to roll back a partially applied WebMCP change"
345 );
346 failed = true;
347 }
348 }
349 if failed { Err(WebmcpError::PartialApply) } else { Ok(()) }
350 }
351
352 fn proposal_diff(before: &[FileSnapshot], changes: &[FileChange]) -> String {
353 let mut diff = String::new();
354 for (snapshot, change) in before.iter().zip(changes) {
355 let old_label = format!("a/{}", snapshot.path);
356 let new_label = format!("b/{}", change.path);
357 let options = DiffOptions {
358 context_lines: 3,
359 old_label: Some(old_label.as_str()),
360 new_label: Some(new_label.as_str()),
361 ..DiffOptions::default()
362 };
363 let document = DiffDocument::between(&snapshot.content, &change.content, options.clone());
364 if document.hunks.is_empty() {
365 continue;
366 }
367 diff.push_str(&format_unified_hunks(&document.hunks, &options));
368 }
369 diff
370 }
371}
372
373#[async_trait]
374impl RuntimeAdapter for FilesystemWorkspace {
375 async fn status(&self) -> Result<RuntimeStatus> {
376 Ok(RuntimeStatus {
377 explanations_available: false,
378 workspace_root: self.root.display().to_string(),
379 connected: true,
380 turns_available: false,
381 mutations_allowed: self.mutations_allowed,
382 checks_allowed: self.checks_allowed,
383 approval_authority: if self.mutations_allowed {
384 "headless full-auto allowlist".into()
385 } else {
386 "headless policy (mutations disabled)".into()
387 },
388 })
389 }
390
391 async fn list_files(&self) -> Result<Vec<WorkspaceFile>> {
392 let root = self.root.clone();
393 let root_dir = self.root_dir.clone();
394 let limits = self.limits;
395 tokio::task::spawn_blocking(move || list_files_blocking(&root, &root_dir, limits))
396 .await
397 .map_err(|error| WebmcpError::Adapter(format!("file listing task failed: {error}")))?
398 }
399
400 async fn read_file(&self, path: &str) -> Result<FileSnapshot> {
401 self.read_snapshot(path).await
402 }
403
404 async fn propose_changes(&self, changes: Vec<FileChange>) -> Result<PatchProposal> {
405 let _mutation_guard = self.mutation_lock.lock().await;
406 if changes.is_empty() || changes.len() > self.limits.max_changes {
407 return Err(WebmcpError::LimitExceeded);
408 }
409 {
410 let state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
411 if state.proposals.len() >= MAX_STORED_PROPOSALS {
412 return Err(WebmcpError::LimitExceeded);
413 }
414 }
415 let mut paths = HashSet::with_capacity(changes.len());
416 let mut before = Vec::with_capacity(changes.len());
417 let mut proposal_bytes = 0usize;
418 for change in &changes {
419 if change.content.len() > self.limits.max_change_bytes || !paths.insert(change.path.clone()) {
420 return Err(WebmcpError::LimitExceeded);
421 }
422 let snapshot = self.read_snapshot(&change.path).await?;
423 if snapshot.digest != change.base_digest {
424 return Err(WebmcpError::Conflict {
425 path: change.path.clone(),
426 expected: change.base_digest.clone(),
427 actual: snapshot.digest,
428 });
429 }
430 proposal_bytes = proposal_bytes
431 .checked_add(snapshot.content.len())
432 .and_then(|bytes| bytes.checked_add(change.content.len()))
433 .ok_or(WebmcpError::LimitExceeded)?;
434 if proposal_bytes > self.limits.max_total_bytes {
435 return Err(WebmcpError::LimitExceeded);
436 }
437 before.push(snapshot);
438 }
439
440 let unified_diff = Self::proposal_diff(&before, &changes);
441 if unified_diff.len() > self.limits.max_total_bytes {
442 return Err(WebmcpError::LimitExceeded);
443 }
444 let stored_size_bytes = proposal_bytes
445 .checked_add(unified_diff.len())
446 .ok_or(WebmcpError::LimitExceeded)?;
447 let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
448 if state
449 .proposal_bytes
450 .checked_add(stored_size_bytes)
451 .is_none_or(|bytes| bytes > MAX_STORED_PROPOSAL_BYTES)
452 {
453 return Err(WebmcpError::LimitExceeded);
454 }
455 let proposal = PatchProposal {
456 proposal_id: Uuid::new_v4().simple().to_string(),
457 unified_diff,
458 changes,
459 };
460 state.proposal_bytes += stored_size_bytes;
461 drop(state.proposals.insert(
462 proposal.proposal_id.clone(),
463 StoredProposal {
464 proposal: proposal.clone(),
465 before,
466 size_bytes: stored_size_bytes,
467 },
468 ));
469 Ok(proposal)
470 }
471
472 async fn apply_proposal(&self, proposal_id: &str) -> Result<AppliedChange> {
473 if !self.mutations_allowed {
474 return Err(WebmcpError::ApprovalRequired);
475 }
476 let workspace = self.clone();
477 let proposal_id = proposal_id.to_string();
478 tokio::spawn(async move { workspace.apply_proposal_inner(&proposal_id).await })
479 .await
480 .map_err(|error| WebmcpError::Adapter(format!("WebMCP apply task failed: {error}")))?
481 }
482
483 async fn run_checks(&self, command: &str) -> Result<CheckResult> {
484 let args = parse_safe_command(command, &self.allowed_commands)?;
485 if !self.checks_allowed {
486 return Err(WebmcpError::ApprovalRequired);
487 }
488 let _mutation_guard = self.mutation_lock.lock().await;
489 let (program, arguments) = args
490 .split_first()
491 .ok_or_else(|| WebmcpError::InvalidRequest("check command cannot be empty".to_string()))?;
492 let executable = resolve_check_executable(program, self.root.as_ref())?;
493 let host_home = std::env::var_os("HOME")
497 .or_else(|| std::env::var_os("USERPROFILE"))
498 .map(PathBuf::from);
499 let mut environment = SandboxEnvironment::new();
500 let _ = environment.insert("PATH".to_string(), trusted_executable_path(&executable)?);
501 let _ = environment.insert("HOME".to_string(), self.root.display().to_string());
502 let _ = environment.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
503 let _ = environment.insert("CARGO_TERM_COLOR".to_string(), "never".to_string());
504 let cargo_home = host_home
505 .as_ref()
506 .and_then(|home| trusted_toolchain_directory(&home.join(".cargo"), self.root.as_ref()));
507 if let Some(cargo_home) = cargo_home {
508 let _ = environment.insert("CARGO_HOME".to_string(), cargo_home.to_string_lossy().into_owned());
509 }
510 let rustup_home = host_home
511 .as_ref()
512 .and_then(|home| trusted_toolchain_directory(&home.join(".rustup"), self.root.as_ref()));
513 if let Some(rustup_home) = rustup_home {
514 let _ = environment.insert("RUSTUP_HOME".to_string(), rustup_home.to_string_lossy().into_owned());
515 }
516 let spec = CommandSpec::new(executable)
517 .with_args(arguments.iter().cloned())
518 .with_cwd(self.root.as_ref().to_path_buf())
519 .with_env(environment)
520 .with_expiration(ExecExpiration::Timeout(CHECK_TIMEOUT));
521 let linux_launcher = LinuxSandboxLauncher::resolve();
522 let check_policy = check_sandbox_policy(self.root.as_ref())
523 .map_err(|error| WebmcpError::Adapter(format!("failed to build WebMCP check sandbox: {error}")))?;
524 let exec_env = SandboxManager::new()
525 .transform(spec, &check_policy, self.root.as_ref(), linux_launcher.as_ref())
526 .map_err(|error| WebmcpError::Adapter(format!("failed to sandbox WebMCP check: {error}")))?;
527 let mut child = Command::new(exec_env.program)
528 .args(exec_env.args)
529 .current_dir(exec_env.cwd)
530 .env_clear()
531 .envs(exec_env.env)
532 .stdin(Stdio::null())
533 .stdout(Stdio::piped())
534 .stderr(Stdio::piped())
535 .kill_on_drop(true)
536 .spawn()?;
537 let stdout = child
538 .stdout
539 .take()
540 .ok_or_else(|| WebmcpError::Adapter("check process stdout was not captured".to_string()))?;
541 let stderr = child
542 .stderr
543 .take()
544 .ok_or_else(|| WebmcpError::Adapter("check process stderr was not captured".to_string()))?;
545 let output = Box::pin(tokio::time::timeout(CHECK_TIMEOUT, async {
546 let (status, stdout, stderr) =
547 tokio::join!(child.wait(), read_process_output(stdout), read_process_output(stderr));
548 Ok::<_, WebmcpError>((status?, stdout?, stderr?))
549 }))
550 .await;
551 let (status, stdout, stderr) = match output {
552 Ok(result) => result?,
553 Err(_elapsed) => {
554 drop(child.kill().await);
555 drop(child.wait().await);
556 return Err(WebmcpError::Timeout(CHECK_TIMEOUT));
557 }
558 };
559 Ok(CheckResult {
560 command: command.to_string(),
561 exit_code: status.code(),
562 stdout: String::from_utf8_lossy(&stdout).into_owned(),
563 stderr: String::from_utf8_lossy(&stderr).into_owned(),
564 })
565 }
566
567 async fn revert_last_change(&self, change_id: &str) -> Result<AppliedChange> {
568 if !self.mutations_allowed {
569 return Err(WebmcpError::ApprovalRequired);
570 }
571 let workspace = self.clone();
572 let change_id = change_id.to_string();
573 tokio::spawn(async move { workspace.revert_last_change_inner(&change_id).await })
574 .await
575 .map_err(|error| WebmcpError::Adapter(format!("WebMCP revert task failed: {error}")))?
576 }
577
578 async fn request_turn(&self, prompt: &str, _proposal_id: Option<&str>) -> Result<TurnResult> {
579 if prompt.trim().is_empty() {
580 return Err(WebmcpError::InvalidRequest("agent turn prompt cannot be empty".to_string()));
581 }
582 if prompt.len() > 16 * 1024 {
583 return Err(WebmcpError::LimitExceeded);
584 }
585 Err(WebmcpError::Unsupported(
586 "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(),
587 ))
588 }
589}
590
591fn validate_relative_path(path: &str) -> Result<PathBuf> {
592 if path.is_empty() || path.len() > 4096 || path.contains('\0') {
593 return Err(WebmcpError::PathRejected(path.to_string()));
594 }
595 let path = Path::new(path);
596 if path.is_absolute() {
597 return Err(WebmcpError::PathRejected(path.display().to_string()));
598 }
599 let mut validated = PathBuf::new();
600 for component in path.components() {
601 match component {
602 Component::Normal(part) => validated.push(part),
603 Component::CurDir => {}
604 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
605 return Err(WebmcpError::PathRejected(path.display().to_string()));
606 }
607 }
608 }
609 if validated.as_os_str().is_empty() {
610 return Err(WebmcpError::PathRejected(path.display().to_string()));
611 }
612 Ok(validated)
613}
614
615fn reject_sensitive_relative_path(relative: &Path, original: &str) -> Result<()> {
616 if is_sensitive_relative_path(relative) {
617 return Err(WebmcpError::PathRejected(format!("sensitive workspace path is not exposed: {original}")));
618 }
619 Ok(())
620}
621
622pub fn is_public_evidence_path(path: &str) -> bool {
625 validate_relative_path(path).is_ok_and(|relative| {
626 !is_sensitive_relative_path(&relative)
627 && !relative.components().any(|component| {
628 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))))
629 })
630 })
631}
632
633fn is_sensitive_relative_path(relative: &Path) -> bool {
634 relative.components().any(|component| {
635 let Component::Normal(name) = component else {
636 return false;
637 };
638 let Some(name) = name.to_str() else {
639 return true;
640 };
641 is_sensitive_file(name)
642 || SENSITIVE_DIRECTORY_NAMES
643 .iter()
644 .any(|sensitive| name.eq_ignore_ascii_case(sensitive))
645 })
646}
647
648#[cfg(unix)]
649fn has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
650 use std::os::unix::fs::MetadataExt;
651
652 metadata.nlink() > 1
653}
654
655#[cfg(not(unix))]
656const fn has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
657 false
658}
659
660fn reject_hard_links(metadata: &std::fs::Metadata, path: &str) -> Result<()> {
661 if has_multiple_hard_links(metadata) {
662 return Err(WebmcpError::PathRejected(format!("hard-linked file is not allowed: {path}")));
663 }
664 Ok(())
665}
666
667fn digest_text(content: &str) -> String {
668 let digest = Sha256::digest(content.as_bytes());
669 let mut encoded = String::with_capacity(7 + digest.len() * 2);
670 encoded.push_str("sha256:");
671 for byte in digest {
672 encoded.push_str(&format!("{byte:02x}"));
673 }
674 encoded
675}
676
677fn list_files_blocking(root: &Path, root_dir: &std::fs::File, limits: FilesystemLimits) -> Result<Vec<WorkspaceFile>> {
678 let mut files = Vec::new();
679 let mut total_bytes = 0usize;
680 let mut visited_directories = 0usize;
681 visit_directory(root, root_dir, root, 0, limits, &mut total_bytes, &mut visited_directories, &mut files)?;
682 files.sort_by(|left, right| left.path.cmp(&right.path));
683 Ok(files)
684}
685
686fn visit_directory(
687 root: &Path,
688 root_dir: &std::fs::File,
689 directory: &Path,
690 depth: usize,
691 limits: FilesystemLimits,
692 total_bytes: &mut usize,
693 visited_directories: &mut usize,
694 files: &mut Vec<WorkspaceFile>,
695) -> Result<()> {
696 if depth > MAX_DIRECTORY_DEPTH {
697 return Err(WebmcpError::LimitExceeded);
698 }
699 *visited_directories = visited_directories.saturating_add(1);
700 if *visited_directories > MAX_VISITED_DIRECTORIES {
701 return Err(WebmcpError::LimitExceeded);
702 }
703 let entries = std::fs::read_dir(directory)?;
704 for entry in entries {
705 if files.len() >= limits.max_files {
706 return Err(WebmcpError::LimitExceeded);
707 }
708 let entry = entry?;
709 let path = entry.path();
710 let relative_path = path
711 .strip_prefix(root)
712 .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
713 if is_sensitive_relative_path(relative_path) {
714 continue;
715 }
716 let metadata = std::fs::symlink_metadata(&path)?;
717 if metadata.file_type().is_symlink() {
718 continue;
719 }
720 if metadata.is_dir() {
721 if entry
722 .file_name()
723 .to_str()
724 .is_some_and(|name| IGNORED_DIRECTORY_NAMES.contains(&name))
725 {
726 continue;
727 }
728 visit_directory(root, root_dir, &path, depth + 1, limits, total_bytes, visited_directories, files)?;
729 continue;
730 }
731 if !metadata.is_file() {
732 continue;
733 }
734 if has_multiple_hard_links(&metadata) {
735 continue;
736 }
737 let max_file_bytes = u64::try_from(limits.max_file_bytes).unwrap_or(u64::MAX);
738 if metadata.len() > max_file_bytes {
739 continue;
740 }
741 let file = open_workspace_file(root, root_dir, relative_path, false)?;
742 let opened_metadata = file.metadata()?;
743 if !opened_metadata.is_file()
744 || has_multiple_hard_links(&opened_metadata)
745 || opened_metadata.len() > max_file_bytes
746 {
747 continue;
748 }
749 let mut reader = file.take(max_file_bytes.saturating_add(1));
750 let mut bytes = Vec::with_capacity(limits.max_file_bytes.min(64 * 1024));
751 let _bytes_read = reader.read_to_end(&mut bytes)?;
752 if bytes.len() > limits.max_file_bytes {
753 continue;
754 }
755 let Ok(content) = String::from_utf8(bytes) else {
756 continue;
757 };
758 *total_bytes = total_bytes.saturating_add(content.len());
759 if *total_bytes > limits.max_total_bytes {
760 return Err(WebmcpError::LimitExceeded);
761 }
762 let relative = relative_path.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
763 files.push(WorkspaceFile {
764 path: relative,
765 size_bytes: content.len() as u64,
766 digest: digest_text(&content),
767 });
768 }
769 Ok(())
770}
771
772fn trusted_toolchain_directory(path: &Path, workspace: &Path) -> Option<PathBuf> {
773 let canonical = vtcode_commons::canonicalize(path).ok()?;
774 (canonical.is_dir() && !canonical.starts_with(workspace)).then_some(canonical)
775}
776
777fn resolve_check_executable(program: &str, workspace: &Path) -> Result<PathBuf> {
778 let executable_name = if cfg!(windows) {
779 format!("{program}.exe")
780 } else {
781 program.to_string()
782 };
783 let mut candidates = Vec::new();
784
785 #[cfg(unix)]
786 {
787 if program == "cargo" {
788 if let Some(home) = std::env::var_os("HOME") {
789 candidates.push(PathBuf::from(home).join(".cargo/bin").join(&executable_name));
790 }
791 }
792 for directory in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"] {
793 candidates.push(PathBuf::from(directory).join(&executable_name));
794 }
795 }
796
797 #[cfg(windows)]
798 {
799 if program == "cargo"
800 && let Some(profile) = std::env::var_os("USERPROFILE")
801 {
802 candidates.push(PathBuf::from(profile).join(".cargo/bin").join(&executable_name));
803 }
804 if let Some(system_root) = std::env::var_os("SystemRoot") {
805 candidates.push(PathBuf::from(system_root).join("System32").join(&executable_name));
806 }
807 }
808
809 for candidate in candidates {
810 let Ok(canonical) = vtcode_commons::canonicalize(&candidate) else {
811 continue;
812 };
813 if canonical.starts_with(workspace) {
814 continue;
815 }
816 let Ok(metadata) = std::fs::metadata(&canonical) else {
817 continue;
818 };
819 if metadata.is_file() && is_executable_file(&metadata) {
820 return Ok(canonical);
821 }
822 }
823
824 Err(WebmcpError::InvalidRequest(format!(
825 "allowlisted check executable is not installed in a trusted location: {program}"
826 )))
827}
828
829fn is_executable_file(metadata: &std::fs::Metadata) -> bool {
830 #[cfg(unix)]
831 {
832 use std::os::unix::fs::PermissionsExt;
833
834 metadata.permissions().mode() & 0o111 != 0
835 }
836 #[cfg(not(unix))]
837 {
838 metadata.is_file()
839 }
840}
841
842fn trusted_executable_path(executable: &Path) -> Result<String> {
843 let mut directories = Vec::new();
844 if let Some(parent) = executable.parent() {
845 directories.push(parent.to_path_buf());
846 }
847 #[cfg(unix)]
848 directories.extend([
849 PathBuf::from("/usr/local/bin"),
850 PathBuf::from("/opt/homebrew/bin"),
851 PathBuf::from("/usr/bin"),
852 PathBuf::from("/bin"),
853 ]);
854 #[cfg(windows)]
855 if let Some(system_root) = std::env::var_os("SystemRoot") {
856 directories.push(PathBuf::from(system_root).join("System32"));
857 }
858 directories.dedup();
859 std::env::join_paths(directories)
860 .map(|path| path.to_string_lossy().into_owned())
861 .map_err(|error| WebmcpError::Adapter(format!("failed to build trusted check PATH: {error}")))
862}
863
864fn sensitive_path_for_policy(path: &Path) -> Result<SensitivePath> {
865 let path_string = path.to_str().ok_or_else(|| {
866 WebmcpError::Adapter(format!("cannot sandbox a non-UTF-8 sensitive path: {}", path.display()))
867 })?;
868 if path_string
869 .chars()
870 .any(|character| character == '"' || character == '\\' || character.is_control())
871 {
872 return Err(WebmcpError::Adapter(format!(
873 "cannot sandbox a sensitive path containing an unsafe character: {path_string}"
874 )));
875 }
876 Ok(SensitivePath::new(path_string))
877}
878
879fn collect_workspace_sensitive_paths(root: &Path) -> Result<Vec<SensitivePath>> {
880 let mut sensitive_paths = Vec::new();
881 let mut pending = vec![(root.to_path_buf(), 0usize)];
882 let mut visited_directories = 0usize;
883
884 while let Some((directory, depth)) = pending.pop() {
885 if depth > MAX_DIRECTORY_DEPTH {
886 return Err(WebmcpError::LimitExceeded);
887 }
888 visited_directories = visited_directories.saturating_add(1);
889 if visited_directories > MAX_VISITED_DIRECTORIES {
890 return Err(WebmcpError::LimitExceeded);
891 }
892
893 for entry in std::fs::read_dir(&directory)? {
894 let entry = entry?;
895 let path = entry.path();
896 let relative_path = path
897 .strip_prefix(root)
898 .map_err(|_error| WebmcpError::PathRejected(path.display().to_string()))?;
899 if is_sensitive_relative_path(relative_path) {
900 if sensitive_paths.len() >= MAX_CHECK_SENSITIVE_PATHS {
901 return Err(WebmcpError::LimitExceeded);
902 }
903 sensitive_paths.push(sensitive_path_for_policy(&path)?);
904 continue;
905 }
906
907 let metadata = std::fs::symlink_metadata(&path)?;
908 if metadata.file_type().is_symlink() {
909 continue;
910 }
911 if metadata.is_dir() {
912 pending.push((path, depth + 1));
913 }
914 }
915 }
916
917 Ok(sensitive_paths)
918}
919
920fn check_sandbox_policy(root: &Path) -> Result<SandboxPolicy> {
921 let mut sensitive_paths = default_sensitive_paths();
922 for name in SENSITIVE_FILES.iter().copied().chain(SENSITIVE_DIRECTORY_NAMES.iter().copied()) {
923 sensitive_paths.push(sensitive_path_for_policy(&root.join(name))?);
924 }
925 sensitive_paths.extend(collect_workspace_sensitive_paths(root)?);
926 Ok(SandboxPolicy::workspace_write_with_sensitive_paths(vec![root.to_path_buf()], sensitive_paths))
927}
928
929fn parse_safe_command(command: &str, allowed_commands: &HashSet<String>) -> Result<Vec<String>> {
930 if command.len() > 512
931 || command
932 .chars()
933 .any(|character| matches!(character, ';' | '|' | '&' | '$' | '`' | '>' | '<' | '\n' | '\r'))
934 {
935 return Err(WebmcpError::InvalidRequest("check command contains shell syntax".to_string()));
936 }
937 let args = shell_words::split(command)
938 .map_err(|error| WebmcpError::InvalidRequest(format!("invalid check command: {error}")))?;
939 let Some(program) = args.first() else {
940 return Err(WebmcpError::InvalidRequest("check command cannot be empty".to_string()));
941 };
942 if Path::new(program).components().count() != 1 || !allowed_commands.contains(program) {
943 return Err(WebmcpError::InvalidRequest("check executable is not allowlisted".to_string()));
944 }
945 if args.iter().any(|argument| argument.contains('\0')) {
946 return Err(WebmcpError::InvalidRequest("check command contains an invalid argument".to_string()));
947 }
948 match program.as_str() {
949 "cargo"
950 if args.get(1).is_some_and(|subcommand| subcommand == "check")
951 && args.iter().skip(2).all(|argument| {
952 matches!(
953 argument.as_str(),
954 "--locked" | "--offline" | "--workspace" | "--all-targets" | "--all-features"
955 )
956 }) =>
957 {
958 Ok(args)
959 }
960 "printf" => Ok(args),
961 _ => Err(WebmcpError::InvalidRequest(
962 "only the bounded cargo check and printf commands are supported".to_string(),
963 )),
964 }
965}
966
967async fn read_process_output<R>(mut reader: R) -> Result<Vec<u8>>
968where
969 R: AsyncRead + Unpin,
970{
971 let mut captured = Vec::with_capacity(MAX_CHECK_OUTPUT_BYTES.min(8192));
972 let mut buffer = [0u8; 8192];
973 let mut exceeded = false;
974 loop {
975 let bytes_read = reader.read(&mut buffer).await?;
976 if bytes_read == 0 {
977 break;
978 }
979 let remaining = MAX_CHECK_OUTPUT_BYTES.saturating_sub(captured.len());
980 exceeded |= bytes_read > remaining;
981 if remaining > 0 {
982 let chunk = buffer
983 .get(..bytes_read.min(remaining))
984 .ok_or_else(|| WebmcpError::Adapter("check output read exceeded its buffer".to_string()))?;
985 captured.extend_from_slice(chunk);
986 }
987 }
988 if exceeded {
989 Err(WebmcpError::LimitExceeded)
990 } else {
991 Ok(captured)
992 }
993}
994
995#[cfg(test)]
996mod tests;