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
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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()}));
|
||||
}
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -130,13 +130,19 @@ pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> {
|
||||
Ok((solidcraft_io::read_design(&bytes)?, entry))
|
||||
}
|
||||
|
||||
/// Delete an entry.
|
||||
pub fn remove(dir: &Path, id: &str) {
|
||||
if valid_id(id) {
|
||||
for ext in ["json", solidcraft_io::DESIGN_EXT, solidcraft_io::LEGACY_DESIGN_EXT, "lock"] {
|
||||
let _ = fs::remove_file(file(dir, id, ext));
|
||||
}
|
||||
/// Delete an entry. Only a real one: `<id>.json` must describe entry `id` and its design must
|
||||
/// be there, so a folder and id that merely name someone's `part.solidcraft` delete nothing.
|
||||
pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
let is_entry = valid_id(id)
|
||||
&& fs::read(file(dir, id, "json")).ok().and_then(|b| serde_json::from_slice::<Entry>(&b).ok()).is_some_and(|e| e.id == id)
|
||||
&& design_file(dir, id).is_file();
|
||||
if !is_entry {
|
||||
return Err(EngineError::Other(format!("no recovered design `{id}`")));
|
||||
}
|
||||
for ext in ["json", solidcraft_io::DESIGN_EXT, solidcraft_io::LEGACY_DESIGN_EXT, "lock"] {
|
||||
let _ = fs::remove_file(file(dir, id, ext));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One app's autosaver.
|
||||
@@ -241,7 +247,7 @@ mod tests {
|
||||
assert_eq!(orphans(&d).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [id]);
|
||||
let (_, loaded) = load(&d, id).unwrap();
|
||||
assert_eq!(loaded, entry);
|
||||
remove(&d, id);
|
||||
remove(&d, id).unwrap();
|
||||
assert!(orphans(&d).is_empty() && !d.join(format!("{id}.solvecraft")).exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -282,7 +288,7 @@ mod tests {
|
||||
let (doc, entry) = load(&d, &found[0].id).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
assert_eq!(entry.features, 1);
|
||||
remove(&d, &entry.id);
|
||||
remove(&d, &entry.id).unwrap();
|
||||
assert!(orphans(&d).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -302,6 +308,53 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// `doc.recovery_discard` deletes recovery entries only (#205): a folder and id that merely
|
||||
/// name someone's design, or a `<id>.json` describing another entry, delete nothing.
|
||||
#[test]
|
||||
fn discarding_refuses_what_is_not_a_recovery_entry() {
|
||||
let d = dir("discard");
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
let part = d.join("part.solidcraft");
|
||||
fs::write(&part, solidcraft_io::write_design(&Document::default())).unwrap();
|
||||
let mut s = Session::default();
|
||||
let discard = |s: &mut Session, id: &str| s.execute("doc.recovery_discard", &json!({"dir": d.to_string_lossy(), "id": id}));
|
||||
let e = discard(&mut s, "part").unwrap_err();
|
||||
assert!(e.to_string().contains("no recovered design `part`"), "{e}");
|
||||
let other = Entry { id: "other".into(), path: None, name: "x".into(), saved_at: 1, features: 0 };
|
||||
fs::write(d.join("part.json"), serde_json::to_vec(&other).unwrap()).unwrap();
|
||||
assert!(discard(&mut s, "part").is_err());
|
||||
assert!(discard(&mut s, "../part").is_err());
|
||||
assert!(part.exists() && d.join("part.json").exists(), "nothing deleted");
|
||||
// A real entry still goes.
|
||||
let mine = Entry { id: "part".into(), ..other };
|
||||
fs::write(d.join("part.json"), serde_json::to_vec(&mine).unwrap()).unwrap();
|
||||
assert_eq!(discard(&mut s, "part").unwrap()["discarded"], json!(["part"]));
|
||||
assert!(!part.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// `doc.recover {discard: true}` opens a real entry and then deletes it; given a folder and
|
||||
/// id that only name someone's design, it deletes nothing (#205 review).
|
||||
#[test]
|
||||
fn recover_with_discard_deletes_only_the_entry_it_opened() {
|
||||
let d = dir("recover-discard");
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
let id = a.id.clone();
|
||||
drop(a);
|
||||
let mut t = Session::default();
|
||||
let r = t.execute("doc.recover", &json!({"dir": d.to_string_lossy(), "id": id, "discard": true})).unwrap();
|
||||
assert_eq!(r["features"], 1, "{r}");
|
||||
assert!(orphans(&d).is_empty() && !file(&d, &id, "json").exists() && !file(&d, &id, "solidcraft").exists());
|
||||
let part = d.join("part.solidcraft");
|
||||
fs::write(&part, solidcraft_io::write_design(&Document::default())).unwrap();
|
||||
assert!(t.execute("doc.recover", &json!({"dir": d.to_string_lossy(), "id": "part", "discard": true})).is_err());
|
||||
assert!(part.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
|
||||
let d = dir("clean");
|
||||
|
||||
@@ -198,6 +198,7 @@ fn join_meeting_nothing_keeps_the_first_body() {
|
||||
mod boolean_diagnostics;
|
||||
mod component_frames;
|
||||
mod mirror_planes;
|
||||
mod path_scope;
|
||||
mod primitive_points;
|
||||
mod robustness;
|
||||
mod robustness_goldens;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Commands run for an agent (#205): with a `vfs::PathScope` active, every command that reads,
|
||||
//! writes or deletes a file it was given a path for stays inside the scope's folders.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_io::vfs::{PathScope, scoped};
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[test]
|
||||
fn file_commands_stay_inside_the_agents_folders() {
|
||||
let base = std::env::temp_dir().join(format!("solidcraft-path-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 mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
std::fs::write(out.join("part.solidcraft"), solidcraft_io::write_design(&s.doc)).unwrap();
|
||||
std::fs::write(out.join("p.csv"), "name,expression\nw,5 mm\n").unwrap();
|
||||
|
||||
scoped(Some(PathScope::new([root.clone()])), || {
|
||||
// Inside: the usual workflow.
|
||||
s.execute("file.save_as", &json!({"path": at(&root, "part.solidcraft")})).unwrap();
|
||||
s.execute("file.export", &json!({"path": at(&root, "part.step")})).unwrap();
|
||||
s.execute("doc.open", &json!({"path": at(&root, "part.solidcraft")})).unwrap();
|
||||
// Outside, directly or through `..`: refused, with the reason.
|
||||
for (cmd, p) in [
|
||||
("file.save_as", json!({"path": at(&out, "x.solidcraft")})),
|
||||
("file.save", json!({"path": at(&root, "../out/x.solidcraft")})),
|
||||
("file.export", json!({"path": at(&out, "x.stl")})),
|
||||
("file.export", json!({"path": at(&out, "x.json"), "format": "json"})),
|
||||
("file.save_as", json!({"path": at(&out, "x.json"), "format": "json", "preview": true})),
|
||||
("parameters.export", json!({"path": at(&out, "x.csv")})),
|
||||
("doc.open", json!({"path": at(&out, "part.solidcraft")})),
|
||||
("parameters.import", json!({"path": at(&out, "p.csv")})),
|
||||
("sketch.insert_dxf", json!({"path": at(&out, "a.dxf")})),
|
||||
("doc.recovery_list", json!({"dir": out.to_string_lossy()})),
|
||||
("doc.recovery_discard", json!({"dir": out.to_string_lossy(), "id": "part"})),
|
||||
("doc.recover", json!({"dir": out.to_string_lossy(), "id": "part", "discard": true})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the folders this agent may use"), "{cmd}: {e}");
|
||||
}
|
||||
// A font file named by path is a file read too; the built-in font still works.
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let e = s.execute("sketch.text", &json!({"text": "A", "at": [0, 0], "font": at(&out, "f.ttf")})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the folders this agent may use"), "sketch.text: {e}");
|
||||
s.execute("sketch.text", &json!({"text": "A", "at": [0, 0]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
// Saving follows a link to its file (#845), so a link that leads out is refused.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(out.join("part.solidcraft"), root.join("linked.solidcraft")).unwrap();
|
||||
let e = s.execute("file.save_as", &json!({"path": at(&root, "linked.solidcraft")})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the folders this agent may use"), "save through a link: {e}");
|
||||
}
|
||||
// The default recovery folder isn't one of the agent's folders either.
|
||||
assert!(s.execute("doc.recovery_list", &json!({})).is_err());
|
||||
});
|
||||
let left: Vec<_> = std::fs::read_dir(&out).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(left.len(), 2, "nothing written or deleted outside: {left:?}");
|
||||
// Without a scope (the user's own dialogs) nothing changes.
|
||||
s.execute("file.export", &json!({"path": at(&out, "x.stl")})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
Reference in new issue
Block a user