fix: confine MCP agents' files to allowed folders; recovery discard deletes only recovery entries (#205) (#578)

Agent tool calls (MCP, and bridged control requests carrying allow_paths) reach files only in the open design's folder (strictly inside home or the temporary folder, not in a hidden folder, and never its hidden files), the app's recovery folder, the server's private scratch folder and each --allow-path; --allow-any-path lifts the limit. Paths are resolved first, so .. and symlinks that lead out (including a save through a link, #845) are refused. On Windows, UNC, device, NT, verbatim non-drive, drive-relative, alternate-stream, reserved-device and 8.3 short-name paths are refused before any file system call, and the resolved path must be on a local drive. doc.recovery_discard and doc.recover {discard} delete only real recovery entries. Docs: mcp.md, control-protocol.md, README.
This commit is contained in:
Kevin Ravensberg authored and GitHub committed 2026-10-13 18:24:08 -04:00
1 parent 3d3f9b626f
commit 96f6424aec
23 files changed
+1000 -37

No files matched your search

+6 -7
View File
@@ -4,13 +4,14 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Map, Value, json};
use solidcraft_engine::io::vfs;
use crate::backend::Backend;
/// Most commands in one `batch` call.
const MAX_BATCH: usize = 10_000;
/// Largest image returned by `screenshot`.
const MAX_IMAGE_BYTES: u64 = 32 << 20;
const MAX_IMAGE_BYTES: usize = 32 << 20;
const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left", "right", "fit"];
@@ -540,7 +541,8 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
let keep = str_arg(a, "path").filter(|p| !p.trim().is_empty()).map(str::to_string);
let path = keep.clone().unwrap_or_else(|| {
let n = SHOT.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("solidcraft-mcp-{}-{n}.png", std::process::id())).to_string_lossy().to_string()
let dir = crate::scratch_dir().unwrap_or_else(std::env::temp_dir);
dir.join(format!("shot-{n}.png")).to_string_lossy().to_string()
});
let window = match str_arg(a, "source") {
Some("model") => false,
@@ -558,12 +560,9 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
} else {
(render(b)?, "model")
};
let read = std::fs::metadata(&path)
.map_err(|e| format!("{path}: {e}"))
.and_then(|m| if m.len() > MAX_IMAGE_BYTES { Err(format!("{path}: image too large")) } else { Ok(()) })
.and_then(|()| std::fs::read(&path).map_err(|e| format!("{path}: {e} (is the app on another machine?)")));
let read = vfs::read(&path, MAX_IMAGE_BYTES).map_err(|e| format!("{path}: {e} (is the app on another machine?)"));
if keep.is_none() {
let _ = std::fs::remove_file(&path);
let _ = vfs::remove(&path);
}
let png = read?;
let (w, h) = match source {