Skip to main content

openmoat_core/
action.rs

1//! What a tool call asks to do, in two forms.
2//!
3//! [`Action`] is the call as a host adapter reports it: one per payload,
4//! before any classification. [`AtomicAction`] is one primitive operation the
5//! classifier derives from it (an argv, a path read or written, a host, an
6//! environment variable, an MCP tool name). Policy rules are matched against
7//! atoms, never against an `Action` directly; [`Action::kind`] and
8//! [`AtomicAction::kind`] map each to the rule [`Kind`] it belongs to.
9
10use serde::{Deserialize, Serialize};
11
12use crate::kind::Kind;
13
14/// A tool call as seen from a host, before classification.
15///
16/// Host adapters (`openmoat-hosts`) translate each payload into exactly one of
17/// these. Adapters never decide.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[non_exhaustive]
21pub enum Action {
22    /// A shell command string as the host would execute it.
23    /// A shell command line, exactly as the host would run it.
24    Shell {
25        /// The command line.
26        command: String,
27    },
28    /// A command for a shell the classifier cannot read (Claude Code `PowerShell`).
29    /// Never classified, so always `unparseable`: a `review`, never an `allow`.
30    ForeignShell {
31        /// The shell's name, for reasons and the audit log.
32        shell: String,
33        /// The command line.
34        command: String,
35    },
36    /// A direct file read through a host tool (e.g. Claude Code `Read`).
37    /// A file read by a host tool (`Read`, `Glob`, `Grep`, `LSP`, `beforeReadFile`).
38    FsRead {
39        /// Path as the tool gave it; normalised during classification.
40        path: String,
41    },
42    /// A direct file write/edit through a host tool.
43    /// A file write or edit by a host tool (`Edit`, `Write`, `NotebookEdit`).
44    FsWrite {
45        /// Path as the tool gave it; normalised during classification.
46        path: String,
47    },
48    /// A network connection a host tool opens and may send data over
49    /// (Claude Code `Monitor` WebSocket).
50    Net {
51        /// The URL as the tool gave it.
52        url: String,
53    },
54    /// A URL read by a host's own fetch tool (Claude Code `WebFetch`): a
55    /// request the agent cannot attach a body to, judged by `fetch` and `net`
56    /// rules (ADR-017).
57    Fetch {
58        /// The URL as the tool gave it.
59        url: String,
60    },
61    /// A query a host's own web search tool (Claude Code and Cursor `WebSearch`)
62    /// sends to the host's search provider: judged by `defaults.web.search`.
63    WebSearch {
64        /// The query as the tool gave it.
65        query: String,
66    },
67    /// A host tool the adapter cannot classify, judged by `defaults.tool.unknown`.
68    /// What its arguments name is checked as well: every path and URL the
69    /// adapter finds in them, and every shell command under a key such as
70    /// `command`, so a tool OpenMoat does not know never runs unchecked.
71    UnknownTool {
72        /// The host's id (`cursor`).
73        host: String,
74        /// The tool name as the host sent it.
75        tool: String,
76        /// Paths the call may read, found in its arguments.
77        #[serde(default, skip_serializing_if = "Vec::is_empty")]
78        reads: Vec<String>,
79        /// Paths the call may write, found in its arguments.
80        #[serde(default, skip_serializing_if = "Vec::is_empty")]
81        writes: Vec<String>,
82        /// URLs or hosts the call may contact, found in its arguments.
83        #[serde(default, skip_serializing_if = "Vec::is_empty")]
84        hosts: Vec<String>,
85        /// Shell commands the call may run, found in its arguments.
86        #[serde(default, skip_serializing_if = "Vec::is_empty")]
87        commands: Vec<String>,
88    },
89    /// A call the hook payload shows only in part: input typed into a shell
90    /// that is already running, or a search over directories the payload does
91    /// not name. `action` is decided as usual and the call is at least an
92    /// `review` (rule [`UNCHECKED_RULE`]), since `reason` names what OpenMoat
93    /// cannot see.
94    Unchecked {
95        /// What the payload shows of the call.
96        action: Box<Action>,
97        /// What it does not show.
98        reason: String,
99    },
100    /// A patch that edits several files in one call (Codex `apply_patch`):
101    /// every path it adds, updates, deletes or moves to is written.
102    /// Files changed by one multi-file patch (Codex `apply_patch`).
103    Patch {
104        /// Every file the patch adds, updates, deletes or moves to.
105        writes: Vec<String>,
106    },
107    /// Files one tool call reads together (Claude Code `SendFile`, which sends
108    /// their contents to another session): every path is read.
109    ReadFiles {
110        /// Every file the call reads.
111        paths: Vec<String>,
112    },
113    /// An MCP tool call, `mcp__<server>__<tool>`. Adapters that understand a
114    /// server's arguments add the paths and hosts the call touches so the
115    /// usual `fs.*` and `net` rules apply to it as well.
116    McpTool {
117        /// `mcp__<server>__<tool>`.
118        name: String,
119        /// Paths the call reads, derived from its arguments.
120        #[serde(default, skip_serializing_if = "Vec::is_empty")]
121        reads: Vec<String>,
122        /// Paths the call writes, derived from its arguments.
123        #[serde(default, skip_serializing_if = "Vec::is_empty")]
124        writes: Vec<String>,
125        /// URLs or hosts the call contacts, derived from its arguments.
126        #[serde(default, skip_serializing_if = "Vec::is_empty")]
127        hosts: Vec<String>,
128        /// The one URL a call that only opens or reads a page names (a
129        /// browser's `navigate`), when its arguments send nothing else
130        /// (ADR-047): decided as a `fetch` of a plain web page, as `hosts`
131        /// otherwise or under `mcp.browser_open: net`.
132        #[serde(default, skip_serializing_if = "Vec::is_empty")]
133        opens: Vec<String>,
134        /// Shell commands the call may run, derived from its arguments where
135        /// the host cannot tell an MCP tool from one of its own.
136        #[serde(default, skip_serializing_if = "Vec::is_empty")]
137        commands: Vec<String>,
138        /// Paths the call may read that only a value's look names: a string
139        /// under an argument key the adapter does not know (`{"input":
140        /// "~/.ssh/id_rsa"}`) that starts at a root, a drive or `~`. Checked
141        /// as reads against the policy's `deny` rules only, so a path no deny
142        /// rule names changes nothing (no new `review` or `defer`), and never
143        /// as writes (a bare `~` or `/` would hit destructive rules).
144        #[serde(default, skip_serializing_if = "Vec::is_empty")]
145        value_reads: Vec<String>,
146        /// The limit that stopped the adapter searching the arguments (nesting
147        /// depth, number of paths and URLs). What was not searched may name a
148        /// file or host, so the engine decides the call at least `review`.
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        unchecked: Option<String>,
151    },
152}
153
154impl Action {
155    /// The kind of action as a user would describe it (`Patch` writes files).
156    #[must_use]
157    pub fn kind(&self) -> Kind {
158        match self {
159            Self::Shell { .. } | Self::ForeignShell { .. } => Kind::Shell,
160            Self::FsRead { .. } | Self::ReadFiles { .. } => Kind::FsRead,
161            Self::FsWrite { .. } | Self::Patch { .. } => Kind::FsWrite,
162            Self::Net { .. } => Kind::Net,
163            Self::Fetch { .. } => Kind::Fetch,
164            Self::WebSearch { .. } => Kind::WebSearch,
165            Self::UnknownTool { .. } => Kind::UnknownTool,
166            Self::McpTool { .. } => Kind::Mcp,
167            Self::Unchecked { action, .. } => action.kind(),
168        }
169    }
170
171    /// An MCP call whose arguments are not interpreted.
172    #[must_use]
173    pub fn mcp(name: impl Into<String>) -> Self {
174        Self::McpTool {
175            name: name.into(),
176            reads: Vec::new(),
177            writes: Vec::new(),
178            hosts: Vec::new(),
179            opens: Vec::new(),
180            commands: Vec::new(),
181            value_reads: Vec::new(),
182            unchecked: None,
183        }
184    }
185
186    /// An unknown tool of `host` whose arguments name nothing to check.
187    #[must_use]
188    pub fn unknown_tool(host: impl Into<String>, tool: impl Into<String>) -> Self {
189        Self::UnknownTool {
190            host: host.into(),
191            tool: tool.into(),
192            reads: Vec::new(),
193            writes: Vec::new(),
194            hosts: Vec::new(),
195            commands: Vec::new(),
196        }
197    }
198
199    /// `action`, decided at least `review` because `reason` names what the
200    /// payload does not show ([`Action::Unchecked`]).
201    #[must_use]
202    pub fn unchecked(action: Self, reason: impl Into<String>) -> Self {
203        Self::Unchecked {
204            action: Box::new(action),
205            reason: reason.into(),
206        }
207    }
208
209    /// The action itself, without the [`Action::Unchecked`] wrapping around it.
210    #[must_use]
211    pub fn shown(&self) -> &Self {
212        match self {
213            Self::Unchecked { action, .. } => action.shown(),
214            other => other,
215        }
216    }
217}
218
219/// The rule id of a call the hook payload shows only in part
220/// ([`Action::Unchecked`]): it needs review whatever else it is decided.
221pub const UNCHECKED_RULE: &str = "unchecked";
222
223/// The primitive operations an [`Action`] decomposes into.
224///
225/// One shell command typically yields several: the command itself, every
226/// path it touches, every host it may contact, every env var it reads or sets.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229#[non_exhaustive]
230pub enum AtomicAction {
231    /// Normalised argv of one (sub-)command.
232    Shell {
233        /// Program and arguments after quote removal.
234        argv: Vec<String>,
235    },
236    /// A whole pipeline / list (`a | b && c`) with separators kept as tokens, so
237    /// rules such as `curl * | sh` can match across sub-commands. Never carries
238    /// a default verdict (see `engine.rs`).
239    Pipeline {
240        /// Words and separator tokens of the whole pipeline or list.
241        argv: Vec<String>,
242    },
243    /// Absolute path in slash-separated canonical form (`~`, `${project}` and
244    /// `cwd` already applied).
245    FsRead {
246        /// Absolute path in canonical form.
247        path: String,
248    },
249    /// A file write, create, delete or move.
250    FsWrite {
251        /// Absolute path in canonical form.
252        path: String,
253    },
254    /// Host name only (no scheme, no port).
255    Net {
256        /// Lowercase host name.
257        host: String,
258    },
259    /// The host of a URL a fetch tool reads; matched by `fetch` and `net` rules.
260    Fetch {
261        /// Lowercase host name.
262        host: String,
263    },
264    /// A shell word with no scheme that looks like a host, outside the arguments
265    /// of a program that names hosts (ADR-025): it may be a JSON path or a method
266    /// call (`check_runs.0.id`, `app.run`). Matched by `net` rules; when none
267    /// matches, the policy's catch-all decides it, not `defaults.net`.
268    NetWord {
269        /// Lowercase host name.
270        host: String,
271    },
272    /// A web search query; no rule list names it, so `defaults` decide it.
273    WebSearch {
274        /// The query as the tool gave it.
275        query: String,
276    },
277    /// A host tool the adapter cannot classify; only `defaults` decide it.
278    UnknownTool {
279        /// The host's id.
280        host: String,
281        /// The tool name as the host sent it.
282        tool: String,
283    },
284    /// An environment variable read (`$NAME`, `printenv NAME`).
285    EnvRead {
286        /// Variable name.
287        name: String,
288    },
289    /// An environment variable set (`NAME=value`, `export NAME=…`).
290    EnvSet {
291        /// Variable name.
292        name: String,
293    },
294    /// An MCP tool invocation.
295    McpTool {
296        /// `mcp__<server>__<tool>`.
297        name: String,
298    },
299}
300
301impl AtomicAction {
302    /// The rule kind this atom is matched against.
303    #[must_use]
304    pub fn kind(&self) -> Kind {
305        match self {
306            Self::Shell { .. } | Self::Pipeline { .. } => Kind::Shell,
307            Self::FsRead { .. } => Kind::FsRead,
308            Self::FsWrite { .. } => Kind::FsWrite,
309            Self::Net { .. } | Self::NetWord { .. } => Kind::Net,
310            Self::Fetch { .. } => Kind::Fetch,
311            Self::WebSearch { .. } => Kind::WebSearch,
312            Self::UnknownTool { .. } => Kind::UnknownTool,
313            Self::EnvRead { .. } => Kind::EnvRead,
314            Self::EnvSet { .. } => Kind::EnvSet,
315            Self::McpTool { .. } => Kind::Mcp,
316        }
317    }
318
319    /// The same read or write of `path`; `None` for any other atom.
320    pub(crate) fn with_path(&self, path: String) -> Option<Self> {
321        match self {
322            Self::FsRead { .. } => Some(Self::FsRead { path }),
323            Self::FsWrite { .. } => Some(Self::FsWrite { path }),
324            _ => None,
325        }
326    }
327
328    /// The text a glob rule is matched against; `None` for shell atoms, which
329    /// are matched as argv.
330    pub(crate) fn subject(&self) -> Option<&str> {
331        match self {
332            Self::Shell { .. } | Self::Pipeline { .. } => None,
333            Self::FsRead { path } | Self::FsWrite { path } => Some(path),
334            Self::Net { host } | Self::Fetch { host } | Self::NetWord { host } => Some(host),
335            Self::EnvRead { name } | Self::EnvSet { name } | Self::McpTool { name } => Some(name),
336            Self::WebSearch { query } => Some(query),
337            Self::UnknownTool { tool, .. } => Some(tool),
338        }
339    }
340
341    /// Short human description used in reasons and audit lines.
342    #[must_use]
343    pub fn describe(&self) -> String {
344        match self {
345            // the near-miss atom names the package the person probably meant
346            Self::Shell { argv } => match argv.as_slice() {
347                [tag, ecosystem, name, popular] if tag == crate::shell::installs::NEAR_MISS => {
348                    format!("{ecosystem} package \"{name}\" is not \"{popular}\"")
349                }
350                _ => format!("shell \"{}\"", argv.join(" ")),
351            },
352            Self::Pipeline { argv } => format!("pipeline \"{}\"", argv.join(" ")),
353            Self::FsRead { path } => format!("read {path}"),
354            Self::FsWrite { path } => format!("write {path}"),
355            Self::Net { host } => format!("net {host}"),
356            Self::NetWord { host } => format!("net {host} (a word that looks like a host)"),
357            Self::Fetch { host } => format!("fetch {host}"),
358            Self::WebSearch { query } => format!("web search \"{query}\""),
359            Self::UnknownTool { host, tool } => format!("unknown {host} tool {tool}"),
360            Self::EnvRead { name } => format!("env read {name}"),
361            Self::EnvSet { name } => format!("env set {name}"),
362            Self::McpTool { name } => format!("mcp {name}"),
363        }
364    }
365}