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() {