Skip to main content

pinto/
automation.rs

1//! A format for safely receiving operation plans generated by AI agents.
2//!
3//! pinto does not configure or store AI providers or API keys. An external
4//! agent or API creates this JSON; the CLI only executes the existing commands.
5
6use serde::Deserialize;
7use serde::Serialize;
8use thiserror::Error;
9
10const SAFE_COMMAND_NAMES: &[&str] = &[
11    "init",
12    "add",
13    "a",
14    "list",
15    "ls",
16    "next",
17    "n",
18    "show",
19    "s",
20    "move",
21    "mv",
22    "reorder",
23    "ro",
24    "edit",
25    "e",
26    "remove",
27    "rm",
28    "restore",
29    "rs",
30    "dep",
31    "d",
32    "link",
33    "ln",
34    "dod",
35    "dd",
36    "export",
37    "sprint",
38    "sp",
39    "board",
40    "b",
41    "cycletime",
42    "ct",
43    "rebalance",
44    "reb",
45    "migrate",
46    "mig",
47    "doctor",
48    "dr",
49];
50
51const UNSAFE_COMMAND_NAMES: &[&str] = &[
52    "automate",
53    "auto",
54    "shell",
55    "kanban",
56    "k",
57    "completion",
58    // `import` replaces the entire board from a snapshot; it is a manual restore, not an
59    // agent-plan step.
60    "import",
61    // `undo` reverts the most recent mutation; it is a human corrective action, not an agent-plan
62    // step (a plan should not reverse its own earlier commands).
63    "undo",
64];
65
66/// Why an automation plan cannot be run safely.
67///
68/// The detailed input is omitted so callers can log an error without leaking
69/// secret values.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
71#[error("invalid automation plan")]
72pub enum AutomationPlanError {
73    /// The JSON format, schema, or permitted command is invalid.
74    Invalid,
75}
76
77/// A JSON plan of pinto commands to execute sequentially.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AutomationPlan {
80    commands: Vec<Vec<String>>,
81}
82
83/// Per-command result returned by `pinto automate --json`.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct AutomationCommandResult {
86    /// One-based position in the submitted plan.
87    pub index: usize,
88    /// Command name without argument values.
89    pub command: String,
90    /// `valid`, `succeeded`, `failed`, or `skipped`.
91    pub status: String,
92    /// IDs created by this command, when they can be determined.
93    pub created_ids: Vec<String>,
94    /// IDs targeted by an update command, when they can be determined.
95    pub updated_ids: Vec<String>,
96    /// Sanitized error detail for failed or invalid commands.
97    pub error: Option<String>,
98}
99
100/// Structured automation summary returned by `pinto automate --json`.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102pub struct AutomationReport {
103    /// `completed`, `partial_failure`, `invalid`, or `dry_run`.
104    pub status: String,
105    /// Whether the plan was validated without applying changes.
106    pub dry_run: bool,
107    /// Results in the same order as the submitted commands.
108    pub commands: Vec<AutomationCommandResult>,
109}
110
111#[derive(Deserialize)]
112#[serde(deny_unknown_fields)]
113struct RawPlan {
114    commands: Vec<Vec<String>>,
115}
116
117impl AutomationPlan {
118    /// Validate and read the JSON object `{\"commands\":[[\"add\", \"...\"], ...]}`.
119    ///
120    /// Reject unknown fields, so don't accept or store connection information like API keys.
121    /// Detailed JSON parsing errors may include input content and are not returned to the caller.
122    pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
123        let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
124        if raw.commands.is_empty()
125            || raw.commands.iter().any(std::vec::Vec::is_empty)
126            || raw.commands.iter().any(|command| {
127                command
128                    .first()
129                    .is_some_and(|name| UNSAFE_COMMAND_NAMES.contains(&name.as_str()))
130            })
131        {
132            return Err(AutomationPlanError::Invalid);
133        }
134        Ok(Self {
135            commands: raw.commands,
136        })
137    }
138
139    /// Return argv lists in execution order.
140    #[must_use]
141    pub fn commands(&self) -> &[Vec<String>] {
142        &self.commands
143    }
144
145    /// Return the JSON Schema for the validated automation-plan envelope.
146    ///
147    /// The schema covers the JSON structure and excludes commands that would recurse into
148    /// automation or start an interactive session. Arguments after the command name remain
149    /// ordinary strings because the CLI's existing `clap` parser is the source of truth for each
150    /// command's complete argument grammar.
151    #[must_use]
152    pub fn json_schema() -> serde_json::Value {
153        serde_json::json!({
154            "$schema": "https://json-schema.org/draft/2020-12/schema",
155            "title": "pinto automation plan",
156            "description": "A non-empty sequence of safe pinto command argv arrays.",
157            "type": "object",
158            "additionalProperties": false,
159            "required": ["commands"],
160            "properties": {
161                "commands": {
162                    "type": "array",
163                    "description": "Commands are executed in order after clap validation.",
164                    "minItems": 1,
165                    "items": {"$ref": "#/$defs/command"}
166                }
167            },
168            "$defs": {
169                "command": {
170                    "type": "array",
171                    "description": "An argv-style pinto command; arguments are strings.",
172                    "minItems": 1,
173                    "prefixItems": [{
174                        "type": "string",
175                        "enum": SAFE_COMMAND_NAMES
176                    }],
177                    "items": {"type": "string"}
178                }
179            }
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::AutomationPlan;
187
188    #[test]
189    fn accepts_a_sequence_of_commands() {
190        let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
191            .expect("valid plan");
192        assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
193    }
194
195    #[test]
196    fn rejects_unknown_fields_including_api_keys() {
197        assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
198    }
199
200    #[test]
201    fn rejects_recursive_or_interactive_commands() {
202        for command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
203            assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
204        }
205    }
206
207    #[test]
208    fn exposes_a_strict_schema_for_safe_command_plans() {
209        let schema = AutomationPlan::json_schema();
210
211        assert_eq!(
212            schema["$schema"],
213            "https://json-schema.org/draft/2020-12/schema"
214        );
215        assert_eq!(schema["type"], "object");
216        assert_eq!(schema["additionalProperties"], false);
217        assert_eq!(schema["required"], serde_json::json!(["commands"]));
218        assert_eq!(schema["properties"]["commands"]["type"], "array");
219        assert_eq!(schema["properties"]["commands"]["minItems"], 1);
220
221        let command = &schema["$defs"]["command"];
222        assert_eq!(command["type"], "array");
223        assert_eq!(command["minItems"], 1);
224        let command_names = command["prefixItems"][0]["enum"]
225            .as_array()
226            .expect("schema command names are an array");
227        for unsafe_command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
228            assert!(
229                !command_names.iter().any(|name| name == unsafe_command),
230                "unsafe command must not be schema-valid: {unsafe_command}"
231            );
232        }
233        assert!(command_names.iter().any(|name| name == "add"));
234        assert_eq!(command["items"]["type"], "string");
235    }
236}