This commit is contained in:
Brandon Thomas committed 2026-10-31 10:54:51 -04:00
commit d3897efd86
7 files changed
+273 -6

No files matched your search

Generated
+1
View File
@@ -1432,6 +1432,7 @@ version = "0.19.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
"effectcraft-model",
"effectcraft-script",
"image",
"serde_json",
+4
View File
@@ -114,3 +114,7 @@ real_name = "Ashok Ram"
[people.dylanmtaylor]
sync_github_name = true
[people.adikshan11]
real_name = "Adithya Shankaran"
sync_github_name = true
+1
View File
@@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
effectcraft-model = { workspace = true }
[dev-dependencies]
effectcraft-script = { workspace = true }
+8 -2
View File
@@ -4,7 +4,7 @@ use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::{Confinement, Lookup};
use crate::confine::{ConfinedFootage, ConfinedServices, Confinement, Lookup};
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub struct Backend {
@@ -39,8 +39,14 @@ impl Backend {
Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None })
}
/// Confine the files the commands run here may read and write ([`Confinement`]).
/// Confine the files the commands run here may read and write ([`Confinement`]). In-process,
/// the session's own footage and file reads are confined too, so a project from a read root
/// can't show footage from outside it.
pub fn confine(&mut self, confinement: Confinement) {
if let Target::Headless(s) = &mut self.target {
s.footage = std::sync::Arc::new(ConfinedFootage::new(s.footage.clone(), confinement.clone()));
s.services = std::sync::Arc::new(ConfinedServices::new(s.services.clone(), confinement.clone()));
}
self.confinement = Some(confinement);
}
+132 -3
View File
@@ -8,12 +8,21 @@
//! 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. Queueing a render without naming
//! its output needs a saved project, because the default output of an unsaved one is the home
//! folder. Commands that run code or open 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.
//! folder. In a headless session the footage and other files a project or an imported file names
//! are read only inside the roots ([`ConfinedFootage`], [`ConfinedServices`]). Commands that run
//! code or open 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};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use effectcraft_engine::Services;
use effectcraft_engine::project::{Footage, ItemId};
use effectcraft_engine::raster::{AuxChannels, Image};
use effectcraft_engine::render::{FloatCurve, FootageSource};
use effectcraft_engine::time::Tick;
use serde_json::Value;
use crate::{Error, Result};
@@ -302,6 +311,126 @@ impl Confinement {
}
}
/// The session's footage, read only inside the read roots (#2131 F-11, "Indirect reads"). The
/// commands an agent runs are checked by the paths they are given, but a project opened or
/// imported from a read root names its own footage, which is read when it renders: a file
/// outside the roots shows as missing footage instead.
pub struct ConfinedFootage {
inner: Arc<dyn FootageSource>,
confinement: Confinement,
}
impl ConfinedFootage {
pub fn new(inner: Arc<dyn FootageSource>, confinement: Confinement) -> Self {
Self { inner, confinement }
}
/// Whether every file of `f` (its file and each frame of a sequence) is inside the roots.
/// Checked on every read, not remembered: a symbolic link inside a root can be pointed
/// elsewhere after it was first read.
fn inside(&self, f: &Footage) -> bool {
std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok())
}
}
impl FootageSource for ConfinedFootage {
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten()
}
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
}
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
}
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten()
}
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
self.inside(footage).then(|| self.inner.model(item, footage)).flatten()
}
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
}
/// Says why footage outside the roots shows nothing, so the agent can act on it.
fn problem(&self, footage: &Footage) -> Option<String> {
if !self.inside(footage) {
return Some(format!("{} is outside the automation read roots, so it is not read", footage.path));
}
self.inner.problem(footage)
}
fn set_cache_budget(&self, bytes: usize) {
self.inner.set_cache_budget(bytes)
}
fn purge(&self) {
self.inner.purge()
}
fn forget(&self, path: &str) {
self.inner.forget(path)
}
fn set_conform_folder(&self, folder: Option<PathBuf>) {
self.inner.set_conform_folder(folder)
}
fn cache_budget(&self) -> Option<usize> {
self.inner.cache_budget()
}
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
self.inner.cancellable(cancel, f)
}
fn called_off(&self) -> bool {
self.inner.called_off()
}
}
/// The session's file reads, only inside the read roots: what a project or an imported file
/// pulls in by itself (an SVG's or Lottie's images, a timeline's media) is checked like a path
/// the agent gives. Writes are left to the command checks.
pub struct ConfinedServices {
inner: Arc<dyn Services>,
confinement: Confinement,
}
impl ConfinedServices {
pub fn new(inner: Arc<dyn Services>, confinement: Confinement) -> Self {
Self { inner, confinement }
}
fn inside(&self, path: &str) -> bool {
self.confinement.check(path, Access::Read).is_ok()
}
}
impl Services for ConfinedServices {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.confinement.check(path, Access::Read).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()))?;
self.inner.read_file(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
self.inner.write_file(path, data)
}
fn exists(&self, path: &str) -> bool {
self.inside(path) && self.inner.exists(path)
}
fn stamp(&self, path: &str) -> Option<(u64, u128)> {
if self.inside(path) { self.inner.stamp(path) } else { None }
}
fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
self.inner.store_file(path, data)
}
fn list_dir(&self, dir: &str) -> Vec<String> {
if self.inside(dir) { self.inner.list_dir(dir) } else { vec![] }
}
fn is_dir(&self, path: &str) -> bool {
self.inside(path) && self.inner.is_dir(path)
}
fn full_path(&self, path: &str) -> Option<String> {
self.inner.full_path(path)
}
}
/// 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.
+124
View File
@@ -1730,3 +1730,127 @@ fn bridged_render_frame_asks_the_app_before_it_writes() {
assert_eq!(&std::fs::read(&free).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
let _ = std::fs::remove_dir_all(&dir);
}
/// #2131 F-11 ("Indirect reads"): a project opened or imported from inside a read root names its
/// footage, and those files are read when it renders. Footage outside the roots (an absolute
/// path, `../`, a symbolic link to a file or a folder out of the root) must not reach the agent:
/// its layer renders as missing footage, while footage inside the roots still renders.
#[test]
fn automation_roots_confine_the_footage_a_project_names() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-footage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
let red = image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255]));
red.save(outside.join("secret.png")).unwrap();
red.save(read.join("own.png")).unwrap();
let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
// A project in the read root showing `footage`, saved unconfined.
let project = |name: &str, footage: &str| {
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "C", "width": 8, "height": 8}}));
let imported = call_json(&mut s, "execute_command", json!({"command": "file.import", "params": {"paths": [footage]}}));
let item = imported["items"][0].as_u64().or_else(|| imported["item"].as_u64()).unwrap_or_else(|| panic!("{imported}"));
call_json(&mut s, "execute_command", json!({"command": "layer.addItem", "params": {"comp": "C", "item": item}}));
let path = p(&read, name);
call_json(&mut s, "save_project", json!({"path": path}));
path
};
// The middle pixel of comp C: red when the footage was read.
let shows_red = |s: &mut McpServer| {
let (c, err) = call(s, "render_frame", json!({"comp": "C"}));
assert!(!err, "{c:?}");
let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap();
let px = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(4, 4).0;
px[0] > 200 && px[3] > 200
};
let mut projects = vec![("absolute", project("absolute.ecproj", &p(&outside, "secret.png")))];
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.join("secret.png"), read.join("link.png")).unwrap();
std::os::unix::fs::symlink(&outside, read.join("dir")).unwrap();
projects.push(("linked file", project("linked-file.ecproj", &p(&read, "link.png"))));
projects.push(("linked folder", project("linked-folder.ecproj", &p(&read.join("dir"), "secret.png"))));
}
// Relative to the project: `../elsewhere/secret.png`, written into a copy of a project.
let own = project("own.ecproj", &p(&read, "own.png"));
let text = std::fs::read_to_string(&own).unwrap();
assert!(text.contains(&p(&read, "own.png")) || text.contains("own.png"), "{text}");
let relative = text.replace(&p(&read, "own.png"), "../elsewhere/secret.png").replace("\"own.png\"", "\"../elsewhere/secret.png\"");
std::fs::write(read.join("relative.ecproj"), relative).unwrap();
projects.push(("relative", p(&read, "relative.ecproj")));
let confined = || {
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());
s
};
// Footage inside the read root renders.
let mut s = confined();
call_json(&mut s, "open_project", json!({"path": own}));
assert!(shows_red(&mut s), "footage inside the read root");
for (how, path) in &projects {
// Unconfined, the project shows the outside file.
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "open_project", json!({"path": path}));
assert!(shows_red(&mut s), "{how}: unconfined shows the footage");
// Confined: opened, or imported into the open project.
let mut s = confined();
call_json(&mut s, "open_project", json!({"path": path}));
assert!(!shows_red(&mut s), "{how}: opened, the footage outside the roots was read");
let mut s = confined();
call_json(&mut s, "execute_command", json!({"command": "file.importProject", "params": {"path": path}}));
assert!(!shows_red(&mut s), "{how}: imported, the footage outside the roots was read");
}
let _ = std::fs::remove_dir_all(&base);
}
/// A symbolic link inside the read root, read once while it pointed inside, is checked again
/// on every read: pointed outside afterwards, its footage must not come back once the frame
/// cache is emptied.
#[cfg(unix)]
#[test]
fn automation_roots_check_relinked_footage_again() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-relink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 255, 255])).save(read.join("own.png")).unwrap();
image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255])).save(outside.join("secret.png")).unwrap();
let link = read.join("link.png");
std::os::unix::fs::symlink(read.join("own.png"), &link).unwrap();
let link_path = link.to_string_lossy().into_owned();
let project = read.join("relink.ecproj").to_string_lossy().into_owned();
{
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "C", "width": 8, "height": 8}}));
let imported = call_json(&mut s, "execute_command", json!({"command": "file.import", "params": {"paths": [link_path]}}));
let item = imported["items"][0].as_u64().or_else(|| imported["item"].as_u64()).unwrap_or_else(|| panic!("{imported}"));
call_json(&mut s, "execute_command", json!({"command": "layer.addItem", "params": {"comp": "C", "item": item}}));
call_json(&mut s, "save_project", json!({"path": project}));
}
// The middle pixel of comp C.
let pixel = |s: &mut McpServer| {
let (c, err) = call(s, "render_frame", json!({"comp": "C"}));
assert!(!err, "{c:?}");
let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap();
image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(4, 4).0
};
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());
call_json(&mut s, "open_project", json!({"path": project}));
let px = pixel(&mut s);
assert!(px[2] > 200 && px[0] < 50, "the link pointing inside renders: {px:?}");
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(outside.join("secret.png"), &link).unwrap();
call_json(&mut s, "execute_command", json!({"command": "edit.purge", "params": {"what": "memory"}}));
let px = pixel(&mut s);
assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}");
let _ = std::fs::remove_dir_all(&base);
}
+3 -1
View File
@@ -13,7 +13,9 @@ parameters must be absolute paths inside the roots (symbolic links resolved, no
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);
`renderQueue.add`, `render.preRender` and `file.createProxy` without an output file need a saved
project, since an unsaved one's default output would go to the home folder (#2479). Scripts
project, since an unsaved one's default output would go to the home folder (#2479). In a headless
session the footage a project names, and what an imported file pulls in, is read only inside the
roots too: footage outside them shows as missing (#2131). 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.