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:
1 parent
3d3f9b626f
commit
96f6424aec
23 files changed
+1000
-37
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user