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.
67 lines
4.0 KiB
Rust
67 lines
4.0 KiB
Rust
//! 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);
|
|
}
|