Files
pdfcraft/crates/automation/tests/hostile_args.rs
T
Christoph OrtenburgandClaude Opus 5.5 85da56ed24 test(automation): pin the content_list guard and form_set_props' refusal (#2660)
Review on #2715: reverting only the content_list guard still passed. The fixture now has
text added by PdfCraft (`/PCAdded`) on page 6, and content_list must report it at its
unchanged rect [70 70 150 100]. form_set_props must refuse a rect for the field on page 6
with "name is on page 6, which could not be read" and leave form_fields unchanged.
Reverting any one of the link, content or form guards now fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:43:33 -04:00

482 lines
21 KiB
Rust

//! Every tool, called with hostile arguments (D2) and with paths that try to leave `--root` (D3).
//!
//! AGENTS.md §3 and §4 treat tool arguments as untrusted — they are the MCP attack surface, and an
//! agent can send any JSON at all. But `tools::check_args` only checks the *shape* of an argument
//! object: that it is an object, that it has no unknown keys, and that the required keys are
//! present and not null. **Types and ranges are not validated there**, so a string where an
//! integer belongs, `i64::MIN` where a page number belongs, or a 100,000-character path all reach
//! the handler. Each handler has to cope on its own, and nothing checked that they all did.
//!
//! These tests call every tool in the table with a zoo of hostile values and require two things:
//!
//! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so
//! every call must come back as `Ok` or as a `ToolError` the agent can read.
//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute
//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and
//! afterwards nothing outside the root may have been created.
//!
//! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves
//! its own paths and that no tool dispatches another internally; this keeps that true as tools are
//! added, which a written finding would not.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use pdfcraft_automation::{Automation, ToolError, tools};
use serde_json::{Value, json};
/// A fresh empty directory for one test.
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-hostile-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("temp dir");
d
}
/// Values chosen to break assumptions: wrong types, the ends of the numeric ranges, strings that
/// are empty, enormous, or full of separators and control characters, and containers that are
/// empty, mistyped or deeply nested.
fn zoo() -> Vec<Value> {
let deep = {
let mut v = json!(0);
for _ in 0..64 {
v = json!([v]);
}
v
};
vec![
Value::Null,
json!(true),
json!(false),
json!(0),
json!(-1),
json!(1),
json!(i64::MAX),
json!(i64::MIN),
json!(u64::MAX),
json!(0.5),
json!(-0.0),
json!(1e308),
json!(-1e308),
json!(""),
json!(" "),
json!("0"),
json!("-1"),
json!("null"),
json!("\0"),
json!("\u{202E}gnp.txe"), // right-to-left override: a name that displays reversed
json!("💥🙈"),
json!("%n%s%p"),
json!("a".repeat(100_000)),
json!("../".repeat(64)),
json!([]),
json!([null]),
json!([i64::MIN, i64::MAX]),
json!(["", "../.."]),
json!({}),
json!({ "unexpected": 1 }),
deep,
]
}
/// Paths that must never resolve inside the root. Absolute ones point into the test's own temp
/// `base` (the root's parent), never at real system files or network shares, so a confinement
/// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link
/// to `base`.
fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
let mut out: Vec<String> = vec![
"../outside.pdf".into(),
"../../outside.pdf".into(),
"../".repeat(40) + "outside.pdf",
"sub/../../outside.pdf".into(),
"./././../outside.pdf".into(),
base.join("outside.pdf").to_string_lossy().into_owned(),
base.join("new-outside.pdf").to_string_lossy().into_owned(),
base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(),
];
if linked {
out.push("link-out/outside.pdf".into());
}
// Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root.
if cfg!(windows) {
out.push(r"..\..\outside.pdf".into());
}
out
}
/// A symbolic link to a folder, where the system allows one (Windows needs Developer Mode or an
/// administrator for it, #948).
fn dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(not(any(unix, windows)))]
{
Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display())))
}
}
/// The properties of `def` whose values are paths, by name and whether they are a list.
fn path_props(def: &pdfcraft_automation::ToolDef) -> Vec<(String, bool)> {
let Some(props) = def.input_schema["properties"].as_object() else { return Vec::new() };
props
.iter()
.filter(|(name, spec)| {
// `out` (an output file) and `image` (a picture to place) are paths when they are
// strings; `image` is an image number elsewhere, which the integer check skips.
let is_pathish = matches!(name.as_str(), "path" | "paths" | "folder" | "out_dir" | "file" | "out" | "image")
|| spec["description"].as_str().is_some_and(|d| d.starts_with("A file path"));
// `file_page` is a page number inside a file, not a path.
is_pathish && spec["type"] != "integer"
})
.map(|(name, spec)| (name.clone(), spec["type"] == "array"))
.collect()
}
/// An arguments object giving every required property a plausible value, so a hostile value for
/// one property is not masked by a "missing argument" error for another.
///
/// `doc` gets a **real** open document when one is given. Without that, almost every tool fails at
/// `self.doc()` before it ever looks at a path, and a confinement test would pass vacuously — as
/// an earlier version of this file did, even with `resolve` disabled entirely.
fn plausible_with(def: &pdfcraft_automation::ToolDef, doc: Option<u64>) -> serde_json::Map<String, Value> {
let mut obj = plausible(def);
if let Some(id) = doc
&& def.input_schema["properties"].as_object().is_some_and(|p| p.contains_key("doc"))
{
obj.insert("doc".into(), json!(id));
}
obj
}
fn plausible(def: &pdfcraft_automation::ToolDef) -> serde_json::Map<String, Value> {
let mut obj = serde_json::Map::new();
let props = def.input_schema["properties"].as_object().cloned().unwrap_or_default();
for req in def.input_schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let spec = props.get(req).cloned().unwrap_or(Value::Null);
let value = match spec["type"].as_str() {
Some("integer") => json!(1),
Some("number") => json!(1.0),
Some("boolean") => json!(false),
Some("array") => json!([]),
Some("object") => json!({}),
_ => json!("in.pdf"),
};
obj.insert(req.to_string(), value);
}
obj
}
/// Run `call`, turning a panic into a message instead of unwinding out of the test.
fn guarded(a: &mut Automation, tool: &str, args: &Value) -> std::result::Result<(), String> {
let result = catch_unwind(AssertUnwindSafe(|| a.call(tool, args)));
match result {
Ok(Ok(_)) => Ok(()),
// Any ToolError is a good outcome: the agent is told what was wrong.
Ok(Err(ToolError::UnknownTool(_) | ToolError::InvalidArgs(_) | ToolError::Failed(_))) => Ok(()),
Err(panic) => {
let what = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
Err(format!("{tool} panicked on {args}: {what}"))
}
}
}
#[test]
fn no_tool_panics_on_hostile_arguments() {
let root = tmp("args");
let mut a = Automation::new().with_root(&root).expect("root");
let values = zoo();
let mut failures = Vec::new();
let mut calls = 0usize;
for def in tools() {
// The whole arguments object, not just its properties.
for bad in [json!(null), json!([]), json!("string"), json!(7), json!(true), json!({})] {
calls += 1;
if let Err(e) = guarded(&mut a, def.name, &bad) {
failures.push(e);
}
}
let props: Vec<String> = def.input_schema["properties"].as_object().map(|p| p.keys().cloned().collect()).unwrap_or_default();
for prop in &props {
for value in &values {
let mut obj = plausible(&def);
obj.insert(prop.clone(), value.clone());
calls += 1;
if let Err(e) = guarded(&mut a, def.name, &Value::Object(obj)) {
failures.push(e);
}
}
}
}
eprintln!("hostile arguments: {calls} calls over {} tools, {} panics", tools().len(), failures.len());
for f in failures.iter().take(20) {
eprintln!(" {f}");
}
let _ = std::fs::remove_dir_all(&root);
assert!(failures.is_empty(), "{} tool call(s) panicked; the first is: {}", failures.len(), failures.first().map_or("", String::as_str));
}
#[test]
fn no_tool_escapes_the_root() {
let base = tmp("escape");
let root = base.join("root");
std::fs::create_dir_all(&root).expect("root");
// What a traversal would reach, and the state we require to be unchanged afterwards.
let outside = base.join("outside.pdf");
std::fs::write(
&outside,
b"%PDF-1.7
% not yours
",
)
.expect("outside file");
let before = listing(&base);
// A link inside the root pointing out of it, where the system allows one to be made. Without
// it `link-out/…` is an ordinary path inside the root, which tools that write rightly accept.
let linked = match dir_symlink(&base, &root.join("link-out")) {
Ok(()) => true,
Err(e) => {
eprintln!("escapes through a symbolic link not checked: {e}");
false
}
};
let mut a = Automation::new().with_root(&root).expect("root");
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document");
let doc = created
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
pdfcraft_automation::Content::Png { .. } => None,
})
.expect("the new document has an id");
let mut leaked = Vec::new();
let mut tried = 0usize;
let mut refused = 0usize;
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);
tried += 1;
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) {
// A path that leaves the root must be refused. Succeeding means the tool
// either used it or ignored an argument it declares — both are faults.
Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)),
Ok(Err(_)) => refused += 1,
Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)),
}
}
}
}
// Second net: nothing outside the root may have appeared, vanished or changed.
let after = listing(&base);
for (path, size) in &after {
if path.starts_with(&root) {
continue;
}
match before.iter().find(|(p, _)| p == path) {
Some((_, was)) if was == size => {}
Some(_) => leaked.push(format!("{} was modified outside the root", path.display())),
None => leaked.push(format!("{} was created outside the root", path.display())),
}
}
for (path, _) in &before {
if !path.starts_with(&root) && !after.iter().any(|(p, _)| p == path) {
leaked.push(format!("{} was deleted outside the root", path.display()));
}
}
eprintln!("root confinement: {tried} escape attempts over {} tools, {refused} refused, {} leaks", tools().len(), leaked.len());
for l in leaked.iter().take(20) {
eprintln!(" {l}");
}
let _ = std::fs::remove_dir_all(&base);
assert!(tried > 100, "only {tried} escape attempts: the path properties are not being found");
assert!(leaked.is_empty(), "{} confinement failure(s); the first is: {}", leaked.len(), leaked.first().map_or("", String::as_str));
}
/// Every regular file under `dir` with its size, not following symbolic links.
fn listing(dir: &Path) -> Vec<(PathBuf, u64)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
if meta.file_type().is_symlink() {
continue; // do not walk through a link we planted
}
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() {
out.push((path, meta.len()));
}
}
}
out.sort();
out
}
/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660),
/// with a link, a URI link and a comment on every page, and a form field and text added by
/// PdfCraft (its `/PCAdded` marker) on the last. Readers
/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while
/// the editing layer sees all six, so annotations come back on pages the viewer does not have.
fn dangling_kid_pdf() -> Vec<u8> {
let pages: Vec<u32> = (0..6).map(|i| 10 + 2 * i).collect();
let mut kids: Vec<u32> = pages[..3].to_vec();
kids.push(77); // does not exist
kids.extend(&pages[3..]);
let kids: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
let mut objs: Vec<(u32, String)> = vec![
(1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()),
(2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))),
(90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()),
];
for (i, &page) in pages.iter().enumerate() {
let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1);
let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) };
objs.push((
page,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \
/Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \
<< /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \
<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>"
),
));
objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len())));
}
objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into()));
let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET";
objs.push((
60,
format!(
"<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream",
added.len()
),
));
objs.sort_by_key(|(n, _)| *n);
let size = 100;
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![None; size];
for (num, body) in &objs {
if let Some(slot) = offsets.get_mut(*num as usize) {
*slot = Some(out.len());
}
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for offset in offsets.iter().skip(1) {
out.extend_from_slice(
match offset {
Some(o) => format!("{o:010} 00000 n \n"),
None => "0000000000 65535 f \n".to_string(),
}
.as_bytes(),
);
}
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
#[test]
fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() {
// #2660: `link_list` indexed the viewer's pages with a page number from the editing layer
// ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a
// file where the two disagree, must answer with a result or an error.
let root = tmp("dangling-kid");
// The JSON a call answers with, or the error it gave.
let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result<Value, String> {
match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) {
Ok(Ok(content)) => content
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => Some(v),
pdfcraft_automation::Content::Png { .. } => None,
})
.ok_or_else(|| format!("{tool} answered without JSON")),
Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")),
Err(_) => Err(format!("{tool} panicked")),
}
};
let pdf = dangling_kid_pdf();
let mut failures = Vec::new();
for def in tools() {
// Written again for every tool: tools that save write to `in.pdf`, the plausible path.
std::fs::write(root.join("in.pdf"), &pdf).expect("fixture");
let mut a = Automation::new().with_root(&root).expect("root");
let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file");
let doc = opened
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
pdfcraft_automation::Content::Png { .. } => None,
})
.expect("the opened document has an id");
if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) {
failures.push(e);
}
match def.name {
// The field sits on the sixth page; a rect is placed on the field's own page. That is
// refused, saying why, and the field is left as it was.
"form_set_props" => {
let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc }));
let before = fields(&mut a);
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) {
Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {}
Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")),
Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()),
Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()),
}
let after = fields(&mut a);
if before.is_err() || before != after {
failures.push(format!("form_set_props changed the field: {before:?} became {after:?}"));
}
}
// Listing still reports what is on the pages the viewer lacks, not just survives it.
"link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
Ok(v) if v["count"] == 12 => {}
other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")),
},
// The added text on page 6 is reported, its rect unchanged (user space), as
// `doc_info` reports rects on pages the viewer could not read.
"content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
Ok(v)
if v["items"].as_array().is_some_and(|items| {
items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0]))
}) => {}
other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")),
},
_ => {}
}
}
for f in &failures {
eprintln!(" {f}");
}
let _ = std::fs::remove_dir_all(&root);
assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str));
}