Merge fix/749

This commit is contained in:
bflatastic
2026-10-17 20:48:35 +07:00
34 changed files with 1292 additions and 112 deletions
+18 -8
View File
@@ -1,5 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options, and boolean `--flag`s. Unknown options are usage errors.
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
use std::collections::BTreeMap;
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec<String>,
values: BTreeMap<String, String>,
values: BTreeMap<String, Vec<String>>,
flags: Vec<String>,
}
@@ -34,7 +35,7 @@ impl Args {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.insert(name.to_string(), v);
a.values.entry(name.to_string()).or_default().push(v);
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
@@ -45,7 +46,12 @@ impl Args {
}
pub fn value(&self, k: &str) -> Option<String> {
self.values.get(k).cloned()
self.values.get(k).and_then(|v| v.last()).cloned()
}
/// Every value of an option that may be given more than once, in order.
pub fn values(&self, k: &str) -> &[String] {
self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
@@ -62,9 +68,9 @@ impl Args {
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
let bleed = match self.values.get("bleed") {
let bleed = match self.value("bleed") {
None => 0.0,
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
@@ -72,7 +78,7 @@ impl Args {
}
pub fn scale(&self) -> CliResult<f64> {
match self.values.get("scale") {
match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
@@ -85,7 +91,7 @@ impl Args {
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
match self.values.get(k) {
match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
@@ -110,6 +116,10 @@ mod tests {
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
assert_eq!(a.values("root"), ["a", "b"]);
assert_eq!(a.value("root").as_deref(), Some("b"));
assert!(a.values("none").is_empty());
}
#[test]
+24 -5
View File
@@ -23,7 +23,7 @@ USAGE:
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
@@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
@@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach"], &[])?;
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
mcp(a.value("attach").as_deref())
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
@@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
@@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
None => Box::new(figcraft_mcp::Headless::new()),
};
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
let mut server = figcraft_mcp::Server::new(backend);
if let Some(r) = &roots {
eprintln!("file access confined to {}", r.to_json());
}
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
let stdin = std::io::stdin();
let stdout = std::io::stdout();
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
+46
View File
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
&["render", "a", "-o"],
&["perf", "--shapes", "lots"],
&["mcp", "--attach", "notaport"],
// An attached app confines itself: roots go to `figcraft --control`.
&["mcp", "--attach", "7979", "--automation-read-root", "."],
&["mcp", "--automation-write-root"],
&["convert", "a"],
] {
let out = bin().args(args).output().unwrap();
@@ -120,6 +123,49 @@ fn mcp_over_stdio() {
assert_eq!(lines[2]["result"]["isError"], false);
}
#[test]
fn mcp_over_stdio_confined_to_automation_roots() {
let d = tmp("roots");
for sub in ["in", "out", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
// A root that isn't a folder stops the server before it serves.
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
let mut child = bin()
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
let call = |id: u32, tool: &str, args: serde_json::Value| {
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
};
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 6);
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
let error = |i: usize| lines[i]["result"]["isError"] == true;
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn commands_json_lists_everything() {
let out = bin().args(["commands", "--json"]).output().unwrap();
+16 -2
View File
@@ -1,4 +1,5 @@
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
@@ -11,6 +12,11 @@ pub struct Args {
pub files: Vec<String>,
/// The last invite link given.
pub join: Option<String>,
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--control" {
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
"--automation-write-root" => Some(&mut out.write_roots),
_ => None,
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
+11 -1
View File
@@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec<String> {
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
assert_eq!(a.read_roots, args(&["in", "more"]));
assert_eq!(a.write_roots, args(&["out"]));
assert_eq!(a.files, args(&["a.figcraft"]));
// A flag without its folder is kept as an empty one, which startup refuses.
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+17
View File
@@ -6,6 +6,11 @@
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
//!
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -121,6 +126,17 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
) {
(true, _) => None,
(false, Ok(r)) => Some(Arc::new(r)),
(false, Err(e)) => {
eprintln!("FigCraft: {e}");
std::process::exit(2);
}
};
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
@@ -176,6 +192,7 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.automation_roots = roots;
fig.ui.home = false;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
+39
View File
@@ -0,0 +1,39 @@
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
# the automation roots (#715), so every command is confined without a check of its own.
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-methods = [
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
]
+3 -3
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
use crate::{Result, Session, bad, spec};
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
@@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
+24 -24
View File
@@ -435,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
@@ -445,11 +445,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
/// The bytes of file `path`, refused when it's larger than `max`.
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
}
read(path)
crate::file_access::read_capped(path, max)
}
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
@@ -457,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
/// truncates `path`, and no file or symlink at a staging name is ever written through.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::write(path, bytes)
}
#[cfg(target_arch = "wasm32")]
@@ -577,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
return None;
}
let dir = dir.as_ref()?;
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
let p = dir.join(format!("{r}{e}"));
let meta = std::fs::metadata(&p).ok()?;
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
})
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
.iter()
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
}
}
@@ -604,11 +598,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
let file = std::path::Path::new(path);
let dir = file.parent()?;
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
let meta = std::fs::metadata(&c).ok()?;
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
return None;
}
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
figcraft_format::rest::is_variables_json(&v).then_some(v)
})
}
@@ -646,20 +636,29 @@ fn remember(s: &mut Session, path: &str) {
}
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
use crate::file_access::{Access, allow, allowed};
const CMD: &str = "file.openRecent";
// Confined to automation roots, the recent files outside them are never listed, opened, removed
// or named: `index` counts the files listed.
let listed = |r: &String| allowed(r, Access::Read);
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
s.prefs.recent_files.retain(|r| !listed(r));
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone);
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
(Some(path), _) => {
allow(&path, Access::Read)?;
Some(path)
}
(None, Some(i)) => {
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
if let Some(path) = path {
if !std::path::Path::new(&path).exists() {
if !crate::file_access::exists(&path) {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
@@ -669,9 +668,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
.prefs
.recent_files
.iter()
.filter(|r| listed(r))
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
})
.collect();
Ok(json!({"files": files}))
@@ -1183,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let path = std::path::Path::new(d).join(&job.file);
// A layer name with slashes exports into folders.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
let _ = crate::file_access::create_dir_all(parent);
}
let path = path.to_string_lossy().to_string();
write(&path, &bytes)?;
+293
View File
@@ -0,0 +1,293 @@
//! Automation roots (#715): the folders the files an agent's commands read and write must lie in.
//!
//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app
//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on
//! the thread, every file the engine reads must lie inside a read or a write root and every file it
//! writes inside a write root; anything else is an ordinary error. The engine reaches the file
//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends
//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes.
//!
//! A path is made absolute against the working directory and resolved before it is compared: its
//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
//! checked is what is opened.
#![allow(clippy::disallowed_methods)]
use std::cell::RefCell;
use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use crate::{EngineError, Result};
/// What a command does with a file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read (read and write roots) and write (write roots), resolved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Roots {
read: Vec<PathBuf>,
write: Vec<PathBuf>,
}
impl Roots {
/// Roots from the folders given (relative ones against the working directory). Each must be an
/// existing folder.
pub fn new(read: &[String], write: &[String]) -> std::result::Result<Self, String> {
let resolve = |flag: &str, dirs: &[String]| {
dirs.iter()
.map(|d| match std::fs::canonicalize(d) {
Ok(p) if p.is_dir() => Ok(p),
Ok(_) => Err(format!("{flag} {d}: not a folder")),
Err(e) => Err(format!("{flag} {d}: {e}")),
})
.collect::<std::result::Result<Vec<_>, String>>()
};
Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? })
}
/// The resolved path to use for `access` to `path`, or why it is refused.
pub fn check(&self, path: &Path, access: Access) -> std::result::Result<PathBuf, String> {
let asked = path.display();
let (roots, what) = match access {
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
Access::Write => (self.write.clone(), "written"),
};
match resolve(path) {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
}
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
}
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
pub fn to_json(&self) -> serde_json::Value {
let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::<Vec<_>>();
serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)})
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
lexical.pop();
}
Component::CurDir => {}
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
/// false when either can't be resolved.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
}
/// `p` without Windows' `\\?\` prefix, for messages.
fn shown(p: &Path) -> String {
let s = p.to_string_lossy();
match s.strip_prefix(r"\\?\UNC\") {
Some(unc) => format!(r"\\{unc}"),
None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(),
}
}
/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its
/// nearest existing folder resolved with the rest appended (plain names only).
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("not a file path".into());
}
let abs = std::path::absolute(path).map_err(|e| e.to_string())?;
let mut rest: Vec<OsString> = Vec::new();
let mut at = abs.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut base) => {
base.extend(rest.iter().rev());
return Ok(base);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Writing through a link that leads nowhere would create its target, wherever it is.
if std::fs::symlink_metadata(at).is_ok() {
return Err("a link that leads nowhere".into());
}
match at.components().next_back() {
Some(Component::Normal(name)) => {
plain_name(name)?;
rest.push(name.to_os_string());
}
Some(Component::CurDir) => {}
_ => return Err("`..` (or a drive) below a folder that doesn't exist".into()),
}
at = at.parent().ok_or("no existing folder")?;
}
Err(e) => return Err(e.to_string()),
}
}
}
/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…)
/// and no alternate data stream (`a:b`).
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
if cfg!(windows) {
let n = name.to_string_lossy();
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.chars().count() == 4
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
if device || n.contains(':') {
return Err(format!("`{n}` is a device or stream name, not a file"));
}
}
Ok(())
}
thread_local! {
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
}
/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`).
pub fn confined<R>(roots: Option<&Arc<Roots>>, f: impl FnOnce() -> R) -> R {
match roots {
Some(roots) => scoped(Some(roots.clone()), f),
None => f(),
}
}
/// Run `f` unconfined: only for the app's own files, never for a path a command was given.
// Only plugins read from disk use it, and the browser has no disk.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn app_owned<R>(f: impl FnOnce() -> R) -> R {
scoped(None, f)
}
fn scoped<R>(roots: Option<Arc<Roots>>, f: impl FnOnce() -> R) -> R {
/// Puts the outer scope's roots back, also when `f` panics.
struct Restore(Option<Arc<Roots>>);
impl Drop for Restore {
fn drop(&mut self) {
let outer = self.0.take();
ROOTS.with(|r| *r.borrow_mut() = outer);
}
}
let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots)));
f()
}
/// The roots this thread's file access is confined to now, if any.
pub fn current() -> Option<Arc<Roots>> {
ROOTS.with(|r| r.borrow().clone())
}
/// The path to use for `access` to `path`: resolved when confined, else as given.
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
let path = path.as_ref();
match current() {
Some(roots) => roots.check(path, access).map_err(EngineError::Other),
None => Ok(path.to_path_buf()),
}
}
/// Whether `path` may be accessed so now.
pub fn allowed(path: impl AsRef<Path>, access: Access) -> bool {
allow(path, access).is_ok()
}
fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
move |e| EngineError::Other(format!("{}: {e}", path.display()))
}
/// The bytes of file `path`.
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
}
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
let path = path.as_ref();
let p = allow(path, Access::Read)?;
let meta = std::fs::metadata(&p).map_err(io(path))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
}
std::fs::read(p).map_err(io(path))
}
/// File `path` opened for reading.
pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
let path = path.as_ref();
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` (links followed).
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
std::fs::metadata(allow(path, Access::Read)?).map_err(io(path))
}
/// Whether `path` is a folder (false when it may not be read).
pub fn is_dir(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_dir())
}
/// Whether `path` exists (false when it may not be read).
pub fn exists(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok()
}
/// The paths of the entries of folder `path`.
pub fn read_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let path = path.as_ref();
let dir = allow(path, Access::Read)?;
Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect())
}
/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see
/// [`figcraft_format::write_file_atomic`]).
pub fn write(path: impl AsRef<Path>, bytes: &[u8]) -> Result<()> {
let path = path.as_ref();
write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
figcraft_format::write_file_atomic(path, bytes)
}
#[cfg(target_arch = "wasm32")]
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser"))
}
/// Create folder `path` and the folders above it that are missing.
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path))
}
#[cfg(test)]
#[path = "file_access_tests.rs"]
mod tests;
+371
View File
@@ -0,0 +1,371 @@
//! Automation roots: how paths are resolved and compared, and that commands run confined can't
//! reach past them.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::json;
use super::{Access, Roots, allow, confined, current};
use crate::Session;
/// A fresh folder for one test: `<tmp>/figcraft-roots-<name>-<pid>`, holding `root/`, `root2/`
/// (a sibling whose name starts like the root's) and `outside/`.
struct Fixture(PathBuf);
impl Fixture {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for sub in ["root/sub", "root2", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
std::fs::write(d.join("root/in.txt"), b"in").unwrap();
std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap();
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
/// The root as both read and write root.
fn rw(&self) -> Roots {
Roots::new(&[], &[self.p("root")]).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
fn symlink_dir(target: &Path, link: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
fn symlink_dir(target: &Path, link: &Path) -> bool {
std::os::windows::fs::symlink_dir(target, link).is_ok()
}
#[cfg(unix)]
fn symlink_file(target: &Path, link: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
fn symlink_file(target: &Path, link: &Path) -> bool {
std::os::windows::fs::symlink_file(target, link).is_ok()
}
#[test]
fn inside_is_allowed_and_resolved_outside_is_refused() {
let f = Fixture::new("inside");
let r = f.rw();
let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap();
assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path");
assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok());
let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}");
assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err());
// The root itself is inside.
assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok());
}
#[test]
fn a_sibling_whose_name_starts_like_the_root_is_outside() {
let f = Fixture::new("prefix");
let r = f.rw();
for p in ["root2/x.png", "root2", "root.png"] {
assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}");
assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}");
}
}
#[test]
fn traversal_is_resolved_before_comparing() {
let f = Fixture::new("dots");
let r = f.rw();
assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err());
assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok());
assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok());
// `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically,
// as the system does, and stays inside).
let deep = f.p("root/missing/../../outside/x.txt");
assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}");
}
#[test]
fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() {
let f = Fixture::new("missing");
let r = f.rw();
let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap();
assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png"));
assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err());
}
#[test]
fn relative_paths_are_resolved_against_the_working_directory() {
// `cargo test` runs in the crate's folder, which holds Cargo.toml.
let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned();
let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap();
assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap());
assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok());
let f = Fixture::new("relative");
assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err());
// Relative roots too.
assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here);
}
#[test]
fn read_and_write_authority_are_separate() {
let f = Fixture::new("modes");
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err();
assert!(e.contains("no folder may be written"), "{e}");
// A write root may be read too; a read root elsewhere doesn't make it writable.
let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap();
assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok());
assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err());
// Several roots of each kind.
let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap();
assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok());
assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err());
}
#[test]
fn roots_must_be_existing_folders() {
let f = Fixture::new("bad-roots");
assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root"));
assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder"));
assert!(Roots::new(&[], &[String::new()]).is_err());
assert!(f.rw().check(Path::new(""), Access::Read).is_err());
}
#[test]
fn links_that_lead_out_of_a_root_are_outside() {
let f = Fixture::new("links");
let r = f.rw();
if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err());
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err());
// A link that leads nowhere: writing through it would create its target.
assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")));
let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
// A link inside the root to the root is fine.
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
}
#[cfg(windows)]
#[test]
fn windows_case_devices_and_streams() {
let f = Fixture::new("windows");
let r = f.rw();
// Case is taken from the disk: another spelling of the root is the root.
let upper = f.0.join("ROOT").join("IN.TXT");
assert!(r.check(&upper, Access::Read).is_ok());
for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] {
assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}");
}
assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok());
// The `\\?\` form of a path inside is inside; of a path outside, outside.
let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png");
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
assert!(r.check(&verbatim, Access::Write).is_ok());
assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err());
// Messages don't show the prefix.
let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err();
assert!(!e.contains(r"\\?\"), "{e}");
}
#[test]
fn a_scope_confines_the_thread_and_ends_with_it() {
let f = Fixture::new("scope");
let roots = Arc::new(f.rw());
assert!(current().is_none());
confined(Some(&roots), || {
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
assert!(super::read(f.p("outside/secret.txt")).is_err());
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
// Not confined: the app's own files.
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
assert!(current().is_some(), "back in the scope after app_owned");
// Another thread isn't confined by this one's scope.
let p = f.p("outside/secret.txt");
assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap());
});
assert!(current().is_none());
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok());
// A panic inside the scope still ends it.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom"))));
assert!(r.is_err());
assert!(current().is_none());
// `None` changes nothing.
assert!(confined(None, || current().is_none()));
}
#[test]
fn commands_run_confined_read_and_write_only_inside() {
let f = Fixture::new("commands");
std::fs::create_dir_all(f.0.join("outside/readable")).unwrap();
let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap());
// Unconfined, the outside is writable: the bait files a confined command must not reach.
let bait = f.p("outside/bait.figcraft");
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap();
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")];
confined(Some(&roots), || {
let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string();
assert!(e.contains("automation path rejected"), "{e}");
assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok());
assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err());
assert!(s.execute("file.open", &json!({"path": bait})).is_err());
assert!(s.execute("file.import", &json!({"path": bait})).is_err());
assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err());
assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read");
assert!(s.execute("file.save", &json!({})).is_err(), "but not written");
assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok());
// Recent files outside the roots aren't listed.
let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string();
assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}");
assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err());
});
assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists());
}
/// Every command, given paths outside the roots in its usual path parameters, writes nothing
/// there and opens nothing from there: they all reach files through `file_access`.
#[test]
fn no_command_reaches_outside_the_roots() {
let f = Fixture::new("sweep");
let roots = Arc::new(f.rw());
let outside = f.0.join("outside");
// Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a
// name that must never show up in a document or a result.
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap();
s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap();
s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap();
std::fs::write(
outside.join("bait.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><rect id="BAIT-LAYER" width="9" height="9"/></svg>"#,
)
.unwrap();
let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
// The bait is what an unconfined command finds.
for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] {
let mut s = Session::new();
s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap();
assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}");
}
let mut s = Session::new();
s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap();
assert!(!s.doc().unwrap().images.is_empty());
for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] {
let path = f.p(&format!("outside/{bait}"));
let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true});
for c in crate::all_commands() {
let mut s = Session::new();
let r = confined(Some(&roots), || s.execute(c.id, &params));
let shown = format!("{r:?}");
assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id);
let docs = crate::inspect::document(&s, 64, true).to_string();
assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id);
assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id);
let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id);
}
}
}
#[test]
fn recent_files_outside_the_roots_are_never_opened_removed_or_named() {
let f = Fixture::new("recents");
let roots = Arc::new(f.rw());
let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft"));
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
s.execute("file.saveAs", &json!({"path": ok})).unwrap();
let mut s = Session::new();
let recents = vec![bait.clone(), gone.clone(), ok.clone()];
s.prefs.recent_files = recents.clone();
let quiet = |r: &crate::Result<serde_json::Value>| {
let shown = format!("{r:?}");
assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}");
};
confined(Some(&roots), || {
let listing = s.execute("file.openRecent", &json!({}));
quiet(&listing);
assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1);
// `index` counts the files listed: 0 is the first one inside the roots.
let opened = s.execute("file.openRecent", &json!({"index": 0}));
quiet(&opened);
assert!(opened.is_ok(), "{opened:?}");
assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str()));
for i in [1, 2] {
let r = s.execute("file.openRecent", &json!({"index": i}));
quiet(&r);
assert!(r.is_err());
}
// A path outside is refused like any outside path, and never dropped from the list.
let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string();
assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}");
quiet(&s.execute("file.openRecent", &json!({"remove": bait})));
assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone));
// Clear clears what is listed.
quiet(&s.execute("file.openRecent", &json!({"clear": true})));
});
assert_eq!(s.prefs.recent_files, vec![bait, gone]);
// Unconfined, a recent file that is gone is still dropped.
s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
assert_eq!(s.prefs.recent_files.len(), 1);
}
#[test]
fn outside_paths_are_refused_alike_whatever_exists_there() {
let f = Fixture::new("oracle");
let r = f.rw();
let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err();
let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display());
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
}
for p in &outside {
let e = refusal(p);
assert!(e.starts_with(&standard(p)), "{p}: {e}");
}
// Inside a root the reason is given.
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere"));
}
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
let install = f.0.join("root");
assert!(super::within_app_folder(&install.join("in.txt"), &install));
assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it");
assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another");
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
}
+3
View File
@@ -6,6 +6,8 @@
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
//! edits and are journaled the same way.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
#![cfg_attr(test, allow(clippy::disallowed_methods))]
pub mod al_keys;
pub mod annotations;
@@ -25,6 +27,7 @@ pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod drop;
pub mod file_access;
pub mod guard;
pub mod guides;
pub mod inspect;
+2 -2
View File
@@ -541,7 +541,7 @@ fn now() -> Option<std::time::SystemTime> {
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
}
@@ -555,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
match figcraft_format::read_library(std::io::BufReader::new(f)) {
Ok(Some(lib)) => Ok(lib),
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(path) = string(p, "path") {
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
let png = crate::cmd::file::export_bytes(&copy, &[inst], ExportFormat::Png, scale)?;
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::file_access::write(&path, &png)?;
out["path"] = json!(path);
}
Ok(out)
+11 -4
View File
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
// The install folder is the app's own (set by the desktop app, never by a command), so automation
// roots don't apply to it.
#[allow(clippy::disallowed_methods)]
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
#[cfg(not(target_arch = "wasm32"))]
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
}
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
#[allow(clippy::disallowed_methods)]
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
use crate::plugins::{module_file, read_file};
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text =
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> =
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> = entries
.into_iter()
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
.collect();
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
+20 -9
View File
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
/// Read a file of at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
let shown = path.display();
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let meta = crate::file_access::metadata(path)?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
crate::file_access::read(path)
}
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
let Some(path) = &p.path else {
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
};
#[cfg(not(target_arch = "wasm32"))]
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
#[cfg(target_arch = "wasm32")]
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
module_from_disk(s, p, path)
}
#[cfg(not(target_arch = "wasm32"))]
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
let file = std::path::Path::new(path);
let read = || module_file(file, &p.manifest).map(Arc::new);
// A plugin installed from a directory lives in the app's own install folder, not among the
// files automation roots confine.
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
if own { crate::file_access::app_owned(read) } else { read() }
}
#[cfg(target_arch = "wasm32")]
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
}
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
@@ -1,5 +1,5 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
+7 -21
View File
@@ -22,10 +22,9 @@ pub trait Backend {
fn describe(&self) -> String;
/// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the
/// current page's content. The default goes through `ui.render` and a temporary file.
/// current page's content. The default goes through `ui.render`, the PNG coming back as data.
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
let path = temp_png("render");
let mut params = json!({"path": path, "scale": scale});
let mut params = json!({"scale": scale});
if !ids.is_empty() {
params["ids"] = json!(ids);
} else {
@@ -36,8 +35,7 @@ pub trait Backend {
}
params["ids"] = json!(kids);
}
self.call("ui.render", params)?;
read_and_remove(&path)
decode_data(&self.call("ui.render", params)?)
}
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
@@ -52,11 +50,10 @@ pub trait Backend {
}
}
fn capture<B: Backend + ?Sized>(b: &mut B, mut params: Value) -> Result<Vec<u8>, String> {
let path = temp_png("screenshot");
params["path"] = json!(path);
b.call("ui.screenshot", params)?;
read_and_remove(&path)
/// A window capture, the PNG coming back as data (no file: an app confined to automation roots
/// writes none outside them).
fn capture<B: Backend + ?Sized>(b: &mut B, params: Value) -> Result<Vec<u8>, String> {
decode_data(&b.call("ui.screenshot", params)?)
}
/// The ids of the current page's top-level layers.
@@ -72,17 +69,6 @@ pub fn page_children<B: Backend + ?Sized>(b: &mut B) -> Result<Vec<u64>, String>
.unwrap_or_default())
}
fn temp_png(what: &str) -> String {
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
std::env::temp_dir().join(format!("figcraft-mcp-{what}-{}-{n}.png", std::process::id())).to_string_lossy().into_owned()
}
fn read_and_remove(path: &str) -> Result<Vec<u8>, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let _ = std::fs::remove_file(path);
Ok(bytes)
}
/// Decode a `data` field (base64, optionally a data: URL) from an export result.
pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?;
+2 -2
View File
@@ -215,7 +215,7 @@ impl Headless {
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
match p.get("path").and_then(Value::as_str) {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h, "bytes": bytes.len()}))
}
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
@@ -278,7 +278,7 @@ impl Backend for Headless {
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
match p.get("path").and_then(Value::as_str) {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h}))
}
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
+26 -5
View File
@@ -1,6 +1,9 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Read, Write};
use std::sync::Arc;
use figcraft_engine::file_access::{Roots, confined};
use serde_json::{Value, json};
@@ -42,6 +45,9 @@ Resources: figcraft://document, figcraft://node/{id}, figcraft://command/{id}. p
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
/// The automation roots every request's file access is confined to (`--automation-read-root`,
/// `--automation-write-root`), if any.
roots: Option<Arc<Roots>>,
}
fn response(id: Value, result: Value) -> Value {
@@ -58,7 +64,14 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
Self { backend, initialized: false, roots: None }
}
/// Confine the files every request reads and writes to `roots` (see
/// [`figcraft_engine::file_access`]).
pub fn with_roots(mut self, roots: Option<Roots>) -> Self {
self.roots = roots.map(Arc::new);
self
}
/// A server over a fresh headless session.
@@ -150,15 +163,23 @@ impl Server {
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
}
// A bug that panics fails this request only; the server keeps serving.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.request(method, &params))).unwrap_or_else(|p| {
Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref()))))
});
let roots = self.roots.clone();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(roots.as_ref(), || self.request(method, &params)))).unwrap_or_else(
|p| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref())))),
);
Some(match r {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
/// What `initialize` says about the automation roots.
fn roots_note(&self) -> String {
let Some(r) = &self.roots else { return String::new() };
let j = r.to_json();
format!(" Files: only these folders may be read: {}; written: {} (any other path is refused).", j["read"], j["write"])
}
fn notification(&mut self, method: &str) {
match method {
"notifications/initialized" => self.initialized = true,
@@ -180,7 +201,7 @@ impl Server {
"prompts": {"listChanged": false},
},
"serverInfo": {"name": "figcraft", "title": "FigCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
"instructions": format!("{INSTRUCTIONS} Backend: {}.{}", self.backend.describe(), self.roots_note()),
}))
}
"ping" => Ok(json!({})),
+126
View File
@@ -0,0 +1,126 @@
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
//! inside the read and write roots and writes only inside the write roots.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use std::path::PathBuf;
use figcraft_engine::file_access::Roots;
use figcraft_mcp::Server;
use serde_json::{Value, json};
/// `<tmp>/figcraft-mcp-roots-<name>-<pid>` with `in/` (read root), `out/` (write root),
/// `outside/` and `out2/` (named like the write root, outside it).
struct Dirs(PathBuf);
impl Dirs {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-mcp-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out", "outside", "out2"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
fn server(&self) -> Server {
Server::headless().with_roots(Some(Roots::new(&[self.p("in")], &[self.p("out")]).unwrap()))
}
}
impl Drop for Dirs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn call(s: &mut Server, tool: &str, args: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string();
let r: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap();
r["result"].clone()
}
fn text(r: &Value) -> String {
r["content"][0]["text"].as_str().unwrap_or_default().to_string()
}
#[test]
fn writes_go_only_into_the_write_root() {
let d = Dirs::new("write");
let mut s = d.server();
assert_eq!(call(&mut s, "create_node", json!({"type": "frame", "width": 40, "height": 20}))["isError"], false);
assert_eq!(call(&mut s, "run_command", json!({"command": "export.add"}))["isError"], false);
for (tool, args) in [
("save_file", json!({"path": d.p("outside/a.figcraft")})),
("save_file", json!({"path": d.p("out2/a.figcraft")})),
("save_file", json!({"path": d.p("out/../outside/a.figcraft")})),
("save_file", json!({"path": d.p("in/a.figcraft")})),
("export", json!({"format": "png", "path": d.p("outside/a.png")})),
("run_command", json!({"command": "file.export", "params": {"format": "svg", "path": d.p("outside/a.svg")}})),
("run_command", json!({"command": "file.exportLayers", "params": {"dir": d.p("outside")}})),
("batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": d.p("outside/b.figcraft")}}]})),
] {
let r = call(&mut s, tool, args.clone());
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
}
for sub in ["outside", "out2", "in"] {
assert_eq!(std::fs::read_dir(d.0.join(sub)).unwrap().count(), 0, "nothing written in {sub}");
}
assert_eq!(call(&mut s, "save_file", json!({"path": d.p("out/a.figcraft")}))["isError"], false);
assert_eq!(call(&mut s, "export", json!({"format": "png", "path": d.p("out/a.png")}))["isError"], false);
assert!(d.0.join("out/a.figcraft").is_file() && d.0.join("out/a.png").is_file());
// Without a path the bytes come back, written nowhere.
let r = call(&mut s, "export", json!({"format": "png"}));
assert_eq!(r["content"][0]["type"], "image", "{r}");
}
#[test]
fn reads_go_only_into_the_read_and_write_roots() {
let d = Dirs::new("read");
// Files made without roots: one in each folder.
let mut free = Server::headless();
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 40, "height": 20, "name": "Made"}))["isError"], false);
for f in ["in/a.figcraft", "out/b.figcraft", "outside/c.figcraft"] {
assert_eq!(call(&mut free, "export", json!({"format": "svg", "path": d.p(&f.replace(".figcraft", ".svg"))}))["isError"], false);
assert_eq!(call(&mut free, "run_command", json!({"command": "file.saveAs", "params": {"path": d.p(f)}}))["isError"], false);
}
let mut s = d.server();
for (tool, args) in [
("open_file", json!({"path": d.p("outside/c.figcraft")})),
("open_file", json!({"path": d.p("in/../outside/c.figcraft")})),
("open_file", json!({"path": d.p("in/a.figcraft"), "variables": d.p("outside/c.svg")})),
("run_command", json!({"command": "file.import", "params": {"path": d.p("outside/c.svg")}})),
("run_command", json!({"command": "file.placeImage", "params": {"path": d.p("outside/c.svg")}})),
("run_command", json!({"command": "library.add", "params": {"path": d.p("outside/c.figcraft")}})),
] {
let r = call(&mut s, tool, args.clone());
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
}
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("in/a.figcraft")}))["isError"], false);
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("out/b.figcraft")}))["isError"], false, "a write root may be read");
assert_eq!(call(&mut s, "run_command", json!({"command": "file.import", "params": {"path": d.p("in/a.svg")}}))["isError"], false);
}
#[test]
fn initialize_names_the_roots_and_no_roots_changes_nothing() {
let d = Dirs::new("init");
let mut s = d.server();
let r: Value =
serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap())
.unwrap();
let ins = r["result"]["instructions"].as_str().unwrap();
assert!(ins.contains("only these folders may be read") && ins.contains("figcraft-mcp-roots-init"), "{ins}");
let mut free = Server::headless();
let r: Value = serde_json::from_str(
&free.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap(),
)
.unwrap();
assert!(!r["result"]["instructions"].as_str().unwrap().contains("only these folders"));
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 4, "height": 4}))["isError"], false);
assert_eq!(call(&mut free, "export", json!({"format": "png", "path": d.p("outside/free.png")}))["isError"], false);
}
+1 -1
View File
@@ -516,7 +516,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
image_pending = true;
let target = target.clone();
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
let Ok(bytes) = std::fs::read(&p) else { return };
let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
if let Some(mut paint) = current_paint(app, &target) {
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
+32 -9
View File
@@ -16,8 +16,10 @@
//! prototype playing (presenter or preview pane)
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
//! returned as base64 `data`
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
//! window), written to `path` or returned as base64 `data`
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
//! windows (desktop)
//! - `app.quit {}`
@@ -55,7 +57,8 @@ impl ControlRequest {
/// Pending screenshot replies.
pub struct PendingShot {
pub id: Value,
pub path: String,
/// Where to write the PNG; `None`: return it as data.
pub path: Option<String>,
pub reply: std::sync::mpsc::Sender<ControlReply>,
/// When it was asked for (UI clock, seconds).
pub since: f64,
@@ -88,7 +91,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
if p.viewport != *vp {
return true;
}
let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]}));
let res = shot_reply(img, p.path.as_deref());
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res });
false
});
@@ -110,7 +113,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
continue;
}
if r.method == "ui.screenshot" {
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
let path = r.params.get("path").and_then(Value::as_str).map(str::to_string);
let viewport = ctx.viewport_id();
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport });
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
@@ -181,14 +184,28 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) {
}
}
fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
/// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app
/// has them), or returned as base64 `data`.
fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result<Value, String> {
let [w, h] = img.size;
let mut rgba = Vec::with_capacity(w * h * 4);
for p in &img.pixels {
rgba.extend_from_slice(&p.to_srgba_unmultiplied());
}
let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?;
buf.save(path).map_err(|e| e.to_string())
let mut png = std::io::Cursor::new(Vec::new());
buf.write_to(&mut png, image::ImageFormat::Png).map_err(|e| e.to_string())?;
let png = png.into_inner();
match path {
Some(path) => {
figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h}))
}
None => {
use base64::Engine as _;
Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))
}
}
}
/// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or
@@ -644,11 +661,13 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(app.inspect())
}
"ui.render" => {
let path = p.get("path").and_then(Value::as_str).ok_or("`path` required")?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
let ids: Vec<figcraft_doc::NodeId> =
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default();
let mut params = json!({"format": "png", "scale": scale, "path": path});
let mut params = json!({"format": "png", "scale": scale});
if let Some(path) = p.get("path").and_then(Value::as_str) {
params["path"] = json!(path);
}
if !ids.is_empty() {
params["ids"] = json!(ids);
}
@@ -723,3 +742,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result<Value, String> {
app.handle_ui_requests();
Ok(json!({"selection": app.session.selection(), "tool": app.session.tool.tool.id()}))
}
#[cfg(test)]
#[path = "control_roots_tests.rs"]
mod roots_tests;
+74
View File
@@ -0,0 +1,74 @@
//! An app started with automation roots: its frames and control requests read and write only
//! inside them, and captures come back as data without a path.
use std::path::PathBuf;
use std::sync::Arc;
use figcraft_engine::Session;
use figcraft_engine::file_access::Roots;
use serde_json::{Value, json};
use crate::{FigApp, NoServices};
struct Dirs(PathBuf);
impl Drop for Dirs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn confined_app(name: &str) -> (FigApp, Dirs) {
let d = std::env::temp_dir().join(format!("figcraft-ui-roots-{name}-{}", std::process::id()));
for sub in ["out", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
let mut app = FigApp::new(s, Box::new(NoServices));
app.automation_roots = Some(Arc::new(Roots::new(&[], &[d.join("out").to_string_lossy().into_owned()]).unwrap()));
(app, Dirs(d))
}
/// Control method `method` handled as the desktop app does: inside the frame guard.
fn handle(app: &mut FigApp, method: &str, p: Value) -> Result<Value, String> {
let ctx = egui::Context::default();
let mut res = Err("not run".to_string());
crate::windows::guard(app, &ctx, |app| res = super::handle(app, &ctx, method, &p));
res
}
#[test]
fn a_confined_app_writes_only_inside_its_write_root() {
let (mut app, d) = confined_app("write");
let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned();
let e = handle(&mut app, "ui.render", json!({"path": p("outside/r.png")})).unwrap_err();
assert!(e.contains("automation path rejected"), "{e}");
let e =
handle(&mut app, "engine.execute", json!({"command": "file.export", "params": {"format": "svg", "path": p("outside/r.svg")}})).unwrap_err();
assert!(e.contains("automation path rejected"), "{e}");
assert!(handle(&mut app, "ui.render", json!({"path": p("out/r.png")})).is_ok());
// Without a path the PNG comes back as data.
let r = handle(&mut app, "ui.render", json!({})).unwrap();
assert!(r["data"].as_str().is_some_and(|d| !d.is_empty()), "{r}");
assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0);
assert!(d.0.join("out/r.png").is_file());
// Outside the guard (the app's own work) nothing is confined.
assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok());
}
#[test]
fn window_captures_are_written_through_the_roots_or_returned() {
let (app, d) = confined_app("capture");
let img = egui::ColorImage::filled([3, 2], egui::Color32::RED);
let roots = app.automation_roots.clone();
let out = d.0.join("out/shot.png").to_string_lossy().into_owned();
let outside = d.0.join("outside/shot.png").to_string_lossy().into_owned();
figcraft_engine::file_access::confined(roots.as_ref(), || {
assert!(super::shot_reply(&img, Some(&outside)).unwrap_err().contains("automation path rejected"));
assert_eq!(super::shot_reply(&img, Some(&out)).unwrap()["width"], 3);
let data = super::shot_reply(&img, None).unwrap();
assert_eq!((data["format"].as_str(), data["height"].as_u64()), (Some("png"), Some(2)));
});
assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists());
}
+1 -2
View File
@@ -306,8 +306,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
app.ui.home = false;
}
}
let recents = app.session.prefs.recent_files.clone();
if recents.is_empty() {
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
// A first run: nothing to reopen yet, so offer something to start from.
subheading(ui, t, tr("Start from a template"));
let shown: Vec<&Template> = TEMPLATES.iter().take(FIRST_RUN).collect();
+8 -2
View File
@@ -506,7 +506,7 @@ impl Recents {
/// For `ui.inspect`: each file listed, whether it is there and whether its picture is in.
pub fn inspect(&self, prefs: &Prefs) -> Value {
let paths: Vec<String> = prefs.recent_files.iter().take(SHOWN).cloned().collect();
let paths: Vec<String> = listed(&prefs.recent_files).take(SHOWN).cloned().collect();
let files: Vec<Value> = sorted(self.shown(&paths), Sort::of(prefs))
.iter()
.map(|f| {
@@ -525,6 +525,12 @@ impl Recents {
}
}
/// The recent files that may be listed: all of them, or, when the app runs confined to automation
/// roots, those inside them (no name or picture of another file is shown).
pub(crate) fn listed(recents: &[String]) -> impl Iterator<Item = &String> {
recents.iter().filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read))
}
/// The recent files for `ui.inspect`: `{view, sort, order, files: [{path, name, exists, picture,
/// selected}]}` in the order shown (`picture`: `none`, `loading`, `picture`, `page` or
/// `placeholder`).
@@ -550,7 +556,7 @@ enum Action {
/// The Recent files heading, its toolbar and the files as cards or a table.
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
let paths: Vec<String> = app.session.prefs.recent_files.iter().take(SHOWN).cloned().collect();
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
let time = ui.input(|i| i.time);
let now = figcraft_engine::cmd::comment::now_secs();
let reduce_motion = app.reduce_motion();
+7
View File
@@ -706,6 +706,9 @@ pub struct FigApp {
pending_dialog: Option<PendingDialog>,
/// The latest UI context schedules the first poll and wakes the app when a dialog completes.
ui_context: Option<egui::Context>,
/// The automation roots the app was started with: every frame and control request runs
/// confined to them ([`windows::guard`]).
pub automation_roots: Option<Arc<figcraft_engine::file_access::Roots>>,
}
/// What to do with the result of a command run in the background.
@@ -736,6 +739,7 @@ impl FigApp {
waiting: Vec::new(),
pending_dialog: None,
ui_context: None,
automation_roots: None,
}
}
@@ -1431,6 +1435,9 @@ impl FigApp {
}
if !self.has_files() || self.ui.home {
home::show(self, ui);
// The shortcuts panel belongs to the canvas; it doesn't hang over the home screen
// (cleared after the home widgets, which may have just asked for it).
self.ui.shortcuts_panel = false;
dialogs::keys(self, &ctx);
dialogs::show(self, &ctx);
self.toast_ui(&ctx);
+2 -2
View File
@@ -301,9 +301,9 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
/// the menu's English label, `label` the one shown. Returns whether an item ran.
fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool {
let picked = submenu(ui, &format!("{prefix}/{key}"), label, t, |ui| {
let recents = &app.session.prefs.recent_files;
let recents: Vec<&String> = crate::home_recents::listed(&app.session.prefs.recent_files).collect();
let mut picked = None;
for p in recents.iter() {
for p in recents.iter().copied() {
let path = std::path::Path::new(p);
let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned());
let exists = path.exists();
+9
View File
@@ -188,3 +188,12 @@ fn shortcuts_panel_opens_switches_tabs_and_searches() {
u.app.run("help.shortcuts", json!({}));
assert!(!u.app.ui.shortcuts_panel, "the command toggles it");
}
#[test]
fn shortcuts_panel_does_not_show_on_the_home_screen() {
let mut u = Ui::new(Session::empty());
u.app.ui.home = true;
u.app.ui.shortcuts_panel = true;
u.frame(vec![]);
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
}
+3 -1
View File
@@ -91,7 +91,9 @@ pub fn with_app<R>(f: impl FnOnce(&mut FigApp) -> R) -> Option<R> {
/// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved
/// files, not a lost document.
pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) {
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut *app)));
// Started with automation roots, everything the window and the control channel do is confined.
let roots = app.automation_roots.clone();
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| figcraft_engine::file_access::confined(roots.as_ref(), || f(&mut *app))));
if let Err(e) = res {
let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
log::error!("UI panic: {msg}");
+5 -2
View File
@@ -156,10 +156,13 @@ figcraft-cli export dashboard.figcraft -o dashboard.png
Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for
machines.
### `mcp [--attach PORT]`
### `mcp [--attach PORT] [--automation-read-root DIR]… [--automation-write-root DIR]…`
Serve MCP on stdio: headless by default, or attached to a running app started with
`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish
`figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing
folders) confine the files the agent's calls read (inside a read or write root) and write (inside a
write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)).
With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish
live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`,
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
(`plugin.directory.*`); the one-shot `run` can't (no network link).
+29 -4
View File
@@ -53,6 +53,29 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s
last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An
unknown id is an error.
## Confining file access
```sh
figcraft --control 7979 --automation-read-root /work/project --automation-write-root /work/project/out
```
`--automation-read-root <dir>` and `--automation-write-root <dir>` (each repeatable, `--flag=<dir>`
works too; the same flags as `figcraft-cli mcp`) confine the files the app reads and writes for
whoever asks: every control request (`engine.execute`, `ui.render`, `ui.screenshot`, `window.new`,
`ui.key`, `ui.menu.invoke`…) and every frame of every window, so input injected with `ui.input`
and what a person does in the window are held to the same folders. Files are read only inside a
read or write root and written only inside a write root; anything else is an ordinary error reply
(`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are
resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder
stops the app before its window opens. Without the flags nothing changes.
Not confined: opening the files given on the command line when the app starts and the documents
the system hands over (Finder, Open With; saving them later is confined like any write), and the
app's own folders (preferences, recovery copies, plugins installed from a plugin directory). Run `figcraft-cli mcp --attach PORT` without the flags against
such an app (it refuses them: the app does the confining). `ui.screenshot` and `ui.render` without a
`path` return the PNG as data, which is how the attached MCP server takes them, so it writes no
temporary files.
## Methods
### `engine.execute {command, params?}`
@@ -265,15 +288,17 @@ the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn`
`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the
focus view shows, or `null`) and `annotationFilter`).
### `ui.screenshot {path}`
### `ui.screenshot {path?}`
PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to
capture). Returns `{path, width, height}`, or an error after 60 s when the window doesn't deliver.
capture). Returns `{path, width, height}`, without `path` `{format: "png", width, height, data}`
(base64), or an error after 60 s when the window doesn't deliver.
### `ui.render {path, scale?, ids?}`
### `ui.render {path?, scale?, ids?}`
PNG of the document without UI chrome (works without a window). Without `ids` it exports the
selection, else the page's top-level layers. Returns the export result.
selection, else the page's top-level layers. Returns the export result: written to `path`, or
`{format: "png", data}` (base64) without one.
### `window.new {path?, index?}`
+56
View File
@@ -26,6 +26,62 @@ If that connection cannot be established, the error says the request was not sen
A connection the app closed while it sat idle is noticed before the next request is written and
replaced then, so a pause between calls is not an error.
### Confining file access
```sh
figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
```
```json
{"mcpServers": {"figcraft": {"command": "/path/to/figcraft-cli",
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
```
The two flags (the other Craft apps take the same ones) keep an agent to the folders you give it.
Each may be given more than once, as `--flag DIR` or `--flag=DIR`:
- Every file a tool call reads must lie inside a **read root or a write root**; every file it writes
inside a **write root**. Anything else is refused with an ordinary tool error the agent can read
and correct, for example `automation path rejected: /etc/x.png is outside the folders that may be
written: /work/project/out`. Nothing is written and the server keeps serving.
- Read and write authority are separate. With only `--automation-read-root` nothing can be written
to disk (`export` and the export commands without a `path` still return the bytes). Without either
flag nothing changes.
- The roots must be existing folders: a missing one, or a file, stops the server at startup.
`initialize`'s `instructions` name the folders, so the agent knows where it may work.
- **What is covered:** everything a call does, because the engine reaches the file system through
one module (`crates/engine/src/file_access.rs`), and `crates/engine/clippy.toml` refuses `std::fs`
anywhere else in the engine, so a command added later is covered too. That is `open_file` (and the
`variables` file it may name), `save_file`, `export {path}`, and through `run_command` / `batch`
every command that names a file: `file.open`, `file.openRecent` (recent files outside the roots
are never listed, opened, removed or named: `index` counts the files listed), `file.import`,
`file.placeImage`, `image.place`, `file.save`, `file.saveAs`, `file.revert`, `file.recover`,
`file.export`, `file.exportSvg`, `file.exportLayers` (and the folders it makes), `file.exportFramesPdf`, the images and variables JSON read next to an imported REST API
file, `library.add` and the library files open documents link to, `variable.import` /
`variable.export`, `codeConnect.import` / `codeConnect.export`, `component.playground {path}`,
`plugin.import {path}`, a development plugin's module read again from disk,
`plugin.directory.publish {path}` and its assets, `collab.recoverFile {path}`, and the headless
`ui.render` / `ui.screenshot` with a `path`.
- **How paths are resolved:** a relative path is made absolute against the server's working
directory, then its links are followed: the real path is what is compared and what is opened. For
a file that doesn't exist yet, its nearest existing folder is resolved and the rest must be plain
names, so `..` below a folder that doesn't exist is refused, and so is a link that leads nowhere
(writing through it would create its target, wherever that is). A path outside the roots gets
the same refusal whatever exists there, so a refusal doesn't tell what is outside. Folders are
compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is
inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones,
and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused.
- Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies,
plugins installed from a plugin directory, the system fonts).
- `--attach` with either flag is a usage error: the app runs the commands, so only the app can
confine them. Start it with the same flags, `figcraft --control 7979 --automation-read-root DIR
--automation-write-root DIR` (see [Control protocol](control-protocol.md#confining-file-access)),
then attach as usual. The attached `screenshot` gets its PNGs from the app as data, so no
temporary file is written anywhere.
- A path is checked, then opened: another program that swaps a folder for a link in between can
still win that race. The roots keep an agent to the files it was given, not other software on the
computer; if that matters, run the server as a user that can't change the roots' folders.
### Fonts
The headless server lays text out with the same fonts as the app: the fonts built into FigCraft
+4 -1
View File
@@ -621,13 +621,16 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with
transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs.
- **Automation:** desktop control channel (`--control <port>`: execute commands, inspect document/UI,
synthetic pointer/keys/typing, menu invocation, screenshots, renders).
synthetic pointer/keys/typing, menu invocation, screenshots, renders). What agents read and write
on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root` on
`figcraft-cli mcp` and the desktop app, #715).
- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |