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
@@ -17,5 +17,52 @@ pub use backend::{Backend, Headless, Remote};
|
||||
pub use server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
|
||||
pub use tools::{call_tool, tool_definitions};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use solidcraft_engine::io::vfs::PathScope;
|
||||
|
||||
/// The folders `solidcraft-cli mcp` lets its agent read, write and delete in by default: the
|
||||
/// app's own recovery folder, this server's private scratch folder (screenshots), and each
|
||||
/// `allowed` folder (`--allow-path`). The open design's folder joins on every call when it is a
|
||||
/// safe one (`vfs::design_folder_refusal`; the server adds it). The working folder, the home
|
||||
/// folder and anything above it never count by themselves, so `~/.ssh`, `~/.bashrc` and other
|
||||
/// dotfiles are out of reach unless a folder holding them is allowed on purpose.
|
||||
pub fn agent_scope(allowed: impl IntoIterator<Item = PathBuf>) -> PathScope {
|
||||
agent_scope_with(solidcraft_engine::recovery::default_dir(), allowed)
|
||||
}
|
||||
|
||||
/// [`agent_scope`] with the recovery folder given (tests).
|
||||
pub fn agent_scope_with(recovery: Option<PathBuf>, allowed: impl IntoIterator<Item = PathBuf>) -> PathScope {
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
if let Some(d) = recovery
|
||||
&& std::fs::create_dir_all(&d).is_ok()
|
||||
{
|
||||
roots.push(d);
|
||||
}
|
||||
if let Some(d) = scratch_dir() {
|
||||
roots.push(d);
|
||||
}
|
||||
roots.extend(allowed);
|
||||
PathScope::new(roots)
|
||||
}
|
||||
|
||||
/// This server's own folder for screenshots it reads back and deletes (`screenshot` without a
|
||||
/// `path`): a new `<temp>/solidcraft-mcp-<pid>-<n>` made on first use (never one that was there
|
||||
/// already, which could be someone else's), private to the user on Unix.
|
||||
pub fn scratch_dir() -> Option<PathBuf> {
|
||||
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
|
||||
(0..16u32).find_map(|i| {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-mcp-{}-{:x}", std::process::id(), seed.wrapping_add(i)));
|
||||
b.create(&d).ok().map(|()| d)
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user