collective

Browse / github.com/storytold/effectcraft / patch

automation: a command that writes a file it is given is checked as a write (#2131 F-11)

automation: a command that writes a file it is given is checked as a write (#2131 F-11) Part of #2131 — the "Unlisted writers are checked as reads" item. The others (relative paths, indirect footage reads, `window.scriptPanel`, the depth-64 walk) are separate; the script one is #2521. ## The breach A command in neither `READS` nor `WRITES` falls to the catch-all that checks every absolute path in its parameters — as a **read** (`confine.rs:156-186`). Two of them write. `file.exportTimeline` writes the timeline file, and the pre-rendered ProRes movies of the layers the format can't express into the same folder (`cmd/timeline.rs:167,177`). Before this change, with `--automation-

Status
open
Score
0.00
Adopters
0 other operators
Author
Anubhav-Rai (imported from pull request #2522, open)
Base
bc4b48f67deef2458e9dd3e3b0e592d5f75768a1
Size
+76 −24 in 2 files
Review
pending
Shared
2026-10-28
Id
a965f9149608fda0e3b68724

How it went

No other operator has reported using it yet.

Diff

raw · crates/automation/src/confine.rs crates/automation/src/tests.rs

diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs--- a/crates/automation/src/confine.rs+++ b/crates/automation/src/confine.rs@@ -4,7 +4,8 @@ //! //! Every engine command an agent runs ([`crate::Backend::exec`], also each step of an //! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside-//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,+//! the roots (symbolic links resolved, no `..`) — the ones it writes inside a write root, and a+//! command can do both — any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the //! Render Queue) are checked against where they would write. Commands that run code or open //! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.@@ -50,6 +51,7 @@ const READS: &[(&str, &[&str])] = &[     ("file.importMultiple", &["paths"]),     ("file.importRecent", &["path"]),     ("file.importLottie", &["path"]),+    ("templates.create", &["path"]),     ("file.importTimeline", &["path"]),     ("file.replaceFootage", &["path"]),     ("file.relinkFootage", &["folder"]),@@ -72,6 +74,12 @@ const WRITES: &[(&str, &[&str])] = &[     ("file.saveAs", &["path"]),     ("file.saveCopy", &["path"]),     ("file.saveCopyAsXml", &["path"]),+    // Writes the timeline file, and the pre-rendered movies of the layers the format can't+    // express into the same folder.+    ("file.exportTimeline", &["path"]),+    // Saves the new project (`file.saveAs`, from inside the command) and extracts the template's+    // footage; with neither given, the footage goes into the settings store.+    ("templates.create", &["projectPath", "footageDir"]),     ("file.collectFiles", &["folder"]),     ("file.exportLottie", &["path"]),     ("comp.saveFrameAs", &["path"]),@@ -142,7 +150,7 @@ impl Confinement {             for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {                 let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };                 let inner_params = step.get("params").cloned().unwrap_or(Value::Null);-                let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);+                let named = [keys_of(WRITES, inner).unwrap_or_default(), keys_of(READS, inner).unwrap_or_default()].concat();                 // A step's `$N` reference is resolved only as the batch runs: never for a file.                 if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {                     return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));@@ -153,36 +161,37 @@ impl Confinement {             }             return Ok(None);         }-        let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) {-            (Some(k), _) => (k, Access::Write),-            (None, Some(k)) => (k, Access::Read),-            (None, None) if id.starts_with("renderQueue.") || id == "prefs.set" => (&[][..], Access::Write),-            (None, None) => (&[][..], Access::Read),-        };+        let writes = keys_of(WRITES, id).unwrap_or_default();+        let reads = keys_of(READS, id).unwrap_or_default();+        // Anything the command is given that the tables don't name: a write for a command that+        // writes, a read otherwise.+        let rest = if writes.is_empty() && !(id.starts_with("renderQueue.") || id == "prefs.set") { Access::Read } else { Access::Write };         let mut given = false;-        for key in named {-            let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };-            given = true;-            let paths: Vec<&Value> = match v {-                Value::Array(a) => a.iter().collect(),-                v => vec![v],-            };-            for p in paths {-                // A Render Queue output may name a template instead of a file.-                match p.as_str() {-                    Some(s) if id == "render.preRender" && !looks_like_path(s) => {}-                    Some(s) => {-                        self.check(s, access)?;+        for (keys, access) in [(writes, Access::Write), (reads, Access::Read)] {+            for key in keys {+                let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };+                given = true;+                let paths: Vec<&Value> = match v {+                    Value::Array(a) => a.iter().collect(),+                    v => vec![v],+                };+                for p in paths {+                    // A Render Queue output may name a template instead of a file.+                    match p.as_str() {+                        Some(s) if id == "render.preRender" && !looks_like_path(s) => {}+                        Some(s) => {+                            self.check(s, access)?;+                        }+                        None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),                     }-                    None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),                 }             }         }         // Any other absolute path the command is given.         let mut other = vec![];-        absolute_strings(params, named, &mut other);+        absolute_strings(params, &[writes, reads].concat(), &mut other);         for s in other {-            self.check(&s, access)?;+            self.check(&s, rest)?;         }         // An entry of a recent list by index is a path the agent didn't give.         if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs--- a/crates/automation/src/tests.rs+++ b/crates/automation/src/tests.rs@@ -1380,3 +1380,46 @@ fn automation_roots_confine_file_access() {     assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");     let _ = std::fs::remove_dir_all(&base); }++/// #2131 (audit F-11, "Unlisted writers are checked as reads"): a command that writes a file it+/// is given belongs in `WRITES`, even when it also reads one. Exporting a timeline and creating+/// a project from a template wrote into a read-only root, because an unlisted path is checked as+/// a read.+#[test]+fn automation_roots_check_an_unlisted_writer_as_a_write() {+    use crate::confine::Confinement;+    let base = std::env::temp_dir().join(format!("ec-mcp-writers-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&base);+    let (read, write) = (base.join("in"), base.join("out"));+    for d in [&read, &write] {+        std::fs::create_dir_all(d).unwrap();+    }+    let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();+    let mut s = server();+    s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());+    let refused = |s: &mut McpServer, args: Value| {+        let (c, err) = call(s, "execute_command", args.clone());+        assert!(err, "{args} should be refused: {c:?}");+        c[0]["text"].as_str().unwrap_or_default().to_string()+    };+    call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));+    // File ▸ Export ▸ Premiere Pro Project: the timeline file, and any pre-render beside it.+    let msg = refused(&mut s, json!({"command": "file.exportTimeline", "params": {"comp": "A", "path": p(&read, "t.xml")}}));+    assert!(msg.contains("cannot write"), "{msg}");+    call_json(&mut s, "execute_command", json!({"command": "file.exportTimeline", "params": {"comp": "A", "path": p(&write, "t.xml")}}));+    assert!(write.join("t.xml").is_file(), "the export inside the write root went through");+    assert_eq!(std::fs::read_dir(&read).unwrap().count(), 0, "nothing was written into the read root");+    // New Project from Template saves the project itself (`file.saveAs` from inside the command,+    // where the confinement no longer sees it) and extracts the template's footage.+    let msg = refused(&mut s, json!({"command": "templates.create", "params": {"id": "title-card", "projectPath": p(&read, "t.ecproj")}}));+    assert!(msg.contains("cannot write"), "{msg}");+    let args = json!({"command": "templates.create", "params": {"id": "title-card", "projectPath": p(&write, "t.ecproj"), "footageDir": p(&read, "f")}});+    assert!(refused(&mut s, args).contains("cannot write"));+    call_json(&mut s, "execute_command", json!({"command": "templates.create", "params": {"id": "title-card", "projectPath": p(&write, "t.ecproj")}}));+    assert!(write.join("t.ecproj").is_file(), "the project inside the write root went through");+    // The template it reads is still a read: a read root is enough for `path`, and refusing it+    // for another reason means the confinement let it by.+    let msg = refused(&mut s, json!({"command": "templates.create", "params": {"path": p(&read, "none.ectemplate")}}));+    assert!(!msg.contains("automation"), "a template in a read root is readable: {msg}");+    let _ = std::fs::remove_dir_all(&base);+}