collective

Browse / github.com/storytold/effectcraft / patch

automation: a confined agent can't start a script's code (#2131 F-11, "Running code")

automation: a confined agent can't start a script's code (#2131 F-11, "Running code") Part of #2131 — the "Running code" item the audit lists. The other four problems in that ticket (unlisted writers checked as reads, relative paths, indirect footage reads, the depth-64 walk) are not touched here; ticket-craft asked for them to be split, and this is one of them. ## The hole `REFUSED` covers `script.run`, `file.runScript` and the install/uninstall commands, so a confined agent can't run a script. It can still start one five other ways: | command | what runs | | --- | --- | | `window.scriptPanel` | `open_panel` → `run_js_named` — the panel's script runs to build it (`cmd/scrip

Status
open
Score
0.00
Adopters
0 other operators
Author
Anubhav-Rai (imported from pull request #2521, open)
Base
bc4b48f67deef2458e9dd3e3b0e592d5f75768a1
Size
+94 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
718d5f46a3b689eb3f2e7906

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@@ -7,7 +7,9 @@ //! the roots (symbolic links resolved, no `..`), 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.+//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,+//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network+//! access the user withheld.  use std::path::{Component, Path, PathBuf}; @@ -35,6 +37,15 @@ const REFUSED: &[&str] = &[     "file.installScript",     "file.installScriptUIPanel",     "file.uninstallScript",+    // A ScriptUI panel's script runs when the panel opens, when an event reaches one of its+    // controls, and when one of its `app.scheduleTask` tasks comes due.+    "window.scriptPanel",+    "window.restoreScriptPanels",+    "script.runTasks",+    "scriptui.click",+    "scriptui.set",+    "scriptui.mouse",+    "scriptui.close",     "file.executeFile",     "file.revealInFinder",     "layer.openSource",@@ -138,6 +149,15 @@ impl Confinement {         if REFUSED.contains(&id) {             return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));         }+        // Scripts can't grant themselves the access the user withheld+        // (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined+        // agent: `prefs.set` saves the settings file, so a script run long after this session+        // would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.)+        if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) {+            return Err(Error::BadArgs(+                "`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(),+            ));+        }         if id == "engine.batch" {             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 };@@ -218,6 +238,22 @@ impl Confinement {     } } +/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON+/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as+/// the dotted spelling, and the page on its own replaces every setting on it.+fn is_scripting_pref(key: &str) -> bool {+    key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting"))+}++/// The settings `prefs.set` is given: `key`, and the keys of `values`.+fn prefs_keys(params: &Value) -> Vec<&str> {+    let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect();+    if let Some(Value::Object(m)) = params.get("values") {+        keys.extend(m.keys().map(String::as_str));+    }+    keys+}+ fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {     table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) }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,60 @@ 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, "Running code"): `script.run` and the Scripts menu are refused while file+/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens,+/// when an event reaches one of its controls, or when one of its tasks comes due — and+/// `prefs.set` could grant every script run afterwards the file and network access the user+/// withheld, in the settings file, long after the session. All of that is refused too.+#[test]+fn automation_roots_refuse_running_script_code() {+    use crate::confine::Confinement;+    let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&base);+    std::fs::create_dir_all(&base).unwrap();+    let mut s = server();+    s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap());+    let refused = |s: &mut McpServer, name: &str, args: Value| {+        let (c, err) = call(s, name, args.clone());+        assert!(err, "{name} {args} should be refused: {c:?}");+        c[0]["text"].as_str().unwrap_or_default().to_string()+    };+    // Every way a script's code starts.+    for (command, params) in [+        ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),+        ("window.restoreScriptPanels", json!({})),+        ("script.runTasks", json!({})),+        ("scriptui.click", json!({"window": 1, "widget": 2})),+        ("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})),+        ("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})),+        ("scriptui.close", json!({"window": 1})),+    ] {+        let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params}));+        assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}");+    }+    // Including as a batch step.+    let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]}));+    assert!(msg.contains("not available while file access is confined"), "{msg}");+    // What scripts may do stays as the user left it, by either spelling of the setting.+    for params in [+        json!({"key": "scripting.allowScriptsWriteFiles", "value": true}),+        json!({"key": "scripting.allow_scripts_write_files", "value": true}),+        json!({"values": {"scripting.warnExecutingFiles": false}}),+        json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}),+        // `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too…+        json!({"key": "scripting/allowScriptsWriteFiles", "value": true}),+        // …and the page on its own replaces every setting on it.+        json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}),+        json!({"values": {"scripting": {"warnExecutingFiles": false}}}),+    ] {+        let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params}));+        assert!(msg.contains("what a script may do"), "{msg}");+    }+    let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}}));+    assert_eq!(got, json!(false), "{got}");+    // Other settings, and looking at the script windows, still work.+    call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}}));+    call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));+    let _ = std::fs::remove_dir_all(&base);+}