forked from 1b-agent/fieldcraft-upstream
recovery::data_dir() names the per-user data folder (the temporary fieldcraft-automation folder in automated runs) that the recovery, crash and log folders live in. Roots::writes_within lets reads go anywhere while writes stay in one folder, for replaying a crash report safely. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
273 lines
12 KiB
Rust
273 lines
12 KiB
Rust
//! Folders an automation client may read from and write to.
|
|
//!
|
|
//! An agent driving FieldCraft (the MCP server, docs/mcp.md) can be kept to the folders it was
|
|
//! given: every file a command reads must lie inside the read root, every file it writes inside
|
|
//! the write root. [`Roots::check`] decides, before the file is touched:
|
|
//!
|
|
//! - The path is made absolute against the working folder, then its links are followed: a file
|
|
//! (or folder) link that leads out of a root is outside it. For a file that does not exist yet,
|
|
//! its nearest existing folder's links are followed and the rest must be plain names, so `..`
|
|
//! below a folder that does not exist is refused, as is a link that leads nowhere (writing
|
|
//! through it would create its target).
|
|
//! - The result is compared with the root by whole folder names: `/work2/x` is not inside
|
|
//! `/work`.
|
|
//! - Read and write access are separate; a root left out grants none of its access. Without
|
|
//! either root ([`Roots::default`]) nothing is confined. [`Roots::writes_within`] confines
|
|
//! writes alone (a crash replay may read the model's geometry wherever it is, and writes
|
|
//! nothing outside a scratch folder).
|
|
//!
|
|
//! A path is checked, then opened: another program that swaps a link in between can still win
|
|
//! that race, so the roots keep an agent to the files it was given rather than guarding against
|
|
//! other software on the computer.
|
|
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use crate::{Error, Result};
|
|
|
|
/// What a command does with a file.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Access {
|
|
Read,
|
|
Write,
|
|
}
|
|
|
|
impl Access {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Access::Read => "read",
|
|
Access::Write => "write",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The folders file access is confined to; the default confines nothing.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Roots {
|
|
/// `None` while unconfined; otherwise what each of read and write access is granted.
|
|
confined: Option<[Grant; 2]>,
|
|
}
|
|
|
|
/// What one kind of access is granted while confined.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Grant {
|
|
/// Not granted.
|
|
None,
|
|
/// Granted anywhere.
|
|
Anywhere,
|
|
/// Granted inside this resolved folder.
|
|
Within(PathBuf),
|
|
}
|
|
|
|
fn resolve_root(p: &Path, access: Access) -> Result<PathBuf> {
|
|
let shown = p.display();
|
|
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
|
|
if !dir.is_dir() {
|
|
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
|
|
}
|
|
Ok(dir)
|
|
}
|
|
|
|
impl Roots {
|
|
/// Nothing confined.
|
|
pub fn unconfined() -> Roots {
|
|
Roots::default()
|
|
}
|
|
|
|
/// Confine reads to `read` and writes to `write`. A root left out grants none of its access.
|
|
/// Each root must be an existing folder; it is resolved (links followed) once, here.
|
|
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Roots> {
|
|
let grant = |p: Option<&Path>, access: Access| -> Result<Grant> {
|
|
Ok(p.map(|p| resolve_root(p, access)).transpose()?.map_or(Grant::None, Grant::Within))
|
|
};
|
|
Ok(Roots { confined: Some([grant(read, Access::Read)?, grant(write, Access::Write)?]) })
|
|
}
|
|
|
|
/// Reads anywhere, writes only inside `write` (an existing folder, resolved here).
|
|
pub fn writes_within(write: &Path) -> Result<Roots> {
|
|
Ok(Roots { confined: Some([Grant::Anywhere, Grant::Within(resolve_root(write, Access::Write)?)]) })
|
|
}
|
|
|
|
/// Whether any access is confined.
|
|
pub fn is_confined(&self) -> bool {
|
|
self.confined.is_some()
|
|
}
|
|
|
|
/// The root of `access`, when confined to one.
|
|
pub fn root(&self, access: Access) -> Option<&Path> {
|
|
match self.confined.as_ref()?.get(access as usize)? {
|
|
Grant::Within(root) => Some(root),
|
|
Grant::None | Grant::Anywhere => None,
|
|
}
|
|
}
|
|
|
|
/// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path
|
|
/// (absolute, links followed), which lies inside the root. Refused with an error naming the
|
|
/// root and the path.
|
|
pub fn check(&self, path: &Path, access: Access) -> Result<PathBuf> {
|
|
let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) };
|
|
let shown = path.display();
|
|
let root = match roots.get(access as usize) {
|
|
Some(Grant::Anywhere) => return Ok(path.to_path_buf()),
|
|
Some(Grant::Within(root)) => root,
|
|
Some(Grant::None) | None => {
|
|
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name())));
|
|
}
|
|
};
|
|
let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?;
|
|
if !resolved.starts_with(root) {
|
|
return Err(Error::Refused(format!("automation path rejected: outside the {} root {}: {shown}", access.name(), root.display())));
|
|
}
|
|
Ok(resolved)
|
|
}
|
|
}
|
|
|
|
/// `path` absolute, its links followed: wholly when it exists, else up to its nearest existing
|
|
/// folder, below which only plain names may follow.
|
|
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
|
|
if path.as_os_str().is_empty() {
|
|
return Err("empty path".into());
|
|
}
|
|
#[cfg(windows)]
|
|
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
|
|
const DEVICES: [&str; 22] = [
|
|
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4",
|
|
"LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
];
|
|
if DEVICES.iter().any(|d| d.eq_ignore_ascii_case(name.trim_end())) {
|
|
return Err("a device name".into());
|
|
}
|
|
}
|
|
let absolute = std::path::absolute(path).map_err(|e| e.to_string())?;
|
|
if let Ok(p) = std::fs::canonicalize(&absolute) {
|
|
return Ok(p);
|
|
}
|
|
// A link that leads nowhere: writing through it would create its target.
|
|
if std::fs::symlink_metadata(&absolute).is_ok() {
|
|
return Err("a link that leads nowhere".into());
|
|
}
|
|
let mut rest = Vec::new();
|
|
let mut base = absolute.as_path();
|
|
loop {
|
|
let Some(parent) = base.parent() else { return Err("no existing folder above it".into()) };
|
|
let Some(name) = base.file_name() else { return Err("not a plain file name".into()) };
|
|
rest.push(name.to_os_string());
|
|
base = parent;
|
|
if let Ok(dir) = std::fs::canonicalize(base) {
|
|
if !dir.is_dir() {
|
|
return Err(format!("{} is not a folder", base.display()));
|
|
}
|
|
let mut out = dir;
|
|
for name in rest.iter().rev() {
|
|
out.push(name);
|
|
}
|
|
return Ok(out);
|
|
}
|
|
if std::fs::symlink_metadata(base).is_ok() {
|
|
return Err("a link that leads nowhere".into());
|
|
}
|
|
// `..` or `.` below a folder that does not exist cannot be resolved.
|
|
if !matches!(Path::new(base).components().next_back(), Some(Component::Normal(_))) {
|
|
return Err("`..` below a folder that does not exist".into());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct Dir(PathBuf);
|
|
impl Dir {
|
|
fn new(tag: &str) -> Dir {
|
|
let d = std::env::temp_dir().join(format!("fieldcraft-roots-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
Dir(std::fs::canonicalize(&d).unwrap())
|
|
}
|
|
}
|
|
impl Drop for Dir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unconfined_changes_nothing() {
|
|
let r = Roots::unconfined();
|
|
assert!(!r.is_confined());
|
|
assert_eq!(r.check(Path::new("/etc/passwd"), Access::Write).unwrap(), Path::new("/etc/passwd"));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_and_writes_stay_inside_their_roots() {
|
|
let d = Dir::new("inside");
|
|
let (read, write) = (d.0.join("in"), d.0.join("in").join("out"));
|
|
std::fs::create_dir_all(&write).unwrap();
|
|
std::fs::write(read.join("a.solidcraft"), b"x").unwrap();
|
|
let r = Roots::new(Some(&read), Some(&write)).unwrap();
|
|
assert_eq!(r.check(&read.join("a.solidcraft"), Access::Read).unwrap(), read.join("a.solidcraft"));
|
|
assert_eq!(r.check(&write.join("new.inp"), Access::Write).unwrap(), write.join("new.inp"));
|
|
// A new file in a new folder below the root: plain names only.
|
|
assert_eq!(r.check(&write.join("x/y/z.inp"), Access::Write).unwrap(), write.join("x/y/z.inp"));
|
|
assert!(r.check(&write.join("x/../../../z.inp"), Access::Write).is_err());
|
|
// `..` through an existing folder resolves first, then is compared.
|
|
assert!(r.check(&write.join("../a.solidcraft"), Access::Write).is_err());
|
|
assert!(r.check(&write.join("../a.solidcraft"), Access::Read).is_ok());
|
|
let e = r.check(&read.join("x.png"), Access::Write).unwrap_err().to_string();
|
|
assert!(e.contains("outside the write root"), "{e}");
|
|
assert!(r.check(Path::new("/etc/passwd"), Access::Read).is_err());
|
|
// Whole folder names: `in2` is not inside `in`.
|
|
std::fs::create_dir_all(d.0.join("in2")).unwrap();
|
|
assert!(r.check(&d.0.join("in2/a"), Access::Read).is_err());
|
|
assert!(r.check(Path::new(""), Access::Read).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_root_left_out_grants_nothing() {
|
|
let d = Dir::new("readonly");
|
|
let r = Roots::new(Some(&d.0), None).unwrap();
|
|
assert!(r.is_confined());
|
|
let e = r.check(&d.0.join("x.inp"), Access::Write).unwrap_err().to_string();
|
|
assert!(e.contains("write authority is absent"), "{e}");
|
|
assert!(r.check(&d.0.join("x.inp"), Access::Read).is_ok());
|
|
assert!(Roots::new(Some(&d.0.join("missing")), None).is_err());
|
|
std::fs::write(d.0.join("file"), b"").unwrap();
|
|
assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder");
|
|
}
|
|
|
|
#[test]
|
|
fn writes_within_reads_anywhere() {
|
|
let d = Dir::new("writes");
|
|
let r = Roots::writes_within(&d.0).unwrap();
|
|
assert!(r.is_confined());
|
|
assert_eq!(r.check(Path::new("/etc/passwd"), Access::Read).unwrap(), Path::new("/etc/passwd"));
|
|
assert!(r.check(Path::new("/etc/passwd"), Access::Write).is_err());
|
|
assert_eq!(r.check(&d.0.join("x.inp"), Access::Write).unwrap(), d.0.join("x.inp"));
|
|
assert_eq!(r.root(Access::Write), Some(d.0.as_path()));
|
|
assert_eq!(r.root(Access::Read), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn links_are_followed() {
|
|
let d = Dir::new("links");
|
|
let (root, outside) = (d.0.join("root"), d.0.join("outside"));
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::create_dir_all(&outside).unwrap();
|
|
std::fs::write(outside.join("secret"), b"x").unwrap();
|
|
std::os::unix::fs::symlink(outside.join("secret"), root.join("file-link")).unwrap();
|
|
std::os::unix::fs::symlink(&outside, root.join("dir-link")).unwrap();
|
|
std::os::unix::fs::symlink(outside.join("nowhere"), root.join("dangling")).unwrap();
|
|
std::os::unix::fs::symlink(&root, outside.join("back")).unwrap();
|
|
let r = Roots::new(Some(&root), Some(&root)).unwrap();
|
|
for p in [root.join("file-link"), root.join("dir-link/secret"), root.join("dir-link/new.inp")] {
|
|
for a in [Access::Read, Access::Write] {
|
|
assert!(r.check(&p, a).is_err(), "{} {a:?}", p.display());
|
|
}
|
|
}
|
|
assert!(r.check(&root.join("dangling"), Access::Write).unwrap_err().to_string().contains("leads nowhere"));
|
|
// A link from outside that leads in is inside.
|
|
assert_eq!(r.check(&outside.join("back/x.inp"), Access::Write).unwrap(), root.join("x.inp"));
|
|
}
|
|
}
|