diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs
--- a/crates/engine/src/cmd/actions_cmds.rs
+++ b/crates/engine/src/cmd/actions_cmds.rs
@@ -48,6 +48,29 @@ pub struct Action {
     /// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`].
     #[serde(default, skip_serializing_if = "String::is_empty")]
     pub color: String,
+    /// Written by automation: loaded, recorded, or had its steps edited while
+    /// [`Session::authorize`] was installed (a control, MCP or synthetic-input request).
+    /// Playback keeps automation's gate ([`Session::automation_gate`]) for it even when the user
+    /// starts it (a key, the Actions panel), so a client confined to its automation roots
+    /// can't plant steps that later run with the user's file access. Batch and droplets copy
+    /// steps instead of the action, so they do not keep the gate yet.
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    pub automation: bool,
+}
+
+/// Mark action `idx` as written by automation when an automation gate is installed.
+pub(crate) fn note_automation_edit(s: &mut Session, idx: usize) {
+    if s.authorize.is_some()
+        && let Some(action) = s.actions.list.get_mut(idx)
+    {
+        action.automation = true;
+    }
+}
+
+/// The gate to install while `action` plays: automation's, for an action automation wrote that
+/// plays with none installed. `None` when the session's own gate (or none) applies.
+pub fn kept_gate(s: &Session, action: &Action) -> Option<fn(&str, &Value) -> Result<()>> {
+    if action.automation && s.authorize.is_none() { s.automation_gate } else { None }
 }
 
 /// Button Mode colours, in Photoshop's order. Empty is no colour.
@@ -55,7 +78,7 @@ pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "b
 
 impl Action {
     pub fn new(name: impl Into<String>, steps: Vec<(String, Value)>) -> Self {
-        Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() }
+        Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new(), automation: false }
     }
 
     /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved.
@@ -330,6 +353,7 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
         s.actions.list.len() - 1
     };
     s.actions.recording = Some((s.journal.len(), idx));
+    note_automation_edit(s, idx);
     s.actions.take = s.actions.take.wrapping_add(1);
     let name = s.actions.list.get(idx).map(|a| a.name.clone()).unwrap_or_default();
     Ok(json!({"action": name, "index": idx}))
@@ -454,7 +478,15 @@ pub fn nested_failure(id: &str, value: &Value) -> Option<String> {
 fn play(s: &mut Session, p: &Value) -> Result<Value> {
     let (action, from) = playback_plan(s, p)?;
     let recording = begin_playback(s, &action)?;
+    // Nested calls and commands the steps run on their own behalf see the kept gate too.
+    let kept = kept_gate(s, &action);
+    if kept.is_some() {
+        s.authorize = kept;
+    }
     let (ran, failed) = run_recorded(s, &action, from);
+    if kept.is_some() {
+        s.authorize = None;
+    }
     finish_playback(s, recording, &action, from);
     Ok(match failed {
         Some(f) => json!({"action": action.name, "ran": ran, "failed": f}),
@@ -491,6 +523,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
         action.steps.insert(to, moved);
         action.remap_steps(|i| Some(moved_index(i, step, to)));
         let result = json!({"action": action.name, "step": to});
+        note_automation_edit(s, idx);
         s.actions.touch();
         Ok(result)
     } else {
@@ -547,6 +580,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
             }
         });
         let result = json!({"action": action.name, "deletedStep": step, "steps": action.steps.len()});
+        note_automation_edit(s, idx);
         s.actions.touch();
         return Ok(result);
     }
@@ -600,6 +634,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
         None => action.disabled = (0..action.steps.len()).collect(),
     }
     let result = json!({"action": action.name, "disabled": action.disabled});
+    note_automation_edit(s, idx);
     s.actions.touch();
     Ok(result)
 }
diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs
--- a/crates/engine/src/cmd/actions_cmds/tests.rs
+++ b/crates/engine/src/cmd/actions_cmds/tests.rs
@@ -729,6 +729,48 @@ fn play_checks_each_step_with_the_authorize_hook() {
     assert!(s.documents().is_empty(), "the refused step does not open a document");
 }
 
+/// An action automation loads or edits keeps automation's gate when the user plays it (its
+/// key, the panel), so a client confined to its roots can't plant file steps that later run
+/// with the user's access. The user's own actions, and sets the user loads, run as before.
+#[test]
+fn actions_automation_wrote_keep_its_gate_when_the_user_plays_them() {
+    fn deny_file(id: &str, _: &Value) -> Result<()> {
+        if id.starts_with("file.") { Err(EngineError::Other(format!("automation command `{id}` is disabled"))) } else { Ok(()) }
+    }
+    let open = || ("file.openAs".to_string(), json!({"path": "/outside/secret.png"}));
+    let mut s = Session::new();
+    s.automation_gate = Some(deny_file);
+    s.actions.list.push(Action::new("Mine", vec![open()]));
+    s.actions.list.push(Action::new("Edited", vec![("layer.new.layer".into(), json!({}))]));
+    let (atn, _) = crate::atn_cmds::export("Set", &[&Action::new("Planted", vec![open()])], &[], false).unwrap();
+    let data = photocraft_paint::tile::b64_encode(&atn);
+
+    // An automation client loads a set and inserts a step into an existing action.
+    s.authorize = Some(deny_file);
+    s.execute("actions.load", json!({"data": data})).unwrap();
+    s.execute("actions.insert", json!({"action": "Edited", "id": "file.openAs", "params": {"path": "/outside/secret.png"}, "at": 0})).unwrap();
+    s.authorize = None;
+    assert!(!s.actions.list[0].automation, "the user's own action is not marked");
+
+    for name in ["Planted", "Edited"] {
+        let r = s.execute("actions.play", json!({"action": name})).unwrap();
+        assert_eq!(r["failed"]["id"], "file.openAs", "{name}: {r}");
+        assert!(r["failed"]["error"].as_str().unwrap().contains("disabled"), "{name}: {r}");
+        assert!(s.authorize.is_none(), "{name}: the kept gate is removed after playback");
+    }
+    // Nested: the user's action calling a planted one still meets the gate.
+    s.actions.list.push(Action::new("Caller", vec![("actions.play".into(), json!({"action": "Planted"}))]));
+    let r = s.execute("actions.play", json!({"action": "Caller"})).unwrap();
+    assert!(r["failed"]["error"].as_str().unwrap().contains("disabled"), "{r}");
+
+    // The user's own action runs with the user's access (the file is missing, not refused).
+    let r = s.execute("actions.play", json!({"action": "Mine"})).unwrap();
+    assert!(!r["failed"]["error"].as_str().unwrap().contains("disabled"), "{r}");
+    // A set the user loads is theirs.
+    s.execute("actions.load", json!({"data": data, "set": "Loaded by the user"})).unwrap();
+    assert!(!s.actions.list.last().unwrap().automation);
+}
+
 #[test]
 fn append_delete_and_from_step() {
     let mut s = Session::new();
diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs
--- a/crates/engine/src/cmd/actions_edit_cmds.rs
+++ b/crates/engine/src/cmd/actions_edit_cmds.rs
@@ -8,7 +8,7 @@
 
 use serde_json::{Value, json};
 
-use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve};
+use crate::actions_cmds::{flush_recording, not_playing, note_automation_edit, pending_steps, resolve};
 use crate::commands::CommandSpec;
 use crate::{EngineError, Result, Session};
 
@@ -69,6 +69,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
     action.steps.insert(at, (id.clone(), params));
     action.remap_steps(|i| Some(if i >= at { i + 1 } else { i }));
     let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()});
+    note_automation_edit(s, idx);
     s.actions.touch();
     Ok(result)
 }
diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs
--- a/crates/engine/src/cmd/atn_cmds/files.rs
+++ b/crates/engine/src/cmd/atn_cmds/files.rs
@@ -58,6 +58,11 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
             }
         }
     }
+    // Actions an automation client loads keep its gate when the user plays them.
+    let automation = s.authorize.is_some();
+    for a in &mut added {
+        a.automation = automation;
+    }
     s.actions.list.extend(added);
     let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect();
     if !keys.is_empty() {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -238,6 +238,12 @@ pub struct Session {
     /// the same check a top-level request sees. `None` runs everything, which is what a local
     /// UI and `photocraft-cli run` do.
     pub authorize: Option<fn(&str, &serde_json::Value) -> Result<()>>,
+    /// The gate automation runs under in this process (the desktop's policy). An action
+    /// automation wrote ([`actions_cmds::Action::automation`]) plays under it when no gate is
+    /// installed, as when the user presses its key, so it never gets the user's file access.
+    /// `None` when there is no automation policy to keep (headless sessions gate everything
+    /// with `authorize`).
+    pub automation_gate: Option<fn(&str, &serde_json::Value) -> Result<()>>,
     /// A further route for File › Print when neither `lp` nor a CUPS server over IPP is
     /// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`.
     pub print_spooler: Option<print_cmds::Spooler>,
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
@@ -26,6 +26,8 @@ pub use thumbs::surface_fingerprint;
 impl PhotocraftApp {
     pub fn new(mut session: Session, mut services: Services) -> Self {
         session.updates.check = services.check_updates.take();
+        // Actions automation wrote keep this gate when the user plays them (Batch included).
+        session.automation_gate = session.automation_gate.or(services.automation_authorize);
         let mut app = Self {
             session,
             ui: UiState::default(),
diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs
--- a/crates/ui-egui/src/panels/actions.rs
+++ b/crates/ui-egui/src/panels/actions.rs
@@ -101,6 +101,21 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result<Value, Str
 /// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes
 /// back, with that named call journaled, only when the whole call ends.
 fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>) -> Result<Value, String> {
+    // An action automation wrote plays under automation's gate even when the user starts it (its
+    // key, the panel, Button Mode); nested calls, a paused step's dialog and the steps' own
+    // commands see that gate too.
+    let kept = actions_cmds::playback_plan(&app.session, params).ok().and_then(|(action, _)| actions_cmds::kept_gate(&app.session, &action));
+    if kept.is_some() {
+        app.session.authorize = kept;
+    }
+    let played = play_steps(app, params, suspended);
+    if kept.is_some() {
+        app.session.authorize = None;
+    }
+    played
+}
+
+fn play_steps(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>) -> Result<Value, String> {
     let (action, from) = match actions_cmds::playback_plan(&app.session, params) {
         Ok(plan) => plan,
         Err(e) => {
diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs
--- a/crates/ui-egui/src/panels/actions/tests.rs
+++ b/crates/ui-egui/src/panels/actions/tests.rs
@@ -1067,6 +1067,25 @@ fn a_paused_playback_keeps_its_automation_gate_when_the_dialog_is_confirmed() {
     assert!(app.ui.status.contains("select.all denied"), "{}", app.ui.status);
 }
 
+/// An action automation wrote, bound to a key, plays under the desktop's automation policy
+/// when the user presses the key: its file step is refused, not run with the user's access.
+#[test]
+fn a_users_key_plays_an_action_automation_wrote_under_its_gate() {
+    fn deny_file(id: &str, _: &Value) -> photocraft_engine::Result<()> {
+        if id.starts_with("file.") { Err(photocraft_engine::EngineError::Other(format!("automation command `{id}` is disabled"))) } else { Ok(()) }
+    }
+    let services = crate::Services { automation_authorize: Some(deny_file), ..Default::default() };
+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
+    let mut planted = Action::new("Backup", vec![("file.openAs".into(), json!({"path": "/outside/secret.png"}))]);
+    planted.automation = true;
+    app.session.actions.list.push(planted);
+    assign_shortcut(&mut app, "Backup", "F9").unwrap();
+    crate::shortcut_dispatch::dispatch(&mut app, &egui::Context::default(), &shortcut_id("Backup"));
+    assert!(app.session.documents().is_empty());
+    assert!(app.ui.status.contains("disabled"), "the refusal is reported: {}", app.ui.status);
+    assert!(app.session.authorize.is_none(), "the kept gate is removed after playback");
+}
+
 /// The dialog names only its pause: changing its fields can't resume another action.
 #[test]
 fn a_step_dialog_resumes_only_the_playback_that_paused_it() {
