Engine: macro recorder applies the macro policy to the steps of a recorded script run

An Automate script run while recording was replaced by all of its steps except automate.* ones,
without the policy check that top-level commands get. For example, a script that runs
window.activate or macro.list produced a recorded macro that the runner refused on playback. The
steps of a recorded script run, and of an inlined macro run, now go through the same policy as
top-level commands; refused ones are left out and listed as skipped.
This commit is contained in:
prxnvt
2026-10-22 02:26:32 -04:00
parent 3f9223c85f
commit efce63f75f
3 changed files with 33 additions and 7 deletions
+12 -4
View File
@@ -6,7 +6,7 @@
//! - commands a macro may not run (saving, opening files…) and other macros' commands are left
//! out; a macro run while recording is replaced by its steps when it has no inputs, no output
//! and only plain commands (otherwise it is left out), and an Automate script run while
//! recording by the script's steps;
//! recording by the script's steps, leaving out the steps a macro may not run;
//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed
//! between commands without one, and runs of `selection.set` collapse to the last;
//! - with relative references (the default), the selection when recording started is the example
@@ -49,6 +49,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
// Commands as they'll be played back: (id, params, sheet before, selection before, whether
// they came from another macro and stay as written).
let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new();
let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_));
for st in steps.iter().filter(|st| st.doc == start.doc) {
let id = st.id.as_str();
if id == "macro.run" {
@@ -58,9 +59,12 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
match inner {
Some(m) if m.inputs.is_empty() && m.output.anchor.is_none() && m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) => {
for x in m.steps.iter().filter(|x| x.enabled) {
if let StepKind::Command { id: cid, params } = &x.kind {
cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
let StepKind::Command { id: cid, params } = &x.kind else { continue };
if refused(cid, params) {
skipped.push(cid.clone());
continue;
}
cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
}
}
_ => skipped.push(id.to_string()),
@@ -72,6 +76,10 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) {
Some(steps) => {
for (cid, params) in steps {
if refused(&cid, &params) {
skipped.push(cid);
continue;
}
cmds.push((cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
}
}
@@ -79,7 +87,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
}
continue;
}
if matches!(macro_policy(id, &st.params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)) {
if refused(id, &st.params) {
skipped.push(id.to_string());
continue;
}
+17
View File
@@ -1078,3 +1078,20 @@ fn a_recorded_macro_run_is_inlined_only_when_its_steps_stand_alone() {
assert_eq!(r["skipped"], json!(["macro.run", "macro.run"]));
assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"plain\"}"]);
}
#[test]
fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() {
let mut s = s();
let steps = json!([
{"command": "cell.set", "params": {"cell": "B1", "input": "kept"}},
{"command": "window.activate", "params": {"index": 0}},
{"command": "macro.list", "params": {}}
]);
s.execute("automate.newScript", json!({"name": "Mixed", "commands": steps})).unwrap();
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("automate.runScript", json!({"name": "Mixed"})).unwrap();
let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
assert_eq!(r["skipped"], json!(["window.activate", "macro.list"]));
assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"kept\"}"]);
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
}
+4 -3
View File
@@ -125,9 +125,10 @@ references the selection when recording starts is the example input: references
on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape,
header and column types are inferred. Toggles such as Bold are recorded with the state they set
(`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not
run are left out and listed in the result; a macro run while recording is replaced by its steps
when it has no inputs, no output and only plain commands (otherwise it is left out and listed), and
an Automate script run by the script's steps.
run are left out and listed in the result, also from the steps of a macro or script run while
recording; a macro run while recording is replaced by its steps when it has no inputs, no output
and only plain commands (otherwise it is left out and listed), and an Automate script run by the
script's steps.
## Other commands and storage