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