Files
effectcraft/crates/automation/src/backend.rs
T
Claude 8fb4281632 MCP: --automation-read-root / --automation-write-root confine the agent's file access (#500)
`effectcraft-cli mcp` had no way to keep an agent's file access to a folder;
the flags were unknown options. Both now take a folder (repeatable, as in
PhotoCraft): with either given, files are read only under a read or write root
and written only under a write root (none: no writes). Defaults are unchanged.

automation::confine::Confinement checks every engine command the backend runs
(execute_command, command_run, open/save_project, each engine.batch step), in
both headless and bridge mode, before it runs: the file parameters of the
commands that read or write files must be absolute paths inside the roots
(symbolic links resolved through the longest existing ancestor, `..` refused),
as must any other absolute path in a command's parameters. Commands that write
where they aren't told are checked against the target they would write: Save /
Increment and Save the open project's file, renderQueue.render every queued
output (renderQueue.list). Scripts, Execute File, Reveal in Finder, Open Recent
by index, `$N` file-path references in a batch, render_frame/screenshot paths
outside a write root and the bridge's UI-driving tools are refused while
confined. docs/mcp.md describes it.

Test: a confined headless server opens and saves inside its roots and is refused
outside them, for a relative path, `..`, a batch step, a `$N` path, Save to a
read-only root's project, the Render Queue's default output there, a symbolic
link out of the write root, and scripts; the unconfined server still writes
anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:57 +00:00

144 lines
5.8 KiB
Rust

//! Where tools run: an in-process session, or the desktop app over its control channel.
use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::Confinement;
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub struct Backend {
target: Target,
/// File access confined to folders (`--automation-read-root` / `--automation-write-root`).
confinement: Option<Confinement>,
}
enum Target {
/// An in-process session (no window): engine commands, inspection and rendering.
Headless(Box<Session>),
/// A running desktop app reached over its control channel: everything, plus the live UI.
Bridge(BridgeClient),
}
/// A rendered comp frame.
pub struct Frame {
pub png: Vec<u8>,
pub comp: Value,
pub time: f64,
pub width: u32,
pub height: u32,
}
impl Backend {
pub fn headless(session: Session) -> Self {
Backend { target: Target::Headless(Box::new(session)), confinement: None }
}
/// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`).
pub fn bridge(addr: &str) -> Result<Self> {
Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None })
}
/// Confine the files the commands run here may read and write ([`Confinement`]).
pub fn confine(&mut self, confinement: Confinement) {
self.confinement = Some(confinement);
}
pub fn confinement(&self) -> Option<&Confinement> {
self.confinement.as_ref()
}
pub fn is_bridge(&self) -> bool {
matches!(self.target, Target::Bridge(_))
}
pub fn session(&mut self) -> Option<&mut Session> {
match &mut self.target {
Target::Headless(s) => Some(s),
Target::Bridge(_) => None,
}
}
/// Run an engine command by id (checked against the [`Confinement`], if any).
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
let params = if params.is_null() { json!({}) } else { params };
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
if let Some((lookup, found)) = lookup {
c.check_lookup(&lookup, &found)?;
}
}
self.run(id, params)
}
fn run(&mut self, id: &str, params: Value) -> Result<Value> {
match &mut self.target {
Target::Headless(s) => {
let r = s.execute_checked(id, params);
s.drain_events();
Ok(r?)
}
Target::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})),
}
}
/// Call a raw control-channel method (bridge only).
pub fn control(&mut self, method: &str, params: Value) -> Result<Value> {
match &mut self.target {
Target::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }),
Target::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())),
}
}
/// Render a comp frame (comp time `time` seconds, default the CTI) as PNG, longest side at most
/// `max_side` (0 = full size).
pub fn render(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32) -> Result<Frame> {
self.render_with(comp, time, max_side, false)
}
/// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over
/// the comp's background colour.
pub fn render_with(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool) -> Result<Frame> {
match &mut self.target {
Target::Headless(s) => {
let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?;
let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?;
let png = encode_png(w, h, rgba, max_side)?;
Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h })
}
Target::Bridge(b) => {
let mut p = json!({"max_side": max_side, "base64": true});
if transparent {
p["transparent"] = json!(true);
}
if let Some(t) = time {
p["time"] = json!(t);
}
if let Some(c) = comp.filter(|c| !c.is_null()) {
p["comp"] = c.clone();
}
let r = b.call("render.frame", p)?;
let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?;
let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?;
let (png, width, height) = recompress_png(&raw, max_side)?;
Ok(Frame { png, comp: r["comp"].clone(), time: r["time"].as_f64().unwrap_or(0.0), width, height })
}
}
}
}
fn no_comp(comp: Option<&Value>) -> Error {
match comp.filter(|c| !c.is_null()) {
Some(c) => Error::BadArgs(format!("no composition {c} (see get_project)")),
None => Error::BadArgs("no active composition: create one (execute_command comp.new) or open a project".into()),
}
}
pub const NEED_BRIDGE: &str =
"this tool drives the live app: start it with `effectcraft --control 9877` and run the MCP server as `effectcraft-cli mcp --bridge 9877`";