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
48/// Shell metacharacters that force the `Shell` form. Their presence means the
49/// command cannot be canonicalized to a single program execution.
50const SHELL_METACHARS: &[char] = &[
51    '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '~', '!', '\n',
52];
53
54/// Whether a raw command string contains shell composition.
55pub fn has_shell_composition(raw: &str) -> bool {
56    raw.chars().any(|c| SHELL_METACHARS.contains(&c))
57}
58
59/// Canonicalize a raw command string. A command free of shell composition is
60/// parsed into the `Program` form; otherwise it is a `Shell` invocation that
61/// requires a shell capability. Parsing is intentionally simple and
62/// whitespace-based; quoting beyond simple tokens forces the `Shell` form.
63pub fn canonicalize(raw: &str, cwd: &str) -> CommandInvocation {
64    if has_shell_composition(raw) || raw.contains('\'') || raw.contains('"') {
65        return CommandInvocation::Shell {
66            script: raw.to_string(),
67            cwd: cwd.to_string(),
68            declared_reads: Vec::new(),
69            declared_writes: Vec::new(),
70        };
71    }
72    let mut tokens = raw.split_whitespace();
73    match tokens.next() {
74        Some(program) => CommandInvocation::Program {
75            program: program.to_string(),
76            args: tokens.map(|s| s.to_string()).collect(),
77            cwd: cwd.to_string(),
78            env: BTreeMap::new(),
79        },
80        None => CommandInvocation::Shell {
81            script: String::new(),
82            cwd: cwd.to_string(),
83            declared_reads: Vec::new(),
84            declared_writes: Vec::new(),
85        },
86    }
87}
88
89/// Governance tier for coreutils / `awk` / `sed` style commands (PSP-8 System 8).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum CommandTier {
93    /// Read-only commands that can produce residual evidence.
94    Inspection,
95    /// Transformations that produce a proposed diff but do not mutate files.
96    PatchPreview,
97    /// Commands that directly modify the workspace — require mutation capability.
98    Mutation,
99}
100
101/// Classify a program invocation into a governance tier. This is a conservative
102/// default: anything not recognized as read-only is treated as a mutation so a
103/// novel tool cannot slip through as inspection.
104pub fn classify_tier(invocation: &CommandInvocation) -> CommandTier {
105    let (program, args) = match invocation {
106        CommandInvocation::Program { program, args, .. } => (program.as_str(), args.as_slice()),
107        // A shell script is treated as mutation unless an explicit declaration
108        // proves otherwise; the kernel still requires a shell capability.
109        CommandInvocation::Shell {
110            declared_writes, ..
111        } => {
112            return if declared_writes.is_empty() {
113                CommandTier::Inspection
114            } else {
115                CommandTier::Mutation
116            };
117        }
118    };
119
120    let base = program.rsplit('/').next().unwrap_or(program);
121    match base {
122        // Always read-only.
123        "rg" | "grep" | "find" | "sort" | "uniq" | "wc" | "comm" | "cat" | "head" | "tail"
124        | "ls" | "git-grep" => CommandTier::Inspection,
125        // `git grep`, `git diff`, `git status` are read-only; `git` others vary.
126        "git" => match args.first().map(String::as_str) {
127            Some("grep") | Some("diff") | Some("status") | Some("log") | Some("show") => {
128                CommandTier::Inspection
129            }
130            _ => CommandTier::Mutation,
131        },
132        // `sed -n` and plain `awk` filters are read-only; `sed -i` mutates.
133        "sed" => {
134            if args.iter().any(|a| a == "-i" || a.starts_with("-i")) {
135                CommandTier::Mutation
136            } else if args.iter().any(|a| a == "-n") {
137                CommandTier::Inspection
138            } else {
139                CommandTier::PatchPreview
140            }
141        }
142        "awk" => CommandTier::Inspection,
143        // Package managers and removers mutate.
144        "rm" | "mv" | "cp" | "cargo" | "npm" | "pnpm" | "yarn" | "pip" | "uv" | "go" => {
145            CommandTier::Mutation
146        }
147        _ => CommandTier::Mutation,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn simple_command_is_program_form() {
157        let cmd = canonicalize("cargo check --workspace", "/repo");
158        assert!(matches!(cmd, CommandInvocation::Program { .. }));
159        assert!(!cmd.requires_shell());
160        assert_eq!(cmd.program_name(), "cargo");
161    }
162
163    #[test]
164    fn piped_command_is_shell_form() {
165        let cmd = canonicalize("cat x | grep y", "/repo");
166        assert!(cmd.requires_shell());
167    }
168
169    #[test]
170    fn redirect_forces_shell_form() {
171        assert!(canonicalize("echo hi > f", "/repo").requires_shell());
172        assert!(canonicalize("rm -rf $HOME", "/repo").requires_shell());
173    }
174
175    #[test]
176    fn read_only_tools_are_inspection() {
177        assert_eq!(
178            classify_tier(&canonicalize("rg pattern", "/r")),
179            CommandTier::Inspection
180        );
181        assert_eq!(
182            classify_tier(&canonicalize("git grep foo", "/r")),
183            CommandTier::Inspection
184        );
185        assert_eq!(
186            classify_tier(&canonicalize("sed -n 1p file", "/r")),
187            CommandTier::Inspection
188        );
189    }
190
191    #[test]
192    fn sed_in_place_is_mutation() {
193        assert_eq!(
194            classify_tier(&canonicalize("sed -i s/a/b/ file", "/r")),
195            CommandTier::Mutation
196        );
197    }
198
199    #[test]
200    fn package_managers_are_mutation() {
201        assert_eq!(
202            classify_tier(&canonicalize("cargo add serde", "/r")),
203            CommandTier::Mutation
204        );
205        assert_eq!(
206            classify_tier(&canonicalize("rm file", "/r")),
207            CommandTier::Mutation
208        );
209    }
210
211    #[test]
212    fn unknown_tool_defaults_to_mutation() {
213        assert_eq!(
214            classify_tier(&canonicalize("frobnicate x", "/r")),
215            CommandTier::Mutation
216        );
217    }
218}