mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 04:07:26 +00:00
MCP: confine file access with --automation-read-root and --automation-write-root (#1448)
* Automation: check the read and write roots on every path a tool reads or writes * CLI: add --automation-read-root and --automation-write-root to mcp * Docs: document --automation-read-root and --automation-write-root for mcp * #1448: count main's doc_batch helpers as resolved by their caller Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.pdf".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("--compact", &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.pdf"));
|
||||
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("--root"), 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()));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod automation_args;
|
||||
mod batch;
|
||||
|
||||
use std::io::Write;
|
||||
@@ -33,8 +35,14 @@ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, in
|
||||
macro_rules! mcp_usage {
|
||||
() => {
|
||||
"\
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
MCP server on stdin/stdout (opt-in)
|
||||
--compact lists a core set of tools plus tool_search and tool_call
|
||||
--automation-read-root / --automation-write-root confine the files the
|
||||
agent's tools read (open, combine, import, fonts, certificates…) and
|
||||
write (save, export, extract, split, reports…) to DIR, links followed;
|
||||
a root left out grants none of its access. --root, when also given,
|
||||
applies as well
|
||||
"
|
||||
};
|
||||
}
|
||||
@@ -901,11 +909,42 @@ fn run(args: &[String]) -> Result<(), CliError> {
|
||||
print_output(&auto, content, flag(args, "--out"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[cfg(feature = "mcp")]
|
||||
fn automation_roots(command: &str, args: &[String]) -> Result<(Option<Arc<pdfcraft_automation::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 = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
||||
.map_err(|e| format!("{command}: {e}"))?;
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
fn mcp(args: &[String]) -> Result<(), CliError> {
|
||||
let (roots, args) = automation_roots("mcp", args)?;
|
||||
let args = args.as_slice();
|
||||
validate_options("mcp", args, MCP_OPTIONS)?;
|
||||
let compact = args.iter().any(|a| a == "--compact");
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(roots) = roots {
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: files confined: read root {}, write root {}",
|
||||
show(roots.read_root()),
|
||||
show(roots.write_root())
|
||||
);
|
||||
auto = auto.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, on the real binary:
|
||||
//! both spellings of the flags confine the server, and a malformed flag stops it instead of
|
||||
//! leaving it unconfined.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// `in/a.pdf` and `outside/secret.pdf`, blank one-page PDFs made by the binary itself.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-cli-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.pdf", "outside/secret.pdf"] {
|
||||
let script = json!([
|
||||
{"tool": "doc_create", "args": {"from": "blank", "pages": 1, "width": 100, "height": 100}},
|
||||
{"tool": "doc_save", "args": {"doc": 1, "path": f}},
|
||||
]);
|
||||
std::fs::write(base.join("steps.json"), script.to_string()).unwrap();
|
||||
let out = Command::new(BIN).args(["run", "--script"]).arg(base.join("steps.json")).arg("--root").arg(&base).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
/// Run `mcp <flags>`, send `calls` as `tools/call` requests and return the replies.
|
||||
fn session(flags: &[String], calls: &[(&str, Value)]) -> (Vec<Value>, String) {
|
||||
let mut child = Command::new(BIN).arg("mcp").args(flags).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
|
||||
for (i, (name, args)) in calls.iter().enumerate() {
|
||||
let r = json!({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
|
||||
input.push_str(&format!("{r}\n"));
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies = String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(replies, String::from_utf8(out.stderr).unwrap())
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn flags(spelling: &str, base: &Path) -> Vec<String> {
|
||||
let (r, w) = (s(&base.join("in")), s(&base.join("out")));
|
||||
if spelling == "equals" {
|
||||
vec![format!("--automation-read-root={r}"), format!("--automation-write-root={w}")]
|
||||
} else {
|
||||
vec!["--automation-read-root".into(), r, "--automation-write-root".into(), w]
|
||||
}
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> String {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_spellings_confine_the_server() {
|
||||
for spelling in ["equals", "separate"] {
|
||||
let base = folders(spelling);
|
||||
let calls = [
|
||||
("doc_open", json!({"path": s(&base.join("in/a.pdf"))})),
|
||||
("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("out/copy.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("outside/copy.pdf"))})),
|
||||
];
|
||||
let (replies, stderr) = session(&flags(spelling, &base), &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[1]).contains("automation path rejected: outside the read root"), "{spelling}: {replies:?}");
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[3]).contains("automation path rejected: outside the write root"), "{spelling}: {replies:?}");
|
||||
assert!(base.join("out/copy.pdf").is_file() && !base.join("outside/copy.pdf").exists());
|
||||
assert!(stderr.contains("files confined: read root") && stderr.contains("write root"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let base = folders("one");
|
||||
let read_only = vec!["--automation-read-root".to_string(), s(&base.join("in"))];
|
||||
let calls = [("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), ("doc_save", json!({"doc": 1, "path": s(&base.join("out/x.pdf"))}))];
|
||||
let (replies, _) = session(&read_only, &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{replies:?}");
|
||||
assert!(!base.join("out/x.pdf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_flags_means_no_confinement() {
|
||||
let base = folders("none");
|
||||
let (replies, stderr) = session(&[], &[("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}))]);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(!stderr.contains("confined"), "{stderr}");
|
||||
}
|
||||
|
||||
fn fails(args: &[&str]) -> String {
|
||||
let out: Output = Command::new(BIN).arg("mcp").args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?} should fail");
|
||||
assert!(out.stdout.is_empty(), "{args:?} must not start a server");
|
||||
String::from_utf8(out.stderr).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_flags_are_errors_not_a_missing_root() {
|
||||
let dir = s(&std::env::temp_dir());
|
||||
assert!(fails(&["--automation-read-root"]).contains("--automation-read-root needs a folder"));
|
||||
assert!(fails(&["--automation-write-root", "--compact"]).contains("--automation-write-root needs a folder; use --automation-write-root=<dir>"));
|
||||
assert!(fails(&["--automation-read-root="]).contains("--automation-read-root needs a non-empty folder"));
|
||||
assert!(fails(&["--automation-read-root", &dir, "--automation-read-root", &dir]).contains("--automation-read-root given twice"));
|
||||
assert!(fails(&["--automation-write-root", &dir, &format!("--automation-write-root={dir}")]).contains("--automation-write-root given twice"));
|
||||
let missing = fails(&["--automation-read-root", "/definitely/not/a/folder"]);
|
||||
assert!(missing.contains("mcp: cannot open the automation read root"), "{missing}");
|
||||
let file = std::env::current_exe().unwrap();
|
||||
assert!(fails(&["--automation-write-root", &s(&file)]).contains("cannot open the automation write root"));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. It applies on top of `with_root`.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
|
||||
@@ -568,7 +568,7 @@ impl Automation {
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve(p, true)?;
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's tools reach, with the names and wording VectorCraft uses. [`AutomationRoots`]
|
||||
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
|
||||
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
|
||||
//!
|
||||
//! - 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.
|
||||
//!
|
||||
//! 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::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 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> {
|
||||
self.check_path(path, Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`check`](Self::check) for a path that was already put together from `shown` (the way the
|
||||
/// caller wrote it, which is what refusals quote).
|
||||
pub fn check_path(&self, shown: &str, target: &Path, 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_shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(shown, target)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = root_shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `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(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().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.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: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {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: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {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: {shown}"));
|
||||
}
|
||||
// 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 {shown}: {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!("pc-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.pdf"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), "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.pdf")), 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.pdf")), 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.pdf")), 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.pdf")), 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.pdf"), "x").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.pdf")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.pdf", 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.pdf", 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.pdf", 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.pdf", 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.pdf", 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.pdf"))).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.pdf")), 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.pdf"), &base.join("in/leak.pdf")) {
|
||||
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.pdf")), 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.pdf"), &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.pdf")), 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.pdf")), 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.PDF"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.pdf")), 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);
|
||||
}
|
||||
|
||||
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
|
||||
/// roots. A function that reaches for the file system without resolving a path first would
|
||||
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
|
||||
/// the same function (or be one of the helpers that only ever get a resolved path).
|
||||
#[test]
|
||||
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
|
||||
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
||||
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_none_or(|e| e != "rs")
|
||||
|| path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests"))
|
||||
{
|
||||
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("\n#[cfg(test)]").next().unwrap_or("");
|
||||
let lines: Vec<&str> = code.lines().collect();
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//")
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|| trimmed.contains("OpenOptions"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (0..=n).rev().find(|&i| lines[i].trim_start().contains("fn ") && lines[i].trim_start().ends_with('{')).unwrap_or(0);
|
||||
let name = lines[start].split("fn ").nth(1).and_then(|r| r.split(['(', '<']).next()).unwrap_or("");
|
||||
if !RESOLVED_BY_CALLER.contains(&name) && !lines[start..=n].iter().any(|l| l.contains("resolve")) {
|
||||
offenders.push(format!("{}:{}: in `{name}`: {}", path.display(), n + 1, trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "resolve the path with `self.resolve` first: {offenders:#?}");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
//! server ([`mcp`]), `pdfcraft-cli run` and (later) the UI control channel.
|
||||
//! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points
|
||||
//! with the origin at the top-left of the displayed page.
|
||||
//! - An optional root directory confines every path a tool reads or writes.
|
||||
//! - An optional root directory confines every path a tool reads or writes. Separately,
|
||||
//! [`file_access::AutomationRoots`] (`pdfcraft-cli mcp --automation-read-root` and
|
||||
//! `--automation-write-root`) holds a read folder and a write folder, each left out meaning no
|
||||
//! access of its kind.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -16,6 +19,7 @@ mod batch;
|
||||
mod comments;
|
||||
mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
@@ -108,6 +112,8 @@ const FIND_MAX_WARNING_BYTES: usize = 1024;
|
||||
pub struct Automation {
|
||||
session: Session,
|
||||
root: Option<PathBuf>,
|
||||
/// The read and write folders of `--automation-read-root` / `--automation-write-root`.
|
||||
roots: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// Synchronous renderers, rebuilt when a document's working bytes change.
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text, under one memory budget across all open documents.
|
||||
@@ -122,7 +128,7 @@ impl Default for Automation {
|
||||
|
||||
impl Automation {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
}
|
||||
|
||||
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
|
||||
@@ -135,6 +141,14 @@ impl Automation {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Confine the files the tools read to the read root and the files they write to the write
|
||||
/// root (`None`: no confinement). Applies on top of [`with_root`](Self::with_root) when both
|
||||
/// are set.
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<file_access::AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a fixed clock for saves (deterministic output in tests).
|
||||
pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_clock(clock);
|
||||
@@ -158,7 +172,7 @@ impl Automation {
|
||||
/// Returns where the file went.
|
||||
pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
let target = self.resolve(path, true)?;
|
||||
let special = self.root.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir());
|
||||
let special = self.root.is_none() && self.roots.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir());
|
||||
if special {
|
||||
std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?;
|
||||
} else {
|
||||
@@ -538,6 +552,12 @@ impl Automation {
|
||||
.iter()
|
||||
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(roots) = &self.roots {
|
||||
// The installed fonts are outside the roots: look in the read root instead.
|
||||
let read = roots
|
||||
.read_root()
|
||||
.ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?;
|
||||
vec![self.resolve(&read.to_string_lossy(), false)?]
|
||||
} else if let Some(root) = &self.root {
|
||||
vec![root.clone()]
|
||||
} else {
|
||||
@@ -889,7 +909,14 @@ impl Automation {
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
Some(p) => self.resolve(p, true)?,
|
||||
None => PathBuf::from(doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?),
|
||||
None => {
|
||||
// Saving in place writes where the file was opened from, which the read root
|
||||
// allows and the write root may not.
|
||||
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
|
||||
let own_path = PathBuf::from(&own);
|
||||
self.check_roots(&own, &own_path, file_access::Access::Write)?;
|
||||
own_path
|
||||
}
|
||||
};
|
||||
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
|
||||
@@ -2006,6 +2033,29 @@ impl Automation {
|
||||
/// namespace is refused without touching it, and the deepest existing ancestor (links
|
||||
/// followed) must be inside the root before anything about the rest is reported.
|
||||
fn resolve(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let access = if for_write { file_access::Access::Write } else { file_access::Access::Read };
|
||||
self.resolve_for(path, access, for_write)
|
||||
}
|
||||
|
||||
/// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that
|
||||
/// is only read may still be missing (a path checked up front, whose absence is reported per
|
||||
/// file later). The automation roots, if any, get the last word.
|
||||
fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result<PathBuf> {
|
||||
let target = self.resolve_in_root(path, may_be_missing)?;
|
||||
self.check_roots(path, &target, access)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Hold `target`, which `path` named, to the automation read or write root. Always passes
|
||||
/// without roots.
|
||||
fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> {
|
||||
match &self.roots {
|
||||
Some(roots) => roots.check_path(path, target, access).map_err(failed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_in_root(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
|
||||
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, files open and load only from inside the read root and are written only inside the
|
||||
//! write root. Paths that leave a root (`..`, another folder, a sibling with the same prefix, a
|
||||
//! link) are refused with the wording VectorCraft uses, nothing is read into the result and
|
||||
//! nothing is written outside.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfcraft_automation::Automation;
|
||||
use pdfcraft_automation::file_access::AutomationRoots;
|
||||
use pdfcraft_automation::mcp::McpServer;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A 1x1 PNG, so the tools that take a picture have 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 one-page PDF reading `text`, with a 1x1 picture on it.
|
||||
fn pdf(text: &str) -> Vec<u8> {
|
||||
let body = format!("q 100 0 0 100 20 20 cm /Im1 Do Q BT /F1 24 Tf 20 150 Td ({text}) Tj ET");
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 6 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\xff\x00\x00\nendstream"
|
||||
.to_vec(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh folder holding `in` (the read root, with `a.pdf`, `b.pdf` and `pic.png`), `out` (the
|
||||
/// write root), `outside` (with `secret.pdf` and a picture) and `in2`.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-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.pdf"), pdf("Inside page")).unwrap();
|
||||
std::fs::write(base.join("in/b.pdf"), pdf("Second inside")).unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), pdf("TOP SECRET")).unwrap();
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.pdf"), pdf("Sibling")).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// A server confined to `in` and `out` (either may be left out).
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (McpServer, PathBuf) {
|
||||
let base = folders(tag);
|
||||
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();
|
||||
(McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000)), base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn rpc(server: &mut McpServer, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string();
|
||||
serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, all the text of the reply).
|
||||
fn call(server: &mut McpServer, name: &str, args: Value) -> (bool, String) {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let result = &reply["result"];
|
||||
let mut text =
|
||||
result["content"].as_array().map(|c| c.iter().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
text.push_str(&result["structuredContent"].to_string());
|
||||
text.push_str(&reply["error"].to_string());
|
||||
(result["isError"] == true || reply["error"].is_object(), text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut McpServer, name: &str, args: Value) -> Value {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
assert!(reply["result"]["isError"] == false, "{name} {args}: {reply}");
|
||||
reply["result"]["structuredContent"].clone()
|
||||
}
|
||||
|
||||
fn refused(server: &mut McpServer, 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}");
|
||||
assert!(!text.contains("TOP SECRET"), "{name} {args} let the file's text through: {text}");
|
||||
}
|
||||
|
||||
/// The text of document 1, through the resource a client can read it with.
|
||||
fn text_of(server: &mut McpServer, doc: u64) -> String {
|
||||
rpc(server, "resources/read", json!({"uri": format!("pdfcraft://doc/{doc}/text")}))["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[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, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("Inside page"));
|
||||
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_open", json!({"path": format!("{}{sep}..{sep}outside{sep}secret.pdf", s(&base.join("in")))}), "outside the read root");
|
||||
// A sibling whose name starts with the root's name is not inside it.
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in2/c.pdf"))}), "outside the read root");
|
||||
// The write root is not readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.pdf"), pdf("Written")).unwrap();
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("out/d.pdf"))}), "outside the read root");
|
||||
// A relative path is the working directory's (the crate's folder here): outside.
|
||||
refused(&mut server, "doc_open", json!({"path": "Cargo.toml"}), "outside the read root");
|
||||
// Nothing was opened by the refused calls.
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_read_a_path_are_held_to_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin")));
|
||||
let out = s(&base.join("out/result.pdf"));
|
||||
for (tool, args) in [
|
||||
("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})),
|
||||
("page_replace", json!({"doc": 1, "pages": [1], "path": secret})),
|
||||
("doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), secret], "out": out})),
|
||||
("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})),
|
||||
("doc_create", json!({"from": "images", "paths": [pic]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
|
||||
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
|
||||
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
|
||||
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
|
||||
("fill_sign_add", json!({"doc": 1, "page": 1, "type": "signature", "at": [10, 10], "path": pic})),
|
||||
("form_set_image", json!({"doc": 1, "field": "f", "path": pic})),
|
||||
("doc_import_data", json!({"doc": 1, "path": secret})),
|
||||
("doc_watermark", json!({"doc": 1, "file": pic})),
|
||||
("doc_background", json!({"doc": 1, "file": pic})),
|
||||
("form_merge_data", json!({"paths": [secret], "path": out})),
|
||||
("ocr_recognize_files", json!({"paths": [secret], "folder": s(&base.join("out/ocr"))})),
|
||||
("sign_trust", json!({"paths": [secret]})),
|
||||
("sign_document", json!({"doc": 1, "id": secret, "out": out})),
|
||||
("text_font_import", json!({"doc": 1, "page": 1, "path": gone})),
|
||||
("text_font_files", json!({"directories": [s(&base.join("outside"))]})),
|
||||
] {
|
||||
refused(&mut server, tool, args, "outside the read root");
|
||||
}
|
||||
// The refused calls changed neither the document nor the disk.
|
||||
assert!(!text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(names(&base.join("out")), Vec::<String>::new());
|
||||
|
||||
// One file per output: a file that can't be read is reported in its own entry, with the same refusal.
|
||||
let (_, text) = call(&mut server, "doc_create_multiple", json!({"paths": [secret], "mode": "separate", "out_dir": s(&base.join("out/sep"))}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/sep")).is_empty());
|
||||
let (_, text) = call(&mut server, "action_run", json!({"paths": [secret], "folder": s(&base.join("out/act")), "action": "Add Page Numbers"}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/act")).is_empty());
|
||||
std::fs::remove_dir_all(base.join("out/act")).unwrap();
|
||||
|
||||
// The same tools work on files inside the root.
|
||||
std::fs::remove_dir_all(base.join("out/sep")).unwrap();
|
||||
ok(&mut server, "page_insert_file", json!({"doc": 1, "path": s(&base.join("in/b.pdf")), "at": 2}));
|
||||
assert!(text_of(&mut server, 1).contains("Second inside"));
|
||||
ok(&mut server, "page_add_image", json!({"doc": 1, "page": 1, "path": s(&base.join("in/pic.png"))}));
|
||||
ok(&mut server, "doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": out}));
|
||||
assert_eq!(names(&base.join("out")), ["result.pdf"]);
|
||||
// Without a folder to list, the installed fonts are outside the read root: it is searched instead.
|
||||
ok(&mut server, "text_font_files", json!({}));
|
||||
ok(&mut server, "text_font_files", json!({"directories": [s(&base.join("in"))]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_write_a_path_are_held_to_the_write_root() {
|
||||
let (mut server, base) = confined("writes", true, true);
|
||||
let input = |name: &str| s(&base.join("in").join(name));
|
||||
ok(&mut server, "doc_open", json!({"path": input("a.pdf")}));
|
||||
ok(&mut server, "doc_open", json!({"path": input("b.pdf")}));
|
||||
let writes = |dir: &str| -> Vec<(&'static str, Value)> {
|
||||
let file = |name: &str| s(&base.join(dir).join(name));
|
||||
vec![
|
||||
("doc_save", json!({"doc": 1, "path": file("saved.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "out": file("extract.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "separate": true, "out_dir": file("pages")})),
|
||||
("doc_combine", json!({"paths": [input("a.pdf"), input("b.pdf")], "out": file("combined.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "combine", "out": file("multiple.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "separate", "out_dir": file("separate")})),
|
||||
("doc_split", json!({"doc": 1, "every": 1, "out_dir": file("parts")})),
|
||||
("doc_reduce", json!({"doc": 1, "path": file("reduced.pdf")})),
|
||||
("doc_optimize", json!({"doc": 1, "path": file("optimized.pdf")})),
|
||||
("doc_print", json!({"doc": 1, "path": file("printed.pdf")})),
|
||||
("doc_export_text", json!({"doc": 1, "path": file("text.txt")})),
|
||||
("doc_export_office", json!({"doc": 1, "path": file("export.docx")})),
|
||||
("doc_export_data", json!({"doc": 1, "path": file("data.xfdf")})),
|
||||
("doc_export_images", json!({"doc": 1, "folder": file("renders")})),
|
||||
("doc_export_all_images", json!({"doc": 1, "folder": file("images")})),
|
||||
("image_save", json!({"doc": 1, "page": 1, "image": 1, "path": file("image.png")})),
|
||||
("measure_export", json!({"doc": 1, "out": file("measurements.csv")})),
|
||||
("comments_summarize", json!({"doc": 1, "out": file("summary.pdf")})),
|
||||
("accessibility_report", json!({"doc": 1, "path": file("report.html")})),
|
||||
("accessibility_review_record", json!({"before": 1, "after": 2, "path": file("record.json")})),
|
||||
("doc_compare_report", json!({"doc": 1, "other": 2, "path": file("compare.pdf")})),
|
||||
("form_merge_data", json!({"paths": [input("a.pdf")], "path": file("merged.csv")})),
|
||||
("action_run", json!({"paths": [input("a.pdf")], "folder": file("actions"), "action": "Add Page Numbers"})),
|
||||
("ocr_recognize_files", json!({"paths": [input("a.pdf")], "folder": file("ocr")})),
|
||||
("sign_id_create", json!({"name": "Test", "password": "secret12", "path": file("id.p12"), "key": "p256"})),
|
||||
]
|
||||
};
|
||||
|
||||
for (tool, args) in writes("outside") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// The read root is not writable either.
|
||||
for (tool, args) in writes("in") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// `..` out of the write root, and a sibling that shares its prefix.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_save",
|
||||
json!({"doc": 1, "path": format!("{}{sep}..{sep}outside{sep}up.pdf", s(&base.join("out")))}),
|
||||
"outside the write root",
|
||||
);
|
||||
std::fs::create_dir_all(base.join("out2")).unwrap();
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out2/x.pdf"))}), "outside the write root");
|
||||
// Saving in place writes to the folder the file was opened from: the read root, not the write root.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "outside the write root");
|
||||
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"], "nothing was written outside the roots");
|
||||
assert_eq!(names(&base.join("in")), ["a.pdf", "b.pdf", "pic.png"], "nothing was written to the read root");
|
||||
assert!(names(&base.join("out2")).is_empty());
|
||||
assert!(names(&base.join("out")).is_empty());
|
||||
|
||||
// The same calls land in the write root.
|
||||
for (tool, args) in writes("out") {
|
||||
let (error, text) = call(&mut server, tool, args.clone());
|
||||
// (Printing to a PDF and the like may not apply to this fixture: a refusal for the roots is the failure.)
|
||||
assert!(!text.contains("automation path rejected") && !text.contains("automation filesystem access"), "{tool} {args}: {text}");
|
||||
let _ = error;
|
||||
}
|
||||
assert!(base.join("out/saved.pdf").is_file() && base.join("out/combined.pdf").is_file() && base.join("out/text.txt").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_writes_and_reads_only_inside_the_roots() {
|
||||
// One folder for both roots, so a digital ID can be made and then read back.
|
||||
let base = folders("signing");
|
||||
let work = base.join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::write(work.join("a.pdf"), pdf("Work page")).unwrap();
|
||||
let roots = AutomationRoots::new(Some(&work), Some(&work)).unwrap();
|
||||
let mut server = McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000));
|
||||
ok(&mut server, "doc_open", json!({"path": s(&work.join("a.pdf"))}));
|
||||
ok(&mut server, "sign_id_create", json!({"name": "Test", "password": "secret12", "path": s(&work.join("id.p12")), "key": "p256"}));
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_id_create",
|
||||
json!({"name": "Test", "password": "secret12", "path": s(&base.join("outside/id.p12")), "key": "p256"}),
|
||||
"outside the write root",
|
||||
);
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_document",
|
||||
json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&base.join("outside/signed.pdf"))}),
|
||||
"outside the write root",
|
||||
);
|
||||
ok(&mut server, "sign_document", json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&work.join("signed.pdf"))}));
|
||||
assert!(work.join("signed.pdf").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let (mut server, base) = confined("read-only", true, false);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
for target in ["out/doc.pdf", "in/doc.pdf"] {
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join(target))}), "write authority is absent");
|
||||
}
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "write authority is absent");
|
||||
refused(&mut server, "doc_export_text", json!({"doc": 1, "path": s(&base.join("out/t.txt"))}), "write authority is absent");
|
||||
refused(&mut server, "page_extract", json!({"doc": 1, "pages": [1], "out": s(&base.join("out/e.pdf"))}), "write authority is absent");
|
||||
refused(&mut server, "doc_split", json!({"doc": 1, "every": 1, "out_dir": s(&base.join("out/parts"))}), "write authority is absent");
|
||||
assert!(names(&base.join("out")).is_empty() && names(&base.join("in")) == ["a.pdf", "b.pdf", "pic.png"]);
|
||||
// A tool that gives its result back instead of writing it still can.
|
||||
ok(&mut server, "page_extract", json!({"doc": 1, "pages": [1]}));
|
||||
|
||||
let (mut server, base) = confined("write-only", false, true);
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}), "read authority is absent");
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_combine",
|
||||
json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": s(&base.join("out/c.pdf"))}),
|
||||
"read authority is absent",
|
||||
);
|
||||
refused(&mut server, "text_font_files", json!({}), "read authority is absent");
|
||||
ok(&mut server, "doc_create", json!({"from": "blank", "pages": 1, "width": 200, "height": 300}));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/blank.pdf"))}));
|
||||
assert!(base.join("out/blank.pdf").is_file());
|
||||
}
|
||||
|
||||
#[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.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).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.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).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;
|
||||
}
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/leak.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/deeper/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/dangling.pdf"))}), "a link that leads nowhere");
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = folders("none");
|
||||
let mut server = McpServer::new(Automation::new());
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("in2/copy.pdf"))}));
|
||||
assert!(base.join("in2/copy.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_roots_apply_on_top_of_a_root() {
|
||||
let base = folders("both");
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
|
||||
// `--root` resolves relative paths inside `in`, which the read root allows and the write root does not.
|
||||
let mut server = McpServer::new(Automation::new().with_root(base.join("in")).unwrap().with_automation_roots(roots));
|
||||
ok(&mut server, "doc_open", json!({"path": "a.pdf"}));
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "copy.pdf"}), "outside the write root");
|
||||
refused(&mut server, "doc_open", json!({"path": "../outside/secret.pdf"}), "outside the allowed directory");
|
||||
assert!(!base.join("in/copy.pdf").exists());
|
||||
// The write root alone is not enough: the other root still has its say.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "../out/copy.pdf"}), "outside the allowed directory");
|
||||
}
|
||||
+63
-1
@@ -1,6 +1,6 @@
|
||||
# MCP conventions
|
||||
|
||||
Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact]`. The server uses JSON lines on
|
||||
Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]`. The server uses JSON lines on
|
||||
stdin/stdout, opens no port, and stops on EOF. `--root` confines all reads and writes, including
|
||||
commands invoked through the common tools. Logs stay on stderr.
|
||||
|
||||
@@ -8,6 +8,68 @@ Supply `--root` at most once, followed by a nonempty directory path. Missing ope
|
||||
repeated roots and another long option in place of the directory are refused before any
|
||||
script is read or server is started. Prefix a directory name beginning with `--` with `./`.
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
pdfcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
```json
|
||||
{"mcpServers": {"pdfcraft": {"command": "/abs/path/target/release/pdfcraft-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 tool reads must lie inside the **read root**: `doc_open`, `page_insert_file`,
|
||||
`page_replace`, `doc_combine` and `doc_create_multiple` inputs, `doc_create` images, stamp,
|
||||
picture, attachment, signature and form-image files, `doc_watermark` and `doc_background` files,
|
||||
`doc_import_data`, `form_merge_data`, the files `action_run` and `ocr_recognize_files` process,
|
||||
digital ID, graphic and certificate files for signing and `sign_trust`, `text_font_import`, and
|
||||
the folders `text_font_files` searches. Without `directories`, `text_font_files` searches the read
|
||||
root instead of the installed fonts.
|
||||
- Every file a tool writes must lie inside the **write root**: `doc_save` (also saving in place),
|
||||
`page_extract`, `doc_combine`, `doc_create_multiple`, `doc_split`, `doc_reduce`, `doc_optimize`,
|
||||
`doc_print` to a file, the `doc_export_*` tools, `image_save`, `measure_export`,
|
||||
`comments_summarize`, the accessibility report and review record, `doc_compare_report`,
|
||||
`form_merge_data`, the output folders of `action_run` and `ocr_recognize_files`, `sign_id_create`
|
||||
and `sign_document`.
|
||||
- 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, though
|
||||
tools that give their result back instead of a path still do. 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 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.pdf` or
|
||||
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.pdf`.
|
||||
Tools that handle several files report a refused one in its own entry.
|
||||
- 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>`).
|
||||
- `--root DIR` still works and can be combined with these flags. Both then apply: a path must be
|
||||
inside `--root` and inside the matching automation root.
|
||||
|
||||
`pdfcraft-cli mcp` always runs the session in its own process (there is no flag to connect it to a
|
||||
running app), so the roots cover every server it starts. The desktop app is not confined by them.
|
||||
|
||||
What the roots do not cover: tools that take no path still read and change the open documents, and
|
||||
`text_extract` and the other inspection tools return their content. Keychain and Windows
|
||||
certificate-store identities for `sign_document` are not files and are not checked. Sending a job
|
||||
to a printer is not a file write. Every path a tool reads or writes goes through
|
||||
`Automation::resolve`, and a unit test keeps the tool modules from touching the file system
|
||||
without it.
|
||||
|
||||
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 | Arguments |
|
||||
|---|---|
|
||||
| `command_list` | `doc?`, `filter?`, `enabled_only?`; returns `{commands: [...]}` with mapped tool schemas |
|
||||
|
||||
Reference in New Issue
Block a user