From ad6bc0c19b256137ca3d68e1f6f57a6b2b69a4ea Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 09:19:17 +0700 Subject: [PATCH] Automation: a confined renderQueue.add without output needs a saved project (#2479) On a project that was never saved, renderQueue.add without output queued an item whose default output was the home folder, outside every automation root (rendering it was refused, but the queue held a path the agent may not write). While file access is confined, such an add is now refused with a hint to give output inside a write root; a saved project's default output (beside it) is checked when the queue renders, as before. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/backend.rs | 2 +- crates/automation/src/confine.rs | 14 ++++++++++++++ crates/automation/src/tests.rs | 6 ++++++ docs/mcp.md | 4 +++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c3947c876..8f7931239 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -64,7 +64,7 @@ impl Backend { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { let lookup = match c.check_command(id, ¶ms)? { - Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), + Some(l @ (crate::confine::Lookup::ProjectFile | crate::confine::Lookup::QueueDefault)) => Some((l, self.run("project.summary", json!({}))?)), Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), None => None, }; diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 06b83a880..9b126bdcb 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -128,6 +128,9 @@ pub enum Lookup { ProjectFile, /// Every output of the queued Render Queue items (`renderQueue.list`). RenderOutputs, + /// Whether the open project has a file (`project.summary` → `path`): a Render Queue item + /// added without `output` writes beside it, or to the home folder before the first save. + QueueDefault, } impl Confinement { @@ -239,6 +242,10 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } + // Without `output`, an unsaved project's item would write to the home folder (#2479). + if id == "renderQueue.add" && !given { + return Ok(Some(Lookup::QueueDefault)); + } if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); } @@ -253,6 +260,13 @@ impl Confinement { Some(p) => self.check(p, Access::Write).map(drop), None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), }, + // A saved project's default output sits beside it, and rendering checks it then. + Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) { + Some(_) => Ok(()), + None => Err(Error::BadArgs( + "`renderQueue.add`: the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root".into(), + )), + }, Lookup::RenderOutputs => { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 1121c5d91..c1e346d9e 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1372,6 +1372,12 @@ fn automation_roots_confine_file_access() { assert!(err, "{name} {args} should be refused: {c:?}"); c[0]["text"].as_str().unwrap_or_default().to_string() }; + // #2479: an unsaved project's queue item without `output` would write to the home folder. + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Unsaved", "width": 64, "height": 64}})); + let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved"}})); + assert!(msg.contains("home folder") && msg.contains("give `output`"), "{msg}"); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved", "output": p(&write, "unsaved.mp4")}})); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.remove"})); // Reading: inside a read root, not outside. call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")})); diff --git a/docs/mcp.md b/docs/mcp.md index a9f27b959..7685a66e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -11,7 +11,9 @@ written only under a write root; with no write root nothing is written. Every en (`execute_command`, `command_run`, each `batch` step) is checked before it runs: its file parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must any other absolute path in its parameters. Save, Increment and Save and rendering the Render -Queue are checked against the files they write (the open project's, the queued outputs). Scripts +Queue are checked against the files they write (the open project's, the queued outputs); +`renderQueue.add` without `output` needs a saved project, since an unsaved one's default output +would go to the home folder (#2479). Scripts (`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`, `ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes.