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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user