Browse / github.com/storytold/photocraft / patch
Automation: judge dialog previews by the policy that judges OK
Automation: Displace and Color Lookup dialog previews read files the automation policy refuses **Version:** PhotoCraft 0.16.0 (b1794c6e0, 2026-10-25), Windows 11, desktop app with `--control` and both automation roots set to one folder. **Problem** A control or MCP client confined by `--automation-read-root` can open the Filter › Distort › Displace dialog or the Image › Adjustments › Color Lookup dialog and set its file field (`mapPath` / `file`) to a file outside the read root. Pressing OK is correctly refused by the automation policy. The dialog's live preview, however, runs the same command on a copy of the document every frame without asking the policy: it reads and decodes that f
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Higgs32584 (imported from pull request #9772, open)
- Base
d42f9da94a421980d7130c63ca2953e47802cfbe- Size
- +232 −2 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
300414c26e83a5d4bbab15b1
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/app.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/automation_preview.rs crates/ui-egui/src/canvas/filter_preview.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs/adjust_preview.rs crates/ui-egui/src/lib.rs docs/control-protocol.md
@@ -114,6 +114,7 @@ impl PhotocraftApp { synthetic_reinject: false, synthetic_snapshot: None, automation_input: false,+ automation_dialogs: Vec::new(), paused_playbacks: Default::default(), channel_thumbs: None, channel_views: HashMap::new(),@@ -283,6 +283,10 @@ impl PhotocraftApp { // Remember the 0.37 panel sizes so a later 0.36 rollback's newer old-key values win on // the next upgrade (see `panels::migrate_panel_state`). panels::remember_panel_state(&ctx);+ // Synthetic input could have edited any dialog open now: preview it under the policy.+ if self.automation_input {+ crate::automation_preview::note_open(self);+ } self.automation_input = false; shape_stroke_ui::finish_frame(self, &ctx); native_menu::sync(self, &ctx);new file mode 100644@@ -0,0 +1,202 @@+//! Dialog previews under the automation policy.+//!+//! A control or MCP client confined to its automation roots may open a filter or adjustment+//! dialog and fill it. Confirming is judged by the automation policy, and so must be the live+//! preview, which runs the same command on a copy of the document every frame: otherwise a+//! Displace map or Color Lookup file the policy refuses would still be read, decoded and shown+//! on the canvas, where the client can screenshot it.+//!+//! The app remembers which dialogs automation opened or changed (a control request that added+//! or edited one, or a synthetic input pass while it was open). Their previews, and every+//! preview computed while automation input is being handled, pass the policy first. Dialogs+//! only the user touched preview as before.++use crate::PhotocraftApp;+use serde_json::{Map, Value};++/// The open dialogs' ids and fields before a control request, so [`note_changes`] can tell+/// which ones the request opened or edited.+pub(crate) type Snapshot = Vec<(u64, Map<String, Value>)>;++pub(crate) fn snapshot(app: &PhotocraftApp) -> Snapshot {+ app.ui.dialogs.iter().map(|d| (d.id, d.fields.clone())).collect()+}++/// After a control request: mark every dialog it opened or changed as automation-driven.+pub(crate) fn note_changes(app: &mut PhotocraftApp, before: &Snapshot) {+ let changed: Vec<u64> = app.ui.dialogs.iter().filter(|d| !before.iter().any(|(id, fields)| *id == d.id && *fields == d.fields)).map(|d| d.id).collect();+ mark(app, changed);+}++/// After a synthetic input pass: the input could have reached any open dialog.+pub(crate) fn note_open(app: &mut PhotocraftApp) {+ let open: Vec<u64> = app.ui.dialogs.iter().map(|d| d.id).collect();+ mark(app, open);+}++fn mark(app: &mut PhotocraftApp, ids: Vec<u64>) {+ for id in ids {+ if !app.automation_dialogs.contains(&id) {+ app.automation_dialogs.push(id);+ }+ }+ // Forget closed dialogs so the list stays as small as the open ones.+ let open = &app.ui.dialogs;+ app.automation_dialogs.retain(|id| open.iter().any(|d| d.id == *id));+}++/// Why the automation policy refuses to preview dialog `dialog` (its `fields`) as `command` with+/// `params`, or None when the preview may run. Only automation-driven dialogs, or previews+/// computed while automation input is handled, are judged; the user's own dialogs never are.+pub(crate) fn refusal(app: &PhotocraftApp, dialog: u64, fields: &Map<String, Value>, command: &str, params: &Value) -> Option<String> {+ if !(app.automation_input || app.session.authorize.is_some() || app.automation_dialogs.contains(&dialog)) {+ return None;+ }+ // What confirming would be judged as (the dialog's command and fields), then what the+ // preview actually runs, which can differ (a smart filter's edit, a layer mask target).+ let dialog_command = fields.get("__command").and_then(Value::as_str).map(|c| (c, Value::Object(fields.clone())));+ let mut checks = dialog_command.into_iter().chain(std::iter::once((command, params.clone())));+ if let Some(authorize) = app.services.automation_command.as_ref() {+ return checks.find_map(|(id, p)| authorize(id, &p).err());+ }+ let gate = app.session.authorize.or(app.services.automation_authorize)?;+ checks.find_map(|(id, p)| gate(id, &p).err().map(|e| e.to_string()))+}++#[cfg(test)]+mod tests {+ use crate::PhotocraftApp;+ use crate::control::{ControlRequest, Outcome, handle};+ use serde_json::{Value, json};++ /// The part of the desktop policy (`photocraft_automation::workspace`) these previews meet:+ /// a Displace `mapPath` or a Color Lookup `file` names a file outside the session.+ fn policy(id: &str, params: &Value) -> photocraft_engine::Result<()> {+ let key = match id {+ "filter.distort.displace" => "mapPath",+ "image.adjustments.colorLookup" => "file",+ _ => return Ok(()),+ };+ match params.get(key).and_then(Value::as_str) {+ Some(path) if !path.is_empty() => Err(photocraft_engine::EngineError::Other(format!("automation command `{id}` is disabled"))),+ _ => Ok(()),+ }+ }++ fn confined_app() -> (PhotocraftApp, egui::Context) {+ let services = crate::Services {+ automation_authorize: Some(policy),+ automation_command: Some(Box::new(|id, params| policy(id, params).map_err(|e| e.to_string()))),+ ..Default::default()+ };+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);+ app.background_jobs = false;+ (app, egui::Context::default())+ }++ fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value {+ let (req, _rx) = ControlRequest::new(method, params);+ match handle(app, ctx, &req) {+ Outcome::Done(v) => v,+ _ => panic!("{method}: expected an immediate reply"),+ }+ }++ fn temp_dir(tag: &str) -> std::path::PathBuf {+ let dir = std::env::temp_dir().join(format!("pc-automation-preview-{tag}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();+ dir+ }++ /// A map whose left half is yellow (shift right and down) and right half black.+ fn displace_map() -> Vec<u8> {+ let rgba: Vec<u8> = (0..64u32).flat_map(|_| (0..64u32).flat_map(|x| if x < 32 { [255, 255, 0, 255] } else { [0, 0, 0, 255] })).collect();+ let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap();+ photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()+ }++ /// A size-2 .cube that swaps red and blue.+ fn swap_cube() -> String {+ let mut cube = String::from("LUT_3D_SIZE 2\n");+ for b in 0..2 {+ for g in 0..2 {+ for r in 0..2 {+ cube.push_str(&format!("{b} {g} {r}\n"));+ }+ }+ }+ cube+ }++ fn preview_pixels(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<Vec<[f32; 4]>> {+ let idx = app.session.active_index().unwrap();+ crate::canvas::ensure_filter_preview(app, idx, ctx)?;+ app.filter_preview.as_ref().and_then(|p| p.result.as_ref()).map(|d| photocraft_compose::flatten(d).px)+ }++ #[test]+ fn displace_preview_of_an_automation_dialog_does_not_read_a_refused_map() {+ let dir = temp_dir("displace");+ let map = dir.join("map.png");+ std::fs::write(&map, displace_map()).unwrap();+ let (mut app, ctx) = confined_app();+ app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();+ app.session.execute("filter.render.clouds", json!({"seed": 4})).unwrap();+ let original = photocraft_compose::flatten(&app.session.active().unwrap().doc).px;+ let values = [("mapPath", json!(map.to_string_lossy())), ("horizontal", json!(50)), ("vertical", json!(50))];++ // A confined client opens and fills the dialog: its preview is refused like OK is.+ let r = call(&mut app, &ctx, "ui.dialog.open", json!({"kind": "command", "command": "filter.distort.displace"}));+ let dialog = r["result"]["dialog"].as_u64().unwrap();+ for (field, value) in &values {+ let r = call(&mut app, &ctx, "ui.dialog.set", json!({"dialog": dialog, "field": field, "value": value}));+ assert!(r.get("error").is_none(), "{r}");+ }+ assert_eq!(preview_pixels(&mut app, &ctx), None, "no preview is computed from the refused map");+ assert!(app.ui.status_error && app.ui.status.contains("filter.distort.displace"), "{}", app.ui.status);+ let confirm = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": dialog}));+ assert_eq!(confirm["ok"], false, "{confirm}");+ crate::dialogs::cancel(&mut app, dialog).unwrap();++ // The user's own dialog previews the same map; a client only inspecting it changes nothing.+ let mine = crate::dialogs::open_command_dialog(&mut app, "filter.distort.displace", "Displace");+ for (field, value) in values {+ app.ui.dialog_mut(mine).unwrap().fields.insert(field.into(), value);+ }+ assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["ok"], true);+ let px = preview_pixels(&mut app, &ctx).expect("the user's preview runs");+ assert_ne!(px, original, "the map displaced the user's preview");+ // Synthetic input reaches whatever dialog is open: the policy judges its preview too.+ app.automation_input = true;+ app.session.execute("filter.render.clouds", json!({"seed": 5})).unwrap();+ assert_eq!(preview_pixels(&mut app, &ctx), None);+ app.automation_input = false;+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn color_lookup_preview_of_an_automation_dialog_does_not_read_a_refused_lut() {+ let dir = temp_dir("lut");+ let lut = dir.join("swap.cube");+ std::fs::write(&lut, swap_cube()).unwrap();+ let (mut app, ctx) = confined_app();+ app.session.execute("file.new", json!({"width": 16, "height": 16})).unwrap();+ app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();++ let r = call(&mut app, &ctx, "ui.dialog.open", json!({"kind": "command", "command": "image.adjustments.colorLookup"}));+ let dialog = r["result"]["dialog"].as_u64().unwrap();+ let r = call(&mut app, &ctx, "ui.dialog.set", json!({"dialog": dialog, "field": "file", "value": lut.to_string_lossy()}));+ assert!(r.get("error").is_none(), "{r}");+ assert_eq!(preview_pixels(&mut app, &ctx), None, "no preview is computed from the refused LUT");+ assert!(app.ui.status_error && app.ui.status.contains("image.adjustments.colorLookup"), "{}", app.ui.status);+ crate::dialogs::cancel(&mut app, dialog).unwrap();++ let mine = crate::dialogs::open_command_dialog(&mut app, "image.adjustments.colorLookup", "Color Lookup");+ app.ui.dialog_mut(mine).unwrap().fields.insert("file".into(), json!(lut.to_string_lossy()));+ let px = preview_pixels(&mut app, &ctx).expect("the user's preview runs");+ let first = px.first().copied().unwrap();+ assert!(first[2] > 0.9 && first[0] < 0.1, "the LUT turned the user's red preview blue: {first:?}");+ let _ = std::fs::remove_dir_all(&dir);+ }+}@@ -58,6 +58,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } params = crate::select_mask_view::request_params(&d.fields, params); }+ // A dialog automation drove previews only what the policy lets it confirm: the preview reads+ // the same files (a Displace map, a Color Lookup file) and the client can screenshot it.+ let refused = crate::automation_preview::refusal(app, d.id, &d.fields, &cmd, ¶ms); let st = app.session.documents().get(idx)?; let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); let request = crate::filter_dialog::FilterPreviewKey {@@ -71,6 +74,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e k, }; let key = request.doc.0 ^ (1u64 << 61);+ if let Some(error) = refused {+ // Show the document without a preview, and say why once per set of values.+ if !app.filter_preview.as_ref().is_some_and(|p| p.key == request && p.result.is_none()) {+ app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request, result: None, committing: false });+ crate::notices::error(app, error);+ }+ return None;+ } // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at // 1000 px takes long enough to freeze the window. Once the slider settles, a preview still // running for an older value is cancelled.@@ -174,7 +174,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest if let Some(gate) = app.services.automation_authorize { app.session.authorize = Some(gate); }+ let before = crate::automation_preview::snapshot(app); let outcome = dispatch(app, ctx, req);+ crate::automation_preview::note_changes(app, &before); app.session.authorize = saved; outcome }@@ -191,7 +191,13 @@ fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> { } let kind = d.fields.get("__adjust")?.as_str()?.to_string(); let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true);- Some((kind, crate::filter_dialog::params_of(&d.fields), preview))+ let params = crate::filter_dialog::params_of(&d.fields);+ // Building the preview reads what the params name (a Color Lookup `file`): a dialog+ // automation drove has no layer preview the policy would refuse to confirm.+ if crate::automation_preview::refusal(app, d.id, &d.fields, &format!("image.adjustments.{kind}"), ¶ms).is_some() {+ return None;+ }+ Some((kind, params, preview)) } /// FNV-1a over `parts`, never 0.@@ -13,6 +13,7 @@ macro_rules! tl { $crate::i18n::t($s) }; }+pub(crate) mod automation_preview; pub mod canvas; pub mod control; pub mod dialogs;@@ -500,6 +501,9 @@ pub struct PhotocraftApp { /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. pub(crate) automation_input: bool,+ /// Open dialogs a control request or synthetic input opened or edited: their live previews+ /// pass the automation policy as confirming them would (`automation_preview`).+ pub(crate) automation_dialogs: Vec<u64>, /// Action playbacks paused at a step dialog, by the token in that dialog's fields /// ([`actions::RESUME`]): what to play next and the automation gate it paused under. pub(crate) paused_playbacks: actions::PausedPlaybacks,@@ -179,7 +179,7 @@ automation permissions. UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`.+Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. So is the live preview of a dialog the client opened or edited (through `ui.dialog.*`, a menu click or synthetic input): a Displace map or Color Lookup file it would refuse on OK is not read for the preview either, and the canvas shows the document unchanged with the refusal as a notice. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`. Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sources