diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -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(),
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -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);
diff --git a/crates/ui-egui/src/automation_preview.rs b/crates/ui-egui/src/automation_preview.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/automation_preview.rs
@@ -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);
+    }
+}
diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs
--- a/crates/ui-egui/src/canvas/filter_preview.rs
+++ b/crates/ui-egui/src/canvas/filter_preview.rs
@@ -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, &params);
     let st = app.session.documents().get(idx)?;
     let k = filter_preview_factor(app, &st.doc, &cmd, &params);
     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.
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -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
 }
diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs
--- a/crates/ui-egui/src/dialogs/adjust_preview.rs
+++ b/crates/ui-egui/src/dialogs/adjust_preview.rs
@@ -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}"), &params).is_some() {
+        return None;
+    }
+    Some((kind, params, preview))
 }
 
 /// FNV-1a over `parts`, never 0.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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,
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -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
