MCP: an agent's folders hold for file loads through the app and for a design's font files (#1112)

doc.open and the STEP/mesh imports run on the file loader's worker with the request's path scope, and a font named by file path in a design is checked against the scope before it is read.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-14 13:58:56 -04:00
1 parent 6de8f6db95
commit c469adf456
8 files changed
+122 -30

No files matched your search

+43 -8
View File
@@ -159,20 +159,28 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
ok(json!({"completed": results.len(), "results": results, "undone": 0}))
}
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
// A request from an MCP server carries the folders its agent may use; every file the
// request reaches must lie inside them (docs/mcp.md).
let Some(allowed) = req.params.get("allow_paths") else { return dispatch(app, ctx, &req.method, &req.params) };
/// A request from an MCP server carries the folders its agent may use (`allow_paths`); every
/// file the request reaches must lie inside them (docs/mcp.md). `None`: it names none.
pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathScope>, String> {
let Some(allowed) = params.get("allow_paths") else { return Ok(None) };
let Some(roots) = allowed.as_array().and_then(|a| a.iter().map(|r| r.as_str().map(std::path::PathBuf::from)).collect::<Option<Vec<_>>>()) else {
return err("`allow_paths` must be a list of folders");
return Err("`allow_paths` must be a list of folders".into());
};
// Plus the app's own recovery folder and, when it is a safe one, the open design's folder.
let roots = roots.into_iter().chain(solidcraft_engine::recovery::default_dir());
Ok(Some(vfs::PathScope::new(roots).with_design(app.session.path.as_deref())))
}
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let scope = match request_scope(app, &req.params) {
Ok(Some(scope)) => scope,
Ok(None) => return dispatch(app, ctx, &req.method, &req.params),
Err(e) => return err(e),
};
let mut p = req.params.clone();
if let Some(o) = p.as_object_mut() {
o.remove("allow_paths");
}
// Plus the app's own recovery folder and, when it is a safe one, the open design's folder.
let roots = roots.into_iter().chain(solidcraft_engine::recovery::default_dir());
let scope = vfs::PathScope::new(roots).with_design(app.session.path.as_deref());
vfs::scoped(Some(scope), || dispatch(app, ctx, &req.method, &p))
}
@@ -867,6 +875,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
/// An MCP server's `doc.open` and imports load off the window thread (`file_load`), where
/// the agent's folders hold as they do for every other request.
#[test]
fn file_loads_stay_inside_allow_paths() {
let mut h = crate::scenario::Harness::new();
let base = vfs::temp_dir().join(format!("solidcraft-load-scope-{}", std::process::id()));
let (root, out) = (base.join("root"), base.join("out"));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&out).unwrap();
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
for d in [&root, &out] {
std::fs::write(d.join("part.solidcraft"), solidcraft_engine::io::write_design(&s.doc)).unwrap();
}
s.execute("file.export", &json!({"path": out.join("part.step").to_string_lossy()})).unwrap();
let load = |command: &str, d: &std::path::Path, file: &str| json!({"command": command, "params": {"path": d.join(file).to_string_lossy()}, "allow_paths": [root.to_string_lossy()]});
for (command, file) in [("doc.open", "part.solidcraft"), ("file.insert_step", "part.step")] {
let (v, _) = h.call_queued("engine.execute", load(command, &out, file));
assert!(v["ok"] == false && v.to_string().contains("outside the folders this agent may use"), "{command}: {v}");
}
let (v, _) = h.call_queued("engine.execute", load("doc.open", &root, "part.solidcraft"));
assert_eq!(v["ok"], true, "{v}");
let (v, _) = h.call_queued("engine.execute", json!({"command": "doc.open", "params": {"path": "x"}, "allow_paths": "/"}));
assert!(v["error"].as_str().is_some_and(|e| e.contains("allow_paths")), "{v}");
let _ = std::fs::remove_dir_all(&base);
}
/// Each step runs as `engine.execute` runs it: same results, same design.
#[test]
fn engine_batch_matches_engine_execute() {
+30 -8
View File
@@ -37,11 +37,13 @@ pub struct Request {
pub kind: Kind,
/// A control-channel caller waiting for the command's reply.
pub reply: Option<Sender<ControlResponse>>,
/// The folders that caller's agent may use: the worker reads the file inside them.
pub scope: Option<solidcraft_engine::io::vfs::PathScope>,
}
impl Request {
pub fn new(path: &str, kind: Kind) -> Self {
Request { path: path.into(), kind, reply: None }
Request { path: path.into(), kind, reply: None, scope: None }
}
fn insert(&self) -> bool {
self.kind == Kind::Insert
@@ -180,7 +182,14 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
_ => return Some(req),
};
let Some(path) = req.params.get("params").and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()) });
let scope = match crate::control::request_scope(app, &req.params) {
Ok(scope) => scope,
Err(e) => {
let _ = req.reply.send(json!({"ok": false, "error": e}));
return None;
}
};
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope });
None
}
@@ -238,6 +247,7 @@ fn start(app: &mut SolveApp) {
let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
let (command, params) = request.command();
let scope = request.scope.clone();
let thumbnail = request.kind == Kind::Open;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let preview_began = std::time::Instant::now();
@@ -256,9 +266,10 @@ fn start(app: &mut SolveApp) {
}
}));
}
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, command, &params, thumbnail, &worker_phase, &worker_progress)))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
compute(scratch, command, &params, scope, thumbnail, &worker_phase, &worker_progress)
}))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let _ = tx.send(result);
});
match spawned {
@@ -364,15 +375,26 @@ fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcra
}
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, command: &str, params: &Value, thumbnail: bool, phase: &AtomicU8, progress: &Arc<Progress>) -> Job {
fn compute(
mut session: Session,
command: &str,
params: &Value,
scope: Option<solidcraft_engine::io::vfs::PathScope>,
thumbnail: bool,
phase: &AtomicU8,
progress: &Arc<Progress>,
) -> Job {
let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some();
let began = std::time::Instant::now();
let cancelled = || Err("file operation cancelled".to_string());
if progress.is_cancelled() {
return cancelled();
}
// The timeline's rebuild reports each feature and stops at the next one after Cancel.
let result = solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)).map_err(|e| e.to_string())?;
// The timeline's rebuild reports each feature and stops at the next one after Cancel. An
// agent's scope is per thread, so it is set again here.
let result =
solidcraft_engine::io::vfs::scoped(scope, || solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)))
.map_err(|e| e.to_string())?;
if progress.is_cancelled() {
return cancelled();
}