MCP: confine file access with --automation-read-root and --automation-write-root (#769) (#798)

* Engine: check automation read and write roots on every path a command reads or writes (#769)

* MCP: confine the headless session to the automation roots (#769)

* CLI: add --automation-read-root and --automation-write-root to mcp (#769)

* Docs: document --automation-read-root and --automation-write-root for mcp (#769)

* Review fixes for #798: confine file.printPdf; guard all of engine/src

- print.rs::print_pdf_to_file (file.printPdf, added on main) wrote with std::fs::write
  directly, so a confined agent could print a PDF anywhere. It now goes through
  io::write_file, which checks the automation write root.
- The guard test now walks every file under crates/engine/src (not only cmd/), except
  io.rs, file_access.rs and the tests*.rs modules, and also flags OpenOptions.
- Tests: an engine test that file.printPdf is refused outside the write root, works inside
  and is unchanged without roots; the MCP integration test covers it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #798: list file.printPdf among the confined writes in docs/mcp.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font files added by path go through io, inside the automation read root (#769)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 07:18:00 -04:00
1 parent 691e887004
commit 64319e0039
15 files changed
+1038 -46

No files matched your search

+1
View File
@@ -151,6 +151,7 @@ WordCraft was designed to be driven by people *and* by AI agents.
```sh
claude mcp add wordcraft -- wordcraft-cli mcp # headless documents
# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
wordcraft --control 7981 & # or drive the running app…
claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981
```
+90
View File
@@ -0,0 +1,90 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--author", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("report.docx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+100 -3
View File
@@ -1,6 +1,8 @@
//! `wordcraft-cli`: WordCraft from the command line.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod automation_args;
use std::fmt;
use std::io::Write;
use std::process::ExitCode;
@@ -25,8 +27,14 @@ USAGE:
who tracked changes and comments are by)
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT | --author NAME]
MCP server on stdio (headless, or bridged to the app)
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
MCP server on stdio (headless, or bridged to the app).
--automation-read-root / --automation-write-root confine
the files the agent's commands read (open, insert text
or picture, mail-merge data, templates…) and write (save,
export, merge output…) to DIR, links followed; a root left
out grants none of its access. They apply to the headless
server and are an error with --connect.
wordcraft-cli zotero <command> <file> [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P]
run a Zotero command on a document (Zotero must be
running): addEditCitation, addEditBibliography,
@@ -76,6 +84,26 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
Ok(())
}
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
/// that are left. A malformed root flag is an error, never a root silently left out.
fn automation_roots(
command: &str,
args: &[String],
) -> Result<(Option<std::sync::Arc<wordcraft_engine::file_access::AutomationRoots>>, Vec<String>), String> {
let mut found = automation_args::AutomationArgs::new(None, None);
let mut left = Vec::new();
let mut it = args.iter().cloned();
while let Some(arg) = it.next() {
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
left.push(arg);
}
}
let roots = wordcraft_engine::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
.map_err(|e| format!("{command}: {e}"))?;
Ok((roots, left))
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
@@ -265,9 +293,17 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
}
"mcp" => {
let (roots, rest) = automation_roots("mcp", &rest)?;
validate_options("mcp", &rest, MCP_OPTIONS)?;
let author = arg_value(&rest, "--author");
let backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
let connect = arg_value(&rest, "--connect");
if roots.is_some() && connect.is_some() {
return Err(
"mcp: --automation-read-root and --automation-write-root confine the headless server; a running app is not confined by them"
.into(),
);
}
let backend: Box<dyn wordcraft_mcp::Backend> = match connect {
// The app's user name is the person's own setting; don't rewrite it from a flag.
Some(_) if author.is_some() => {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
@@ -281,6 +317,13 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
wordcraft_engine::file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("wordcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
headless = headless.with_automation_roots(Some(roots));
}
if let Some(name) = author {
set_author(&mut headless.session, &name)?;
}
@@ -428,4 +471,58 @@ mod tests {
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
}
#[test]
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
let (roots, left) =
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
.unwrap();
let roots = roots.unwrap();
assert_eq!(left, args(&["--author", "Claude"]));
assert!(roots.read_root().is_some() && roots.write_root().is_some());
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
// A root left out grants none of its access.
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
let roots = roots.unwrap();
assert!(roots.write_root().is_none());
assert!(
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
);
// No flags: nothing is confined and the arguments pass through.
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
assert!(roots.is_none());
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
for bad in [
args(&["--automation-read-root"]),
args(&["--automation-read-root", "--author"]),
args(&["--automation-write-root="]),
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
] {
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
}
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
// Through the command line: an error, not a server that quietly runs unconfined.
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
}
+1 -2
View File
@@ -233,8 +233,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
let png = img.to_png();
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
+2 -8
View File
@@ -33,14 +33,8 @@ fn font_json(f: &UserFont) -> Value {
/// Read a font file named by the user, refusing one larger than the cap before reading it.
#[cfg(not(target_arch = "wasm32"))]
fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
let meta = std::fs::metadata(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
if !meta.is_file() {
return Err(CmdError::Failed(format!("{path}: not a file")));
}
if meta.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES as u64 {
return Err(CmdError::Failed(format!("{path}: the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
}
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
// Through `io`, which keeps automation inside its read root (#769).
crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed)
}
fn add(s: &mut Session, v: &Value) -> CmdResult {
+1 -9
View File
@@ -187,15 +187,7 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
let bytes = if let Some(d) = p::str(v, "data") {
base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
} else if let Some(path) = p::str(v, "path") {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
}
crate::io::read_file(path).map_err(CmdError::Failed)?
} else {
s.ui_requests.push(json!({"open": "insertPicture"}));
return sel_result(s);
+1 -7
View File
@@ -320,14 +320,8 @@ fn extension(path: &str) -> Option<String> {
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase())
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> Result<Vec<u8>, CmdError> {
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read_file(_path: &str) -> Result<Vec<u8>, CmdError> {
Err(CmdError::Failed("paths aren't available on the web; pass `data`".into()))
crate::io::read_file(path).map_err(CmdError::Failed)
}
/// Field names and records from a table's rows: with `headers`, the first row names the fields
+1 -9
View File
@@ -203,15 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
let bytes = if let Some(d) = p::str(v, "data") {
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?
} else if let Some(path) = p::str(v, "path") {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
return Err(CmdError::Failed("paths aren't available on the web".into()));
}
crate::io::read_file(path).map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
+480
View File
@@ -0,0 +1,480 @@
//! The folders automation may read and write (#769).
//!
//! `wordcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots
//! nothing is checked.
//!
//! A path is checked, then opened: a process that can change the folders in between (swapping in
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
//! guard against other programs on the computer.
use std::cell::RefCell;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock};
/// Read access or write access.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
}
#[derive(Debug)]
struct Root {
/// The folder made absolute, links followed: what paths are compared with.
resolved: PathBuf,
/// The same without Windows' `\\?\` prefix, for people.
shown: PathBuf,
}
impl Root {
/// The folder at `path`, which must exist.
fn open(path: &Path, access: Access) -> Result<Self, String> {
let kind = match access {
Access::Read => "read",
Access::Write => "write",
};
let resolved = std::fs::canonicalize(path)
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
Ok(Self { shown: plain(resolved.clone()), resolved })
}
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
};
let Some(Root { resolved: folder, shown }) = root else {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
};
if inside(&resolve(path)?, folder) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
}
}
}
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
#[derive(Clone, Debug)]
enum Scope {
Confined(Arc<AutomationRoots>),
Unconfined,
}
thread_local! {
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
/// (they stay as they were).
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
PROCESS.set(roots).is_ok()
}
/// Puts the scope it replaced back when dropped (also when `f` panics).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = previous);
}
}
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
f()
}
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
match roots {
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
None => f(),
}
}
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
/// app's own folders.
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
with_scope(Some(Scope::Unconfined), f)
}
/// The roots in force on this thread, if any.
pub fn current() -> Option<Arc<AutomationRoots>> {
match SCOPE.with(|s| s.borrow().clone()) {
Some(Scope::Confined(r)) => Some(r),
Some(Scope::Unconfined) => None,
None => PROCESS.get().cloned(),
}
}
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
fn plain(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
let text = path.to_string_lossy();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
}
}
/// May `path` be read here? Always, without roots in force.
pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("automation path rejected: path is empty".into());
}
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
}
}
}
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
_ => false,
}
}
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
fn is_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.docx"), "x").unwrap();
std::fs::write(base.join("outside/secret.docx"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn paths_inside_the_roots_pass_and_others_dont() {
let (base, roots) = folders("inside");
assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder");
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
// Read and write access are separate.
let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
std::fs::write(base.join("in2/b.docx"), "x").unwrap();
assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_is_resolved_or_refused() {
let (base, roots) = folders("dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in")));
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub")));
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out")));
let e = roots.check(&missing, Access::Write).unwrap_err();
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
// The tests' working directory is the crate's folder: not inside the roots.
assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root"));
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
#[cfg(unix)]
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let made = |target: &Path, link: &Path| {
let made =
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
made.is_ok()
};
#[cfg(not(any(unix, windows)))]
let made = |_: &Path, _: &Path| false;
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) {
eprintln!("skipped: symbolic links can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
assert!(made(&base.join("outside"), &base.join("out/away")));
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "a folder link out: {e}");
assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png")));
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/A.SVG"));
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(()));
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn scopes_nest_and_stay_on_their_thread() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.docx"));
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
confine(Some(&roots), || {
assert!(check_read(&outside).is_err());
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
assert!(check_read(&outside).is_err(), "restored after unconfined");
let o = outside.clone();
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
assert!(there, "another thread is not confined by this thread's scope");
});
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
assert!(r.is_err());
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
/// Every file a command reads or writes by path goes through `io` (`open_path_with`,
/// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that
/// reaches for `std::fs` itself would bypass them, so none may: every file under `src/`
/// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`,
/// reached only after `io` checked the roots), this module, and the test files.
#[test]
fn commands_do_not_touch_the_file_system_themselves() {
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = vec![];
walk(&src, &mut files);
assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
let mut offenders = vec![];
for path in files {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs" || name.starts_with("tests"))
|| path.starts_with(src.join("io"))
{
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w));
if !line.starts_with("//") && touches {
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
}
}
}
assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}");
}
}
+44 -1
View File
@@ -228,6 +228,7 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
crate::file_access::check_read(&path.to_string_lossy())?;
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 2 << 30 {
return Err(format!("{}: file is larger than 2 GB", path.display()));
@@ -247,7 +248,8 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
/// The pictures an HTML file names by a path relative to its folder.
///
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. URLs
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is
/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs
/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and
/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for
/// the whole document, so one large picture can't be multiplied by referencing it many times.
@@ -348,6 +350,46 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
Some(out)
}
/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data).
/// Every such read goes through here so the automation read root applies ([`crate::file_access`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
Err(format!("{path}: files are opened through the browser on the web"))
}
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
/// anything is read (a font named by the user, which could be any size).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a file"));
}
if meta.len() > max {
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
}
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
crate::file_access::check_write(path)?;
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))
}
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
Err(format!("{path}: files are saved through the browser on the web"))
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
Err(format!("{}: files are opened through the browser on the web", path.display()))
@@ -360,6 +402,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
crate::file_access::check_write(&path.to_string_lossy())?;
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
+1
View File
@@ -8,6 +8,7 @@
pub mod catalog;
pub mod cmd;
pub mod file_access;
pub mod io;
mod io_ext;
pub mod keymap;
+29 -1
View File
@@ -161,8 +161,9 @@ pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result<Value, Cmd
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
let (bytes, plan) = s.print_pdf(v)?;
// Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere.
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()}))
}
@@ -215,4 +216,31 @@ mod tests {
let r = s.run("file.printPdf", &json!({})).is_err();
assert!(r, "printPdf needs a path");
}
/// `file.printPdf` writes only inside the automation write root when there is one (#769).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn print_pdf_to_file_is_held_to_the_write_root() {
use crate::file_access::{AutomationRoots, confine};
let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap();
let mut s = Session::new(crate::sample::sample_document());
let inside = base.join("out/job").to_string_lossy().into_owned();
let outside = base.join("outside/job").to_string_lossy().into_owned();
confine(roots.as_ref(), || {
let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string();
assert!(e.contains("outside the write root"), "{e}");
s.run("file.printPdf", &json!({"path": inside})).unwrap();
});
assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root");
assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF"));
// Without roots nothing is checked: the behaviour is unchanged.
s.run("file.printPdf", &json!({"path": outside})).unwrap();
assert!(base.join("outside/job.pdf").is_file());
let _ = std::fs::remove_dir_all(&base);
}
}
+24 -6
View File
@@ -12,8 +12,11 @@ pub mod tools;
pub use backend::{Backend, Remote};
pub use server::Server;
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_engine::Session;
use wordcraft_engine::file_access::{self, AutomationRoots};
/// Methods that need the desktop app.
pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7981` and run `wordcraft-cli mcp --connect 127.0.0.1:7981`";
@@ -21,16 +24,18 @@ pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7
/// An in-process session.
pub struct Headless {
pub session: Session,
roots: Option<Arc<AutomationRoots>>,
}
impl Default for Headless {
fn default() -> Self {
Headless { session: Session::new(wordcraft_doc::Document::new()) }
impl Headless {
/// Confine every file the session's commands read and write to `roots`
/// (`wordcraft-cli mcp --automation-read-root … --automation-write-root …`).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"engine.execute" | "command" => {
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
@@ -55,6 +60,19 @@ impl Backend for Headless {
other => self.session.run(other, &params).map_err(|e| e.to_string()),
}
}
}
impl Default for Headless {
fn default() -> Self {
Headless { session: Session::new(wordcraft_doc::Document::new()), roots: None }
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, params))
}
fn has_ui(&self) -> bool {
false
}
+209
View File
@@ -0,0 +1,209 @@
//! `wordcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` (#769): through
//! the MCP protocol, files open and load only from inside the read root and are saved and
//! exported only inside the write root; paths that leave a root (`..`, another folder, a sibling
//! with the same prefix, a link) are refused, and nothing is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use wordcraft_engine::file_access::AutomationRoots;
use wordcraft_mcp::{Headless, Server};
/// A 1x1 PNG, so `insert.picture` has something real to read.
const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
/// A fresh folder holding `in` (the read root, with `a.txt`, `people.csv` and `pic.png`), `out` (the
/// write root), `outside` (with `secret.txt`) and `in2`, and a server confined to `in` and `out`
/// (either may be left out).
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
let base = std::env::temp_dir().join(format!("wc-mcp-roots-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.txt"), "Inside text").unwrap();
std::fs::write(base.join("in/people.csv"), "Name\nAda\n").unwrap();
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap();
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
std::fs::write(base.join("outside/t.docx"), "x").unwrap();
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
(Server::new(Box::new(Headless::default().with_automation_roots(roots))), base)
}
fn s(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
/// `tools/call` -> (isError, the first text block).
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
let result = &reply["result"];
let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string();
(result["isError"] == true, text)
}
fn ok(server: &mut Server, name: &str, args: Value) -> String {
let (error, text) = call(server, name, args.clone());
assert!(!error, "{name} {args}: {text}");
text
}
fn refused(server: &mut Server, name: &str, args: Value, why: &str) {
let (error, text) = call(server, name, args.clone());
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
}
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
call(server, "execute", json!({"command": command, "params": params}))
}
#[test]
fn files_open_only_from_inside_the_read_root() {
let (mut server, base) = confined("open", true, true);
let sep = std::path::MAIN_SEPARATOR;
ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}));
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
refused(&mut server, "open_document", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root");
refused(
&mut server,
"open_document",
json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}),
"outside the read root",
);
refused(&mut server, "open_document", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root");
// The refused open left the document it had.
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
// The write root isn't readable: the two kinds of access are separate.
std::fs::write(base.join("out/d.txt"), "d").unwrap();
refused(&mut server, "open_document", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root");
// A relative path is the working directory's (the crate's folder here): outside.
refused(&mut server, "open_document", json!({"path": "Cargo.toml"}), "outside the read root");
}
#[test]
fn commands_that_read_a_path_are_held_to_the_read_root() {
let (mut server, base) = confined("reads", true, true);
let outside = s(&base.join("outside/secret.txt"));
// The injected-document scenario from the issue: pull a file in from outside, then read it back.
for (command, params) in [
("insert.textFromFile", json!({"path": outside})),
("insert.object", json!({"path": outside})),
("insert.picture", json!({"path": s(&base.join("outside/pic.png"))})),
("mailings.recipients", json!({"path": outside})),
("file.newFromTemplate", json!({"path": outside})),
("review.compare", json!({"path": outside})),
("review.compare", json!({"text": "x", "original": outside})),
("file.open", json!({"path": outside})),
("styles.organizer", json!({"path": s(&base.join("outside/t.docx"))})),
("tools.updateStyles", json!({"path": s(&base.join("outside/t.docx"))})),
("styles.copyFrom", json!({"path": s(&base.join("outside/t.docx")), "styles": ["Heading 1"]})),
] {
let (error, text) = run(&mut server, command, params);
assert!(error && text.contains("outside the read root"), "{command}: {text}");
}
assert!(!ok(&mut server, "get_text", json!({})).contains("TOP SECRET"));
// The same commands work on files inside the root.
let (error, text) = run(&mut server, "insert.textFromFile", json!({"path": s(&base.join("in/a.txt"))}));
assert!(!error, "{text}");
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
let (error, text) = run(&mut server, "insert.picture", json!({"path": s(&base.join("in/pic.png"))}));
assert!(!error, "{text}");
let (error, text) = run(&mut server, "mailings.recipients", json!({"path": s(&base.join("in/people.csv"))}));
assert!(!error, "{text}");
}
#[test]
fn saves_and_exports_go_only_into_the_write_root() {
let (mut server, base) = confined("write", true, true);
let sep = std::path::MAIN_SEPARATOR;
ok(&mut server, "type_text", json!({"text": "Hello"}));
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}));
assert!(base.join("out/doc.docx").is_file());
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.pdf"))}));
ok(&mut server, "save_document", json!({"path": s(&base.join("out/page.png"))}));
assert!(base.join("out/page.png").is_file());
let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/two"))}));
assert!(!error, "{text}");
// Printing to a PDF (#641) writes a file too.
let (error, text) = run(&mut server, "file.printPdf", json!({"path": s(&base.join("out/printed"))}));
assert!(!error, "{text}");
assert!(base.join("out/printed.pdf").is_file());
refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "outside the write root");
refused(&mut server, "save_document", json!({"path": s(&base.join("outside/doc.html"))}), "outside the write root");
refused(&mut server, "save_document", json!({"path": s(&base.join("outside/page.png"))}), "outside the write root");
refused(
&mut server,
"save_document",
json!({"path": format!("{}{sep}..{sep}outside{sep}up.docx", s(&base.join("out")))}),
"outside the write root",
);
let (error, text) = run(&mut server, "mailings.recipients", json!({"csv": "Name\nAda\n"}));
assert!(!error, "{text}");
for (command, params) in [
("file.saveAs", json!({"path": s(&base.join("outside/x.docx"))})),
("file.exportPdf", json!({"path": s(&base.join("outside/x.pdf"))})),
("file.printPdf", json!({"path": s(&base.join("outside/printed.pdf"))})),
("file.printPdf", json!({"path": format!("{}{sep}..{sep}outside{sep}up", s(&base.join("out")))})),
("file.exportPng", json!({"path": s(&base.join("outside/x.png"))})),
("file.saveTemplate", json!({"path": s(&base.join("outside/x.dotx"))})),
("mailings.finish", json!({"path": s(&base.join("outside/merged.docx"))})),
] {
let (error, text) = run(&mut server, command, params);
assert!(error && text.contains("outside the write root"), "{command}: {text}");
}
// The document saved inside the write root saves again in place.
ok(&mut server, "save_document", json!({}));
let mut written: Vec<String> =
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
written.sort();
assert_eq!(written, ["pic.png", "secret.txt", "t.docx"], "nothing was written outside the roots");
assert!(!base.join("in/doc.docx").exists());
}
#[test]
fn a_root_left_out_grants_none_of_its_access() {
let (mut server, base) = confined("read-only", true, false);
ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}));
refused(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}), "write authority is absent");
refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "write authority is absent");
assert!(!base.join("out/doc.docx").exists());
let (mut server, base) = confined("write-only", false, true);
refused(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent");
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}));
}
#[test]
fn links_out_of_a_root_are_refused() {
let (mut server, base) = confined("links", true, true);
#[cfg(unix)]
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok()
&& std::os::unix::fs::symlink(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok();
#[cfg(windows)]
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok()
&& std::os::windows::fs::symlink_file(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok();
#[cfg(not(any(unix, windows)))]
let linked = false;
// Windows needs Developer Mode or elevation to make links.
if !linked {
eprintln!("skipped: symbolic links can't be made here");
return;
}
refused(&mut server, "open_document", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root");
refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root");
assert!(!base.join("outside/doc.docx").exists());
}
+54
View File
@@ -24,6 +24,60 @@ with `wordcraft-cli mcp --author "Claude (copyedit)"`, or run
`review.trackChanges {"value": true}`; without `value` it toggles, which turns tracking *off* in
a document that was saved with it on. `wordcraft-cli run` takes `--author` too.
## Confining file access
```sh
wordcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
```
```json
{"mcpServers": {"wordcraft": {"command": "/abs/path/target/release/wordcraft-cli",
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
```
The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client
config works across the suite) keep the agent to the folders you give it:
- Every file a command reads must lie inside the **read root**: `open_document` and `file.open`,
`file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and
`picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm,
ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`,
`styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read
from beside that file, so they are inside the read root too (a link that leads out is not followed).
- Every file a command writes must lie inside the **write root**: `save_document`, `file.save`,
`file.saveAs`, `file.exportPdf`, `file.exportPng`, `file.printPdf`, `file.saveTemplate`, and `mailings.finish` with
a `path`.
- Read and write access are separate. Give the same folder twice for both; a root left out grants
none of its access (with only `--automation-read-root`, nothing can be written to disk). Without
either flag nothing changes.
- A path is made absolute against the server's working directory, then its links are followed: a
file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet,
its nearest existing folder's links are followed and the rest must be plain names, so `..` below a
folder that doesn't 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`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
- A refused path is an ordinary tool error the agent can read and correct, for example
`automation path rejected: outside the write root /work/project/out: /etc/x.docx` or
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.docx`.
- The roots must be existing folders; a missing one stops the server with an error. Each flag is
given once (`--flag <dir>` or `--flag=<dir>`).
The flags confine the headless server; `--connect` with them is an error, since the server would
otherwise talk to an app that isn't confined. WordCraft's desktop app has no equivalent flags yet,
so a running app (`wordcraft --control`) is not confined.
What the roots do not cover: commands that take no path (`text.insert`, `execute` of anything that
works on the open document) can still read and change that document, and `get_text` and
`inspect_document` return it. The Zotero commands talk to a local Zotero over a socket, not to files.
Anything that reads or writes a path goes through one place in the engine (`io::open_path_with`,
`io::save_path_with`, `io::read_file`, `io::write_file`); a test keeps command modules from
touching the file system on their own.
The checks hold for every thread of the server process. 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.
## Tools
| Tool | What it does |