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

+10 -6
View File
@@ -170,6 +170,7 @@ fn rename_design(s: &mut Session, from: &str, to: &str) {
}
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
// Saving an unnamed design names it after its file (not an undoable edit).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed {
@@ -337,10 +338,13 @@ fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
}
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
match str_(p, "dir").filter(|d| !d.trim().is_empty() && d.len() < 4096) {
Some(d) => Ok(d.into()),
None => crate::recovery::default_dir().ok_or_else(|| bad(cmd, "no recovery folder on this system (give `dir`)")),
}
let dir = match str_(p, "dir").filter(|d| !d.trim().is_empty() && d.len() < 4096) {
Some(d) => d.into(),
None => crate::recovery::default_dir().ok_or_else(|| bad(cmd, "no recovery folder on this system (give `dir`)"))?,
};
// An agent's commands reach the recovery folder only when it is one of their folders.
solidcraft_io::vfs::check(&dir.to_string_lossy()).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(dir)
}
fn recovery_list(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -360,7 +364,7 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
s.path = entry.path.clone();
s.mark_unsaved();
if bool_(p, "discard").unwrap_or(false) {
crate::recovery::remove(&dir, id);
crate::recovery::remove(&dir, id)?;
}
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"id": id, "path": entry.path, "name": entry.name, "features": s.doc.features.len(), "errors": errors}))
@@ -375,7 +379,7 @@ fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
vec![str_(p, "id").ok_or_else(|| bad(cmd, "`id` or `all` is required"))?.to_string()]
};
for id in &ids {
crate::recovery::remove(&dir, id);
crate::recovery::remove(&dir, id)?;
}
Ok(json!({"discarded": ids}))
}
+1
View File
@@ -589,6 +589,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
} else {
serde_json::to_string_pretty(&out).unwrap_or_default()
};
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "samples": rows.len()}));
}
+9
View File
@@ -136,6 +136,15 @@ fn motion_study_plays_and_exports() {
run(&mut s, "motion.export", json!({"study": "Push", "samples": 3, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
// For an agent (#205) the export lands only inside its folders.
let root = dir.join("root");
std::fs::create_dir_all(&root).unwrap();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || {
run(&mut s, "motion.export", json!({"study": "Push", "samples": 2, "path": root.join("in.json").to_string_lossy()}));
let e = s.execute("motion.export", &json!({"study": "Push", "samples": 2, "path": dir.join("out.json").to_string_lossy()})).unwrap_err();
assert!(e.to_string().contains("outside the folders this agent may use"), "{e}");
});
assert!(root.join("in.json").exists() && !dir.join("out.json").exists());
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
+4
View File
@@ -117,6 +117,10 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
Some(Value::String(f)) => Some(f.trim().to_string()),
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
};
// A font file an agent names must lie in its folders (installed families are found by name).
if let Some(path) = font.as_deref().and_then(solidcraft_sketch::font_file_path) {
solidcraft_io::vfs::check(&path.to_string_lossy()).map_err(|e| bad(cmd, e.to_string()))?;
}
// An unknown font or an unreadable file is an error now, not a lost link later.
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font })