collective

Browse / github.com/storytold/figcraft / patch

engine: refuse unknown command parameter names (#1413)

[Bug Report] MCP: Commands accept misspelled parameter names without an error and return a different result ## Summary When a command gets a parameter name it doesn't know, it ignores it and reports success. The result is then not what was asked for, and nothing says so. Wrong parameter *types* already give an error; unknown *names* don't. ## Steps to reproduce Run this script with `figcraft-cli run repro.json` (headless, empty document): ```json [ {"command":"node.create","params":{"type":"frame","name":"F"}}, {"command":"comment.add","params":{"x":10,"y":10,"text":"a"}}, {"command":"comment.resolve","params":{"id":1}}, {"command":"comment.list","params":{"filterr":"open"}}, {"command":"co

Status
open
Score
0.00
Adopters
0 other operators
Author
jayzed80 (imported from pull request #1436, open)
Base
43af145a12eacd1e87f5f5e6984c49b7da040288
Size
+210 −14 in 8 files
Review
pending
Shared
2026-10-28
Id
f3f262987043edabd1f83935

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/automation/params.rs crates/engine/src/cmd.rs crates/engine/src/cmd/comment.rs crates/engine/src/cmd/edit/props.rs crates/engine/src/cmd/file.rs crates/engine/src/library_variables_tests.rs crates/engine/src/session/exec.rs crates/engine/src/tests.rs

diff --git a/crates/engine/src/automation/params.rs b/crates/engine/src/automation/params.rs--- a/crates/engine/src/automation/params.rs+++ b/crates/engine/src/automation/params.rs@@ -1,16 +1,58 @@ //! Parameter parsing helpers shared by commands (lenient: agents send numbers as strings, ids as //! "12" or "0:12", colours as hex or CSS). +use std::cell::RefCell;+use std::collections::HashSet;+ use figcraft_color::{BlendMode, Color, Paint}; use figcraft_doc::NodeId; use serde_json::{Value, json};  use crate::{EngineError, Result, Session, bad}; +thread_local! {+    /// The parameter names each running command asked for, innermost command last.+    static READ: RefCell<Vec<HashSet<String>>> = const { RefCell::new(Vec::new()) };+}+ pub fn get<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {+    note_read(key);     p.get(key).filter(|v| !v.is_null()) } +fn note_read(key: &str) {+    READ.with_borrow_mut(|r| {+        if let Some(top) = r.last_mut()+            && !top.contains(key)+        {+            top.insert(key.to_string());+        }+    });+}++/// Count every key of `p` as read, for a command that takes keys it doesn't name (flat properties).+pub fn read_all(p: &Value) {+    for key in p.as_object().into_iter().flat_map(|m| m.keys()) {+        note_read(key);+    }+}++/// Run `f` and return what it returned with the parameter names it asked for through [`get`].+pub(crate) fn tracking_reads<T>(f: impl FnOnce() -> T) -> (T, HashSet<String>) {+    struct Frame;+    impl Drop for Frame {+        fn drop(&mut self) {+            READ.with_borrow_mut(|r| r.pop());+        }+    }+    READ.with_borrow_mut(|r| r.push(HashSet::new()));+    let frame = Frame;+    let out = f();+    let read = READ.with_borrow_mut(|r| r.last_mut().map(std::mem::take).unwrap_or_default());+    drop(frame);+    (out, read)+}+ pub fn f64_of(v: &Value) -> Option<f64> {     match v {         Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs--- a/crates/engine/src/cmd.rs+++ b/crates/engine/src/cmd.rs@@ -50,6 +50,40 @@ impl CommandSpec {     pub fn shortcut(&self) -> Option<&'static str> {         crate::catalog::shortcut(self.id)     }+    /// The parameter names `params` documents: the first word of each entry at the top level of+    /// its `{..}` groups (`{ids?, index | name, effect {..}}` → ids, index, name, effect).+    pub fn param_names(&self) -> Vec<&'static str> {+        let doc = self.params;+        let mut names = Vec::new();+        let mut depth = 0usize;+        let mut entry_start = None;+        for (i, c) in doc.char_indices() {+            match c {+                '{' | '(' | '[' => {+                    if depth == 0 && c == '{' {+                        entry_start = Some(i + 1);+                    }+                    depth += 1;+                }+                '}' | ')' | ']' => depth = depth.saturating_sub(1),+                ',' | '|' if depth == 1 => entry_start = Some(i + 1),+                _ => {}+            }+            if let Some(start) = entry_start+                && depth == 1+                && !c.is_whitespace()+                && i >= start+            {+                entry_start = None;+                let rest = doc.get(i..).unwrap_or_default();+                let len = rest.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')).unwrap_or(rest.len());+                if let Some(name) = rest.get(..len).filter(|n| !n.is_empty() && !names.contains(n)) {+                    names.push(name);+                }+            }+        }+        names+    } }  /// Define a command spec: `spec!(id, label, params_doc, history, run)`.diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs--- a/crates/engine/src/cmd/comment.rs+++ b/crates/engine/src/cmd/comment.rs@@ -463,14 +463,20 @@ pub fn specs() -> Vec<CommandSpec> {                 Ok(json!({"on": on}))             }         ),-        spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {-            let id = req_comment("comment.resolve", s, p)?;-            let v = boolean(p, "resolved").unwrap_or(true);-            if let Some(c) = s.doc_mut()?.comment_mut(id) {-                c.resolved = v;+        spec!(+            "comment.resolve",+            "Resolve comment",+            "{id, resolved? (default true), author? (accepted, ignored: anyone can resolve)}",+            true,+            |s, p| {+                let id = req_comment("comment.resolve", s, p)?;+                let v = boolean(p, "resolved").unwrap_or(true);+                if let Some(c) = s.doc_mut()?.comment_mut(id) {+                    c.resolved = v;+                }+                ok()             }-            ok()-        }),+        ),         spec!(             "comment.move",             "Move comment",@@ -659,4 +665,23 @@ mod tests {         assert!(s.undo().unwrap());         assert_eq!(s.doc().unwrap().comments.len(), 1);     }++    /// A parameter name the command doesn't know is refused, not ignored: a misspelt `filterr` made+    /// `comment.list` report every thread as if no filter was given.+    #[test]+    fn misspelt_parameter_names_are_refused() {+        let mut s = Session::new();+        let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "a"})).unwrap();+        let cid = c["id"].as_u64().unwrap();+        s.execute("comment.resolve", &json!({"id": cid})).unwrap();+        let err = s.execute("comment.list", &json!({"filterr": "open"})).unwrap_err().to_string();+        assert!(err.contains("filterr") && err.contains("filter"), "{err}");+        // The correct name still works, and finds nothing open.+        let open = s.execute("comment.list", &json!({"filter": "open"})).unwrap();+        assert!(open["comments"].as_array().unwrap().is_empty());+        // A refused edit changes nothing.+        let before = s.doc().unwrap().comments.len();+        assert!(s.execute("comment.add", &json!({"x": 1, "y": 1, "text": "b", "textt": "c"})).is_err());+        assert_eq!(s.doc().unwrap().comments.len(), before, "nothing stored");+    } }diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs--- a/crates/engine/src/cmd/edit/props.rs+++ b/crates/engine/src/cmd/edit/props.rs@@ -94,7 +94,8 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {     let props = match get(p, "props") {         Some(Value::Object(m)) => m.clone(),         _ => {-            // Allow flat params: {"id": 3, "fill": "#f00"}.+            // Allow flat params: {"id": 3, "fill": "#f00"}. `apply` refuses keys it doesn't know.+            crate::params::read_all(p);             let mut m = p.as_object().cloned().unwrap_or_default();             m.remove("id");             m.remove("ids");diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {         spec!(             "file.open",             "Open",-            "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",+            "{path | data (base64), name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",             false,             open         ),@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {         spec!(             "file.export",             "Export",-            "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",+            "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgSimplifyStroke? (true or false, as in the layer's export settings), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",             false,             export         ),@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {         spec!(             "file.import",             "Import",-            "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)}  SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",+            "{path | data (base64), name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)}  SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",             true,             import         ),diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs--- a/crates/engine/src/library_variables_tests.rs+++ b/crates/engine/src/library_variables_tests.rs@@ -332,7 +332,7 @@ fn library_style_bindings_ignore_unrelated_local_variables() {     assert_eq!(local, var, "the ids must collide for this test");     let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));     ex(&mut s, "library.add", json!({"path": path}));-    ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));+    ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "ids": [r]}));     assert_eq!(fill(&s, r), "0000FF");     healthy(&s); diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs--- a/crates/engine/src/session/exec.rs+++ b/crates/engine/src/session/exec.rs@@ -1,4 +1,7 @@+use std::collections::HashSet;+ use super::*;+use crate::cmd::CommandSpec;  /// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. fn command_carries_import_bytes(id: &str, params: &Value) -> bool {@@ -13,12 +16,42 @@ fn command_carries_import_bytes(id: &str, params: &Value) -> bool {     } } +/// A parameter the command neither asked for nor documents changed nothing: refuse it (the edit is+/// rolled back) rather than report success for something that wasn't done. A misspelt `filterr`+/// made `comment.list` list every thread.+fn check_params_read(spec: &CommandSpec, params: &Value, read: &HashSet<String>) -> Result<()> {+    refuse_unknown_params(spec, params, |k| read.contains(k))+}++/// Commands whose effects outlive a rollback (tabs, branches, versions, libraries, preferences, the undo history, plugin publishing, written files, the font scan): their+/// unknown parameter names are refused before they run, by their documented names.+fn effects_outlive_rollback(id: &str) -> bool {+    const FAMILIES: [&str; 11] =+        ["file.", "doc.", "version.", "branch.", "library.", "libraries.", "pref.", "window.", "app.", "board.", "plugin.directory."];+    FAMILIES.iter().any(|f| id.starts_with(f)) || matches!(id, "command.batch" | "edit.undo" | "edit.redo" | "image.place" | "text.rescanFonts")+}++fn refuse_unknown_params(spec: &CommandSpec, params: &Value, read: impl Fn(&str) -> bool) -> Result<()> {+    let Some(given) = params.as_object() else { return Ok(()) };+    let documented = spec.param_names();+    let unknown: Vec<&str> =+        given.iter().filter(|(k, v)| !v.is_null() && !read(k.as_str()) && !documented.contains(&k.as_str())).map(|(k, _)| k.as_str()).collect();+    if unknown.is_empty() {+        return Ok(());+    }+    let takes = if documented.is_empty() { "takes no parameters".to_string() } else { format!("takes {}", documented.join(", ")) };+    Err(bad(spec.id, format!("unknown parameters: {} (`{}` {takes})", unknown.join(", "), spec.id)))+}+ impl Session {     /// Run a command by id. Mutations become one undo step; a failure or panic leaves the document     /// as it was.     pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {         let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;         board::refuse_on_board(self, id)?;+        if effects_outlive_rollback(id) {+            refuse_unknown_params(spec, params, |_| false)?;+        }         // Revert replaces the document's uid: refuse before ending previews or syncing branches.         if id == "file.revert" && self.live().doc_uid.is_some() {             return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));@@ -27,7 +60,8 @@ impl Session {         self.apply_view_prefs();         if textpreview::COMMANDS.contains(&id) {             // A live preview is no edit: no undo step, normalisation or rollback here.-            let r = guard::run(id, || (spec.run)(self, params));+            let (r, read) = params::tracking_reads(|| guard::run(id, || (spec.run)(self, params)));+            let r = r.and_then(|v| check_params_read(spec, params, &read).map(|()| v));             if r.is_err() {                 textpreview::end(self);             }@@ -44,7 +78,9 @@ impl Session {         let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));         // Cut and copy write the clipboard before a later check can refuse the command.         let clipboard_before = self.clipboard.clone();-        let result = guard::run(id, || (spec.run)(self, params))+        let (result, read) = params::tracking_reads(|| guard::run(id, || (spec.run)(self, params)));+        let result = result+            .and_then(|v| check_params_read(spec, params, &read).map(|()| v))             .and_then(|v| self.check_instance_structure(id, &before).map(|()| v))             .and_then(|v| self.check_preview(id, &before).map(|()| v))             .and_then(|v| self.check_live_access(id, &before).map(|()| v))diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -488,6 +488,64 @@ fn every_command_survives_junk_params() {     } } +/// No command reports success for a parameter name it doesn't know: it used to ignore it, so a+/// misspelt option gave a different result with nothing saying so.+#[test]+fn every_command_refuses_an_unknown_parameter_name() {+    let mut accepted = Vec::new();+    for spec in all_commands() {+        // These write or read files, or walk the font folders; the check is the same for them.+        if spec.id.starts_with("file.") || spec.id == "image.place" || spec.id == "text.rescanFonts" {+            continue;+        }+        let mut s = sess();+        let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));+        if s.execute(spec.id, &json!({"zzNotAParameter": 1})).is_ok() {+            accepted.push(spec.id);+        }+    }+    assert!(accepted.is_empty(), "these commands accepted an unknown parameter name: {accepted:?}");+}++#[test]+fn an_unknown_parameter_name_stops_a_command_before_its_effects() {+    let mut s = sess();+    let tabs = s.execute("doc.list", &json!({})).unwrap().to_string();+    assert!(s.execute("file.new", &json!({"nmae": "x"})).is_err());+    assert_eq!(s.execute("doc.list", &json!({})).unwrap().to_string(), tabs, "no tab was opened");+    assert!(s.execute("file.new", &json!({"name": "x"})).is_ok());+    // Tab switches and copies change the session before a post-run check could refuse them.+    s.execute("file.new", &json!({"name": "y"})).unwrap();+    let state = |s: &mut Session| s.execute("doc.list", &json!({})).unwrap().to_string();+    s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();+    s.execute("version.autosave", &json!({})).unwrap();+    let versions = s.execute("version.list", &json!({})).unwrap().to_string();+    assert!(versions.len() > 2, "the test needs a saved version: {versions}");+    assert!(s.execute("version.delete", &json!({"index": 0, "zzNotAParameter": 1})).is_err());+    assert_eq!(s.execute("version.list", &json!({})).unwrap().to_string(), versions, "version.delete removed a version");+    let before = state(&mut s);+    // A batch's nested steps (here a new tab) run before its own unknown name could be refused.+    let nested = json!({"commands": [{"command": "file.new", "params": {"name": "z"}}], "zzNotAParameter": 1});+    assert!(s.execute("command.batch", &nested).is_err());+    assert_eq!(state(&mut s), before, "command.batch opened a tab");+    assert!(s.execute("branch.create", &json!({"name": "b", "zzNotAParameter": 1})).is_err());+    assert_eq!(state(&mut s), before, "branch.create opened a tab");+    assert!(s.execute("branch.list", &json!({})).unwrap().as_array().is_some_and(|b| b.is_empty()), "a branch was recorded");+    // A refused undo must not consume the undo step.+    s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();+    let layers = |s: &mut Session| s.ds().map(|d| d.doc.nodes().count()).unwrap_or(0);+    let with_rect = layers(&mut s);+    assert!(s.execute("edit.undo", &json!({"zzNotAParameter": 1})).is_err());+    assert_eq!(layers(&mut s), with_rect);+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(layers(&mut s), with_rect - 1, "the refused undo used up the step");+    let before = state(&mut s);+    for id in ["doc.switch", "doc.nextTab", "doc.previousTab", "version.duplicate"] {+        assert!(s.execute(id, &json!({"index": 0, "zzNotAParameter": 1})).is_err(), "{id}");+        assert_eq!(state(&mut s), before, "{id} changed the tabs");+    }+}+ #[test] fn parity_catalog_mostly_covered() {     let (done, total, missing) = catalog::parity(&[]);