Skip to main content

openmoat_core/
action.rs

1use serde::{Deserialize, Serialize};
2
3use crate::kind::Kind;
4
5/// A tool call as seen from a host, before classification.
6///
7/// Host adapters (`openmoat-hosts`) translate each payload into exactly one of
8/// these. Adapters never decide.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Action {
12    /// A shell command string as the host would execute it.
13    /// A shell command line, exactly as the host would run it.
14    Shell {
15        /// The command line.
16        command: String,
17    },
18    /// A command for a shell the classifier cannot read (Claude Code `PowerShell`).
19    /// Never classified, so always `unparseable`: an `ask`, never an `allow`.
20    ForeignShell {
21        /// The shell's name, for reasons and the audit log.
22        shell: String,
23        /// The command line.
24        command: String,
25    },
26    /// A direct file read through a host tool (e.g. Claude Code `Read`).
27    /// A file read by a host tool (`Read`, `Glob`, `Grep`, `LSP`, `beforeReadFile`).
28    FsRead {
29        /// Path as the tool gave it; normalised during classification.
30        path: String,
31    },
32    /// A direct file write/edit through a host tool.
33    /// A file write or edit by a host tool (`Edit`, `Write`, `NotebookEdit`).
34    FsWrite {
35        /// Path as the tool gave it; normalised during classification.
36        path: String,
37    },
38    /// A network connection a host tool opens and may send data over
39    /// (Claude Code `Monitor` WebSocket).
40    Net {
41        /// The URL as the tool gave it.
42        url: String,
43    },
44    /// A URL read by a host's own fetch tool (Claude Code `WebFetch`): a
45    /// request the agent cannot attach a body to, judged by `fetch` and `net`
46    /// rules (ADR-017).
47    Fetch {
48        /// The URL as the tool gave it.
49        url: String,
50    },
51    /// A patch that edits several files in one call (Codex `apply_patch`):
52    /// every path it adds, updates, deletes or moves to is written.
53    /// Files changed by one multi-file patch (Codex `apply_patch`).
54    Patch {
55        /// Every file the patch adds, updates, deletes or moves to.
56        writes: Vec<String>,
57    },
58    /// Files one tool call reads together (Claude Code `SendFile`, which sends
59    /// their contents to another session): every path is read.
60    ReadFiles {
61        /// Every file the call reads.
62        paths: Vec<String>,
63    },
64    /// An MCP tool call, `mcp__<server>__<tool>`. Adapters that understand a
65    /// server's arguments add the paths and hosts the call touches so the
66    /// usual `fs.*` and `net` rules apply to it as well.
67    McpTool {
68        /// `mcp__<server>__<tool>`.
69        name: String,
70        /// Paths the call reads, derived from its arguments.
71        #[serde(default, skip_serializing_if = "Vec::is_empty")]
72        reads: Vec<String>,
73        /// Paths the call writes, derived from its arguments.
74        #[serde(default, skip_serializing_if = "Vec::is_empty")]
75        writes: Vec<String>,
76        /// URLs or hosts the call contacts, derived from its arguments.
77        #[serde(default, skip_serializing_if = "Vec::is_empty")]
78        hosts: Vec<String>,
79        /// The limit that stopped the adapter searching the arguments (nesting
80        /// depth, number of paths and URLs). What was not searched may name a
81        /// file or host, so the engine decides the call at least `ask`.
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        unchecked: Option<String>,
84    },
85}
86
87impl Action {
88    /// The kind of action as a user would describe it (`Patch` writes files).
89    #[must_use]
90    pub fn kind(&self) -> Kind {
91        match self {
92            Self::Shell { .. } | Self::ForeignShell { .. } => Kind::Shell,
93            Self::FsRead { .. } | Self::ReadFiles { .. } => Kind::FsRead,
94            Self::FsWrite { .. } | Self::Patch { .. } => Kind::FsWrite,
95            Self::Net { .. } => Kind::Net,
96            Self::Fetch { .. } => Kind::Fetch,
97            Self::McpTool { .. } => Kind::Mcp,
98        }
99    }
100
101    /// An MCP call whose arguments are not interpreted.
102    #[must_use]
103    pub fn mcp(name: impl Into<String>) -> Self {
104        Self::McpTool {
105            name: name.into(),
106            reads: Vec::new(),
107            writes: Vec::new(),
108            hosts: Vec::new(),
109            unchecked: None,
110        }
111    }
112}
113
114/// The primitive operations an [`Action`] decomposes into.
115///
116/// One shell command typically yields several: the command itself, every
117/// path it touches, every host it may contact, every env var it reads or sets.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum AtomicAction {
121    /// Normalised argv of one (sub-)command.
122    Shell {
123        /// Program and arguments after quote removal.
124        argv: Vec<String>,
125    },
126    /// A whole pipeline / list (`a | b && c`) with separators kept as tokens, so
127    /// rules such as `curl * | sh` can match across sub-commands. Never carries
128    /// a default verdict (see `engine/mod.rs`).
129    Pipeline {
130        /// Words and separator tokens of the whole pipeline or list.
131        argv: Vec<String>,
132    },
133    /// Absolute path in slash-separated canonical form (`~`, `${project}` and
134    /// `cwd` already applied).
135    FsRead {
136        /// Absolute path in canonical form.
137        path: String,
138    },
139    /// A file write, create, delete or move.
140    FsWrite {
141        /// Absolute path in canonical form.
142        path: String,
143    },
144    /// Host name only (no scheme, no port).
145    Net {
146        /// Lowercase host name.
147        host: String,
148    },
149    /// The host of a URL a fetch tool reads; matched by `fetch` and `net` rules.
150    Fetch {
151        /// Lowercase host name.
152        host: String,
153    },
154    /// An environment variable read (`$NAME`, `printenv NAME`).
155    EnvRead {
156        /// Variable name.
157        name: String,
158    },
159    /// An environment variable set (`NAME=value`, `export NAME=…`).
160    EnvSet {
161        /// Variable name.
162        name: String,
163    },
164    /// An MCP tool invocation.
165    McpTool {
166        /// `mcp__<server>__<tool>`.
167        name: String,
168    },
169}
170
171impl AtomicAction {
172    /// The rule kind this atom is matched against.
173    #[must_use]
174    pub fn kind(&self) -> Kind {
175        match self {
176            Self::Shell { .. } | Self::Pipeline { .. } => Kind::Shell,
177            Self::FsRead { .. } => Kind::FsRead,
178            Self::FsWrite { .. } => Kind::FsWrite,
179            Self::Net { .. } => Kind::Net,
180            Self::Fetch { .. } => Kind::Fetch,
181            Self::EnvRead { .. } => Kind::EnvRead,
182            Self::EnvSet { .. } => Kind::EnvSet,
183            Self::McpTool { .. } => Kind::Mcp,
184        }
185    }
186
187    /// The same read or write of `path`; `None` for any other atom.
188    pub(crate) fn with_path(&self, path: String) -> Option<Self> {
189        match self {
190            Self::FsRead { .. } => Some(Self::FsRead { path }),
191            Self::FsWrite { .. } => Some(Self::FsWrite { path }),
192            _ => None,
193        }
194    }
195
196    /// The text a glob rule is matched against; `None` for shell atoms, which
197    /// are matched as argv.
198    pub(crate) fn subject(&self) -> Option<&str> {
199        match self {
200            Self::Shell { .. } | Self::Pipeline { .. } => None,
201            Self::FsRead { path } | Self::FsWrite { path } => Some(path),
202            Self::Net { host } | Self::Fetch { host } => Some(host),
203            Self::EnvRead { name } | Self::EnvSet { name } | Self::McpTool { name } => Some(name),
204        }
205    }
206
207    /// Short human description used in reasons and audit lines.
208    #[must_use]
209    pub fn describe(&self) -> String {
210        match self {
211            Self::Shell { argv } => format!("shell \"{}\"", argv.join(" ")),
212            Self::Pipeline { argv } => format!("pipeline \"{}\"", argv.join(" ")),
213            Self::FsRead { path } => format!("read {path}"),
214            Self::FsWrite { path } => format!("write {path}"),
215            Self::Net { host } => format!("net {host}"),
216            Self::Fetch { host } => format!("fetch {host}"),
217            Self::EnvRead { name } => format!("env read {name}"),
218            Self::EnvSet { name } => format!("env set {name}"),
219            Self::McpTool { name } => format!("mcp {name}"),
220        }
221    }
222}