Skip to main content

perspt_sdk/
command.rs

1//! Typed command IR and governance tiers (PSP-8 System 8).
2//!
3//! `sh -c` is not an implicit compatibility path. A command proposal is parsed
4//! into a typed [`CommandInvocation`]; verifier commands prefer the `Program`
5//! form, and the `Shell` form requires a capability that explicitly names shell
6//! execution. Coreutils, `awk`, and `sed` are modeled in three tiers so that a
7//! read-only inspection cannot silently become a workspace mutation.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13/// A canonicalized command invocation.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(tag = "form", rename_all = "snake_case")]
16pub enum CommandInvocation {
17    /// A direct program execution with explicit args — the preferred form.
18    Program {
19        program: String,
20        args: Vec<String>,
21        cwd: String,
22        env: BTreeMap<String, String>,
23    },
24    /// A shell script — requires an explicit `RunShell` capability.
25    Shell {
26        script: String,
27        cwd: String,
28        declared_reads: Vec<String>,
29        declared_writes: Vec<String>,
30    },
31}
32
33impl CommandInvocation {
34    /// Whether this invocation requires a shell capability.
35    pub fn requires_shell(&self) -> bool {
36        matches!(self, CommandInvocation::Shell { .. })
37    }
38
39    /// The program name being invoked (for `Program`) or `"sh"` for `Shell`.
40    pub fn program_name(&self) -> &str {
41        match self {
42            CommandInvocation::Program { program, .. } => program,
43            CommandInvocation::Shell { .. } => "sh",
44        }
45    }
46
47    /// The canonical single-line rendering of this invocation — the stable
48    /// input contract for text-matching policy layers. `Program` renders as
49    /// the shell-quoted argv; `Shell` renders its script verbatim. Never feed
50    /// a policy the Rust `Debug` form: its field syntax defeats every
51    /// substring pattern written against real command lines.
52    pub fn command_line(&self) -> String {
53        match self {
54            CommandInvocation::Program { program, args, .. } => {
55                let mut parts = Vec::with_capacity(args.len() + 1);
56                parts.push(program.clone());
57                parts.extend(args.iter().cloned());
58                shell_words::join(parts.iter().map(String::as_str))
59            }
60            CommandInvocation::Shell { script, .. } => script.clone(),
61        }
62    }
63}
64
65/// Shell metacharacters that force the `Shell` form. Their presence means the
66/// command cannot be canonicalized to a single program execution.
67const SHELL_METACHARS: &[char] = &[
68    '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '~', '!', '\n',
69];
70
71/// Whether a raw command string contains shell composition.
72pub fn has_shell_composition(raw: &str) -> bool {
73    raw.chars().any(|c| SHELL_METACHARS.contains(&c))
74}
75
76/// Canonicalize a raw command string. A command free of shell composition is
77/// parsed into the `Program` form; otherwise it is a `Shell` invocation that
78/// requires a shell capability. Parsing is intentionally simple and
79/// shell-free: quoting is parsed as argv syntax, while composition operators
80/// still force the `Shell` form. Invalid argv syntax also fails closed to the
81/// shell form and therefore requires explicit shell authority.
82pub fn canonicalize(raw: &str, cwd: &str) -> CommandInvocation {
83    if has_shell_composition(raw) {
84        return CommandInvocation::Shell {
85            script: raw.to_string(),
86            cwd: cwd.to_string(),
87            declared_reads: Vec::new(),
88            declared_writes: Vec::new(),
89        };
90    }
91    let mut tokens = match shell_words::split(raw) {
92        Ok(tokens) => tokens.into_iter(),
93        Err(_) => {
94            return CommandInvocation::Shell {
95                script: raw.to_string(),
96                cwd: cwd.to_string(),
97                declared_reads: Vec::new(),
98                declared_writes: Vec::new(),
99            };
100        }
101    };
102    match tokens.next() {
103        Some(program) => CommandInvocation::Program {
104            program,
105            args: tokens.collect(),
106            cwd: cwd.to_string(),
107            env: BTreeMap::new(),
108        },
109        None => CommandInvocation::Shell {
110            script: String::new(),
111            cwd: cwd.to_string(),
112            declared_reads: Vec::new(),
113            declared_writes: Vec::new(),
114        },
115    }
116}
117
118/// Governance tier for coreutils / `awk` / `sed` style commands (PSP-8 System 8).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum CommandTier {
122    /// Read-only commands that can produce residual evidence.
123    Inspection,
124    /// Transformations that produce a proposed diff but do not mutate files.
125    PatchPreview,
126    /// Commands that directly modify the workspace — require mutation capability.
127    Mutation,
128}
129
130/// Classify a program invocation into a governance tier. This is a conservative
131/// default: anything not recognized as read-only is treated as a mutation so a
132/// novel tool cannot slip through as inspection.
133pub fn classify_tier(invocation: &CommandInvocation) -> CommandTier {
134    let (program, args) = match invocation {
135        CommandInvocation::Program { program, args, .. } => (program.as_str(), args.as_slice()),
136        // A declaration is an assertion, not proof. Shell composition is
137        // always mutation-tier; a process sandbox may still enforce a
138        // read-only filesystem for explicitly approved inspection scripts.
139        CommandInvocation::Shell { .. } => return CommandTier::Mutation,
140    };
141
142    let base = program.rsplit('/').next().unwrap_or(program);
143    match base {
144        // Always read-only.
145        "rg" | "grep" | "find" | "sort" | "uniq" | "wc" | "comm" | "cat" | "head" | "tail"
146        | "ls" | "git-grep" => CommandTier::Inspection,
147        // `git grep`, `git diff`, `git status` are read-only; `git` others vary.
148        "git" => match args.first().map(String::as_str) {
149            Some("grep") | Some("diff") | Some("status") | Some("log") | Some("show") => {
150                CommandTier::Inspection
151            }
152            _ => CommandTier::Mutation,
153        },
154        // `sed -n` and plain `awk` filters are read-only; `sed -i` mutates.
155        "sed" => {
156            if args.iter().any(|a| a == "-i" || a.starts_with("-i")) {
157                CommandTier::Mutation
158            } else if args.iter().any(|a| a == "-n") {
159                CommandTier::Inspection
160            } else {
161                CommandTier::PatchPreview
162            }
163        }
164        "awk" => CommandTier::Inspection,
165        // Package managers and removers mutate.
166        "rm" | "mv" | "cp" | "cargo" | "npm" | "pnpm" | "yarn" | "pip" | "uv" | "go" => {
167            CommandTier::Mutation
168        }
169        _ => CommandTier::Mutation,
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn simple_command_is_program_form() {
179        let cmd = canonicalize("cargo check --workspace", "/repo");
180        assert!(matches!(cmd, CommandInvocation::Program { .. }));
181        assert!(!cmd.requires_shell());
182        assert_eq!(cmd.program_name(), "cargo");
183    }
184
185    #[test]
186    fn piped_command_is_shell_form() {
187        let cmd = canonicalize("cat x | grep y", "/repo");
188        assert!(cmd.requires_shell());
189    }
190
191    #[test]
192    fn quotes_preserve_a_direct_program_invocation() {
193        let cmd = canonicalize(r#"rg "hello world" src"#, "/repo");
194        assert_eq!(
195            cmd,
196            CommandInvocation::Program {
197                program: "rg".into(),
198                args: vec!["hello world".into(), "src".into()],
199                cwd: "/repo".into(),
200                env: BTreeMap::new(),
201            }
202        );
203    }
204
205    #[test]
206    fn an_empty_shell_write_declaration_is_not_read_only_proof() {
207        let command = CommandInvocation::Shell {
208            script: "cat x | rg y".into(),
209            cwd: "/repo".into(),
210            declared_reads: vec!["x".into()],
211            declared_writes: Vec::new(),
212        };
213        assert_eq!(classify_tier(&command), CommandTier::Mutation);
214    }
215
216    #[test]
217    fn redirect_forces_shell_form() {
218        assert!(canonicalize("echo hi > f", "/repo").requires_shell());
219        assert!(canonicalize("rm -rf $HOME", "/repo").requires_shell());
220    }
221
222    #[test]
223    fn read_only_tools_are_inspection() {
224        assert_eq!(
225            classify_tier(&canonicalize("rg pattern", "/r")),
226            CommandTier::Inspection
227        );
228        assert_eq!(
229            classify_tier(&canonicalize("git grep foo", "/r")),
230            CommandTier::Inspection
231        );
232        assert_eq!(
233            classify_tier(&canonicalize("sed -n 1p file", "/r")),
234            CommandTier::Inspection
235        );
236    }
237
238    #[test]
239    fn sed_in_place_is_mutation() {
240        assert_eq!(
241            classify_tier(&canonicalize("sed -i s/a/b/ file", "/r")),
242            CommandTier::Mutation
243        );
244    }
245
246    #[test]
247    fn package_managers_are_mutation() {
248        assert_eq!(
249            classify_tier(&canonicalize("cargo add serde", "/r")),
250            CommandTier::Mutation
251        );
252        assert_eq!(
253            classify_tier(&canonicalize("rm file", "/r")),
254            CommandTier::Mutation
255        );
256    }
257
258    #[test]
259    fn unknown_tool_defaults_to_mutation() {
260        assert_eq!(
261            classify_tier(&canonicalize("frobnicate x", "/r")),
262            CommandTier::Mutation
263        );
264    }
265}