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

+14 -1
View File
@@ -15,6 +15,11 @@ pub trait Backend {
/// True when a real window is attached (`ui.screenshot` can capture it).
fn has_ui(&self) -> bool;
fn describe(&self) -> String;
/// The open design's file, when this side knows it (its folder joins the agent's folders,
/// see `vfs::PathScope::with_design`). A running app adds its own, so `Remote` says nothing.
fn design_path(&self) -> Option<String> {
None
}
}
/// Longest reply line accepted from the app.
@@ -85,6 +90,11 @@ impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let mut params = params;
// The app confines the files this request reaches to the agent's folders (control.rs).
if let (Some(scope), Some(o)) = (solidcraft_engine::io::vfs::current_scope(), params.as_object_mut()) {
o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
}
let line = json!({"id": id, "method": method, "params": params}).to_string();
let reply = match self.roundtrip(&line) {
Ok(r) => r,
@@ -170,7 +180,7 @@ impl Backend for Headless {
let scene = solidcraft_engine::view::scene(s, &cam);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
let path = str_p("path").ok_or("missing `path`")?;
std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
solidcraft_engine::io::vfs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
Ok(json!({"path": path, "bytes": png.len(), "width": w, "height": h}))
}
"ui.screenshot" => Err("no window in headless mode; use ui.render".into()),
@@ -183,4 +193,7 @@ impl Backend for Headless {
fn describe(&self) -> String {
"headless in-process SolidCraft session".into()
}
fn design_path(&self) -> Option<String> {
self.session.path.clone()
}
}
+47
View File
@@ -17,5 +17,52 @@ pub use backend::{Backend, Headless, Remote};
pub use server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
pub use tools::{call_tool, tool_definitions};
use std::path::PathBuf;
use solidcraft_engine::io::vfs::PathScope;
/// The folders `solidcraft-cli mcp` lets its agent read, write and delete in by default: the
/// app's own recovery folder, this server's private scratch folder (screenshots), and each
/// `allowed` folder (`--allow-path`). The open design's folder joins on every call when it is a
/// safe one (`vfs::design_folder_refusal`; the server adds it). The working folder, the home
/// folder and anything above it never count by themselves, so `~/.ssh`, `~/.bashrc` and other
/// dotfiles are out of reach unless a folder holding them is allowed on purpose.
pub fn agent_scope(allowed: impl IntoIterator<Item = PathBuf>) -> PathScope {
agent_scope_with(solidcraft_engine::recovery::default_dir(), allowed)
}
/// [`agent_scope`] with the recovery folder given (tests).
pub fn agent_scope_with(recovery: Option<PathBuf>, allowed: impl IntoIterator<Item = PathBuf>) -> PathScope {
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(d) = recovery
&& std::fs::create_dir_all(&d).is_ok()
{
roots.push(d);
}
if let Some(d) = scratch_dir() {
roots.push(d);
}
roots.extend(allowed);
PathScope::new(roots)
}
/// This server's own folder for screenshots it reads back and deletes (`screenshot` without a
/// `path`): a new `<temp>/solidcraft-mcp-<pid>-<n>` made on first use (never one that was there
/// already, which could be someone else's), private to the user on Unix.
pub fn scratch_dir() -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
(0..16u32).find_map(|i| {
let d = std::env::temp_dir().join(format!("solidcraft-mcp-{}-{:x}", std::process::id(), seed.wrapping_add(i)));
b.create(&d).ok().map(|()| d)
})
})
.clone()
}
#[cfg(test)]
mod tests;
+14 -2
View File
@@ -6,6 +6,8 @@ use serde_json::{Value, json};
/// Longest accepted request line, shared with the control server.
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::io::vfs::{self, PathScope};
use crate::backend::Backend;
use crate::tools::{call_tool, check_arguments, tool_definitions};
@@ -35,6 +37,7 @@ edge], radius}`, `solid.chamfer`, patterns, mirror, primitives). Use `batch` for
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
scope: Option<PathScope>,
}
fn response(id: Value, result: Value) -> Value {
@@ -46,7 +49,13 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
Self { backend, initialized: false, scope: None }
}
/// Confine the files tool calls may reach to `scope` (`None`: anywhere). `solidcraft-cli mcp`
/// always sets one unless started with `--allow-any-path` (docs/mcp.md).
pub fn with_path_scope(mut self, scope: Option<PathScope>) -> Self {
self.scope = scope;
self
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
@@ -146,7 +155,10 @@ impl Server {
Some(a) => a.clone(),
};
check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?;
Ok(call_tool(self.backend.as_mut(), name, &args))
// The open design's folder counts as it is now (a save-as or open moves it).
let scope = self.scope.clone().map(|s| s.with_design(self.backend.design_path().as_deref()));
let backend = self.backend.as_mut();
Ok(vfs::scoped(scope, || call_tool(backend, name, &args)))
}
"resources/list" => Ok(json!({"resources": [
{"uri": "solidcraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"},
+115
View File
@@ -439,6 +439,121 @@ fn legacy_design_files_and_resource_uris() {
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
}
/// The default folders (#205): the recovery folder works out of the box, `screenshot` without a
/// `path` uses the server's own scratch folder, and the open design's folder counts while the
/// design is open there, without its hidden files. The working folder doesn't count at all.
#[test]
fn the_default_folders_are_the_designs_the_recovery_folder_and_the_scratch_folder() {
let base = std::env::temp_dir().join(format!("solidcraft-mcp-defaults-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (proj, other, recovery) = (base.join("proj"), base.join("other"), base.join("recovery"));
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(proj.join(".env"), "TOKEN=secret").unwrap();
let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string();
let scope = crate::agent_scope_with(Some(recovery.clone()), []);
let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
assert!(!scope.roots().contains(&cwd), "the working folder is not a default folder: {scope:?}");
assert!(scope.roots().contains(&std::fs::canonicalize(&recovery).unwrap()), "{scope:?}");
assert!(scope.roots().contains(&crate::scratch_dir().unwrap()), "{scope:?}");
let mut session = solidcraft_engine::Session::default();
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
session.execute("file.save_as", &json!({"path": at(&proj, "part.solidcraft")})).unwrap();
let mut s = Server::new(Box::new(Headless::new(session))).with_path_scope(Some(scope));
// Legitimate work in the design's folder: export, save, open, screenshots, the recovery folder.
tool(&mut s, "export", json!({"path": at(&proj, "part.step")}));
tool(&mut s, "execute", json!({"command": "parameters.export", "params": {"path": at(&proj, "params.csv")}}));
tool(&mut s, "save", json!({"path": at(&proj, "copy.solidcraft")}));
tool(&mut s, "open", json!({"path": at(&proj, "part.solidcraft")}));
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {"dir": recovery.to_string_lossy()}}));
assert!(proj.join("part.step").exists() && proj.join("copy.solidcraft").exists());
// Hidden files in it, and folders beside it, stay out of reach.
for (name, args) in [
("export", json!({"path": at(&proj, ".env")})),
("execute", json!({"command": "parameters.import", "params": {"path": at(&proj, ".env")}})),
("export", json!({"path": at(&proj, ".git/x.step")})),
("export", json!({"path": at(&other, "x.step")})),
] {
let r = raw_tool(&mut s, name, args);
assert_eq!(r["result"]["isError"], json!(true), "{name}: {r}");
}
assert_eq!(std::fs::read_to_string(proj.join(".env")).unwrap(), "TOKEN=secret");
assert_eq!(std::fs::read_dir(&other).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&base);
}
/// Tool calls reach files only inside the server's folders (#205): the usual workflow (save,
/// export, open, screenshots) works there; a path outside, directly, through `..` or through a
/// symlink that leads out, is refused with a clear error and nothing is written.
#[test]
fn tool_calls_stay_inside_the_agents_folders() {
use solidcraft_engine::io::vfs::PathScope;
let base = std::env::temp_dir().join(format!("solidcraft-mcp-scope-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (root, out) = (base.join("root"), base.join("out"));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&out).unwrap();
let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string();
let defaults = crate::agent_scope_with(None, [root.clone()]);
assert!(defaults.roots().contains(&std::fs::canonicalize(&root).unwrap()), "{defaults:?}");
assert!(!defaults.roots().contains(&std::fs::canonicalize(std::env::temp_dir()).unwrap()), "{defaults:?}");
// Only `root` here, to tell inside from outside within the temporary folder.
let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(PathScope::new([root.clone()])));
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
tool(&mut s, "save", json!({"path": at(&root, "part.solidcraft")}));
tool(&mut s, "export", json!({"path": at(&root, "part.step")}));
tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")}));
tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64}));
let mut escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")];
#[cfg(unix)]
{
std::os::unix::fs::symlink(&out, root.join("link")).unwrap();
escapes.push(at(&root, "link/x.step"));
}
for p in escapes {
for (name, args) in [
("export", json!({"path": p})),
("save", json!({"path": p})),
("open", json!({"path": p})),
("screenshot", json!({"path": p, "width": 64, "height": 64})),
("execute", json!({"command": "file.save_as", "params": {"path": p}})),
("batch", json!({"commands": [{"command": "file.export", "params": {"path": p}}]})),
] {
let r = raw_tool(&mut s, name, args);
assert_eq!(r["result"]["isError"], json!(true), "{name} {p}: {r}");
assert!(r.to_string().contains("outside the folders this agent may use"), "{name} {p}: {r}");
}
}
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written outside");
let _ = std::fs::remove_dir_all(&base);
}
/// A bridged server sends the agent's folders with every request, so the app confines the
/// request's files the same way (`allow_paths`, crates/ui-egui/src/control.rs).
#[test]
fn a_bridged_request_carries_the_agents_folders() {
use std::io::{BufRead, BufReader, Write};
use solidcraft_engine::io::vfs::{PathScope, scoped};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let echo = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": req["params"]})).unwrap();
});
let mut b = crate::Remote::connect(&addr).unwrap();
let dir = std::fs::canonicalize(std::env::temp_dir()).unwrap();
let got = scoped(Some(PathScope::new([dir.clone()])), || crate::Backend::call(&mut b, "engine.execute", json!({"command": "file.new"}))).unwrap();
assert_eq!(got["allow_paths"], json!([dir.to_string_lossy()]), "{got}");
assert_eq!(got["command"], "file.new");
echo.join().unwrap();
}
/// #528: an agent saves the angle of a product photo as a named view once, then renders the
/// model from that view after each change. The render must use the saved camera, not a
/// standard view.
+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 {