MCP: document.inspect and document.json leave out paths outside a confined client's read roots

Under --automation-read-root, document.inspect gives `path` as null when
the document's file is outside the read roots. document.json names a
linked image or placed file outside them by its file name alone, without
its relative path, size or date, in the layers, symbols, graph designs,
patterns, the pattern being edited and opacity masks. It also names a
data set's image value outside them by its file name, and leaves the
Export for Screens folder outside them out of export_settings. The
document is not changed, and unconfined callers see the same replies as
before.
This commit is contained in:
dotCooCoo
2026-11-11 23:36:11 -08:00
parent 48e066f475
commit 46ce6364c5
6 changed files with 111 additions and 4 deletions
+6 -2
View File
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
doc_move
),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
cmd!(
@@ -50,7 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
doc_compare
),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| {
let st = s.doc()?;
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc);
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
}),
cmd!(
"document.setUnits",
"Units",
+45 -1
View File
@@ -28,7 +28,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
};
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
@@ -1195,6 +1197,48 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
});
}
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
/// allow names its file alone, without its relative path or the size and date kept of its file,
/// and each data set's image value it does not allow names its file alone too. The folder Export
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was
/// before and opacity masks are all checked.
pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> {
if crate::file_access::current().is_none() {
return Cow::Borrowed(d);
}
let mut d = d.clone();
// The pattern being edited as it was before is checked as one more pattern, then put back.
let original = d.pattern_edit.as_mut().and_then(|e| e.original.take());
let edited = original.is_some();
d.patterns.extend(original);
d.update_links_where(
|l| !path_shown(&l.path, doc_path),
|l| {
let name = l.name().to_string();
*l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() };
},
);
if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) {
e.original = Some(def);
}
if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) {
d.export_settings.remove("folder");
}
let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path));
if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) {
for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) {
if hidden(&*v)
&& let DataValue::Image(path) = v
{
*path = LinkInfo::new(path.as_str()).name().to_string();
}
}
}
Cow::Owned(d)
}
// ---------- commands ----------
fn check(s: &mut Session, p: &Value) -> Result<Value> {
+2 -1
View File
@@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value {
"title": st.title(),
// The File Info title (`file.info`), where `title` comes from the file name once the document has one.
"fileInfoTitle": d.title,
"path": st.path,
// Null for a confined caller when the file is outside its read roots.
"path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()),
"dirty": st.is_dirty(),
"revision": st.revision,
"units": d.units.label(),
@@ -1084,6 +1084,58 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
}
/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed
/// document and a linked image outside the read roots are not named by path, and the document
/// keeps its links.
#[test]
fn document_replies_leave_out_paths_outside_the_read_roots() {
let away = Folder::new("doc-lrr-away");
let home = Folder::new("doc-lrr-home");
let src = away.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let pic = away.file("photo.png");
write(&pic, &crate::tests_links::png(60, 30, BLUE));
let mut s = session();
let id = place(&mut s, &src);
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap();
let image = r["ids"][0].as_u64().unwrap();
let poster = away.file("poster.vectorcraft");
save(&mut s, &poster);
// The folder Export for Screens wrote to, and a pattern of the linked image being edited: its
// definition before editing is kept beside the tile art.
let out = away.file("out");
s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap();
let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone();
s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap();
assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some()));
let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| {
let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap();
crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap()
};
let inspect = ask(&mut s, &home.0, "document.inspect");
assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}");
assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}");
let model = ask(&mut s, &home.0, "document.json").to_string();
assert!(!model.contains("doc-lrr-away"), "{model}");
for name in ["logo.vectorcraft", "photo.png"] {
assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}");
}
assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}");
// The document keeps its links, and a caller whose read roots hold the files sees every path.
assert_eq!(placed(&s, id).link.path, src);
for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] {
let model = model.to_string();
for path in [&src, &pic, &out] {
assert!(model.contains(&json!(path).to_string()), "{path}: {model}");
}
}
assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster));
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster));
}
/// Save As reads its defaults from the catalog; using them must retain current editable links.
#[test]
fn native_save_catalog_defaults_keep_placed_documents_editable() {
+5
View File
@@ -158,6 +158,11 @@ the suite) keep the agent to the folders you give it:
`null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and
`prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the
read roots, and the preset stays. The preferences themselves are not changed.
- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots.
`document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and
leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by
its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used)
when that folder is outside them. The document itself is not changed.
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
+1
View File
@@ -760,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
- Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`).
- Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`).
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.