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`: an `ask`, 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: it names no path or URL the
68    /// adapter knows to read, so what it touches is unknown. Judged by
69    /// `defaults.tool.unknown`, which the default policy sets to `ask`.
70    UnknownTool {
71        /// The host's id (`cursor`).
72        host: String,
73        /// The tool name as the host sent it.
74        tool: String,
75    },
76    /// A patch that edits several files in one call (Codex `apply_patch`):
77    /// every path it adds, updates, deletes or moves to is written.
78    /// Files changed by one multi-file patch (Codex `apply_patch`).
79    Patch {
80        /// Every file the patch adds, updates, deletes or moves to.
81        writes: Vec<String>,
82    },
83    /// Files one tool call reads together (Claude Code `SendFile`, which sends
84    /// their contents to another session): every path is read.
85    ReadFiles {
86        /// Every file the call reads.
87        paths: Vec<String>,
88    },
89    /// An MCP tool call, `mcp__<server>__<tool>`. Adapters that understand a
90    /// server's arguments add the paths and hosts the call touches so the
91    /// usual `fs.*` and `net` rules apply to it as well.
92    McpTool {
93        /// `mcp__<server>__<tool>`.
94        name: String,
95        /// Paths the call reads, derived from its arguments.
96        #[serde(default, skip_serializing_if = "Vec::is_empty")]
97        reads: Vec<String>,
98        /// Paths the call writes, derived from its arguments.
99        #[serde(default, skip_serializing_if = "Vec::is_empty")]
100        writes: Vec<String>,
101        /// URLs or hosts the call contacts, derived from its arguments.
102        #[serde(default, skip_serializing_if = "Vec::is_empty")]
103        hosts: Vec<String>,
104        /// The limit that stopped the adapter searching the arguments (nesting
105        /// depth, number of paths and URLs). What was not searched may name a
106        /// file or host, so the engine decides the call at least `ask`.
107        #[serde(default, skip_serializing_if = "Option::is_none")]
108        unchecked: Option<String>,
109    },
110}
111
112impl Action {
113    /// The kind of action as a user would describe it (`Patch` writes files).
114    #[must_use]
115    pub fn kind(&self) -> Kind {
116        match self {
117            Self::Shell { .. } | Self::ForeignShell { .. } => Kind::Shell,
118            Self::FsRead { .. } | Self::ReadFiles { .. } => Kind::FsRead,
119            Self::FsWrite { .. } | Self::Patch { .. } => Kind::FsWrite,
120            Self::Net { .. } => Kind::Net,
121            Self::Fetch { .. } => Kind::Fetch,
122            Self::WebSearch { .. } => Kind::WebSearch,
123            Self::UnknownTool { .. } => Kind::UnknownTool,
124            Self::McpTool { .. } => Kind::Mcp,
125        }
126    }
127
128    /// An MCP call whose arguments are not interpreted.
129    #[must_use]
130    pub fn mcp(name: impl Into<String>) -> Self {
131        Self::McpTool {
132            name: name.into(),
133            reads: Vec::new(),
134            writes: Vec::new(),
135            hosts: Vec::new(),
136            unchecked: None,
137        }
138    }
139}
140
141/// The primitive operations an [`Action`] decomposes into.
142///
143/// One shell command typically yields several: the command itself, every
144/// path it touches, every host it may contact, every env var it reads or sets.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147#[non_exhaustive]
148pub enum AtomicAction {
149    /// Normalised argv of one (sub-)command.
150    Shell {
151        /// Program and arguments after quote removal.
152        argv: Vec<String>,
153    },
154    /// A whole pipeline / list (`a | b && c`) with separators kept as tokens, so
155    /// rules such as `curl * | sh` can match across sub-commands. Never carries
156    /// a default verdict (see `engine.rs`).
157    Pipeline {
158        /// Words and separator tokens of the whole pipeline or list.
159        argv: Vec<String>,
160    },
161    /// Absolute path in slash-separated canonical form (`~`, `${project}` and
162    /// `cwd` already applied).
163    FsRead {
164        /// Absolute path in canonical form.
165        path: String,
166    },
167    /// A file write, create, delete or move.
168    FsWrite {
169        /// Absolute path in canonical form.
170        path: String,
171    },
172    /// Host name only (no scheme, no port).
173    Net {
174        /// Lowercase host name.
175        host: String,
176    },
177    /// The host of a URL a fetch tool reads; matched by `fetch` and `net` rules.
178    Fetch {
179        /// Lowercase host name.
180        host: String,
181    },
182    /// A shell word with no scheme that looks like a host, outside the arguments
183    /// of a program that names hosts (ADR-025): it may be a JSON path or a method
184    /// call (`check_runs.0.id`, `app.run`). Matched by `net` rules; when none
185    /// matches, the policy's catch-all decides it, not `defaults.net`.
186    NetWord {
187        /// Lowercase host name.
188        host: String,
189    },
190    /// A web search query; no rule list names it, so `defaults` decide it.
191    WebSearch {
192        /// The query as the tool gave it.
193        query: String,
194    },
195    /// A host tool the adapter cannot classify; only `defaults` decide it.
196    UnknownTool {
197        /// The host's id.
198        host: String,
199        /// The tool name as the host sent it.
200        tool: String,
201    },
202    /// An environment variable read (`$NAME`, `printenv NAME`).
203    EnvRead {
204        /// Variable name.
205        name: String,
206    },
207    /// An environment variable set (`NAME=value`, `export NAME=…`).
208    EnvSet {
209        /// Variable name.
210        name: String,
211    },
212    /// An MCP tool invocation.
213    McpTool {
214        /// `mcp__<server>__<tool>`.
215        name: String,
216    },
217}
218
219impl AtomicAction {
220    /// The rule kind this atom is matched against.
221    #[must_use]
222    pub fn kind(&self) -> Kind {
223        match self {
224            Self::Shell { .. } | Self::Pipeline { .. } => Kind::Shell,
225            Self::FsRead { .. } => Kind::FsRead,
226            Self::FsWrite { .. } => Kind::FsWrite,
227            Self::Net { .. } | Self::NetWord { .. } => Kind::Net,
228            Self::Fetch { .. } => Kind::Fetch,
229            Self::WebSearch { .. } => Kind::WebSearch,
230            Self::UnknownTool { .. } => Kind::UnknownTool,
231            Self::EnvRead { .. } => Kind::EnvRead,
232            Self::EnvSet { .. } => Kind::EnvSet,
233            Self::McpTool { .. } => Kind::Mcp,
234        }
235    }
236
237    /// The same read or write of `path`; `None` for any other atom.
238    pub(crate) fn with_path(&self, path: String) -> Option<Self> {
239        match self {
240            Self::FsRead { .. } => Some(Self::FsRead { path }),
241            Self::FsWrite { .. } => Some(Self::FsWrite { path }),
242            _ => None,
243        }
244    }
245
246    /// The text a glob rule is matched against; `None` for shell atoms, which
247    /// are matched as argv.
248    pub(crate) fn subject(&self) -> Option<&str> {
249        match self {
250            Self::Shell { .. } | Self::Pipeline { .. } => None,
251            Self::FsRead { path } | Self::FsWrite { path } => Some(path),
252            Self::Net { host } | Self::Fetch { host } | Self::NetWord { host } => Some(host),
253            Self::EnvRead { name } | Self::EnvSet { name } | Self::McpTool { name } => Some(name),
254            Self::WebSearch { query } => Some(query),
255            Self::UnknownTool { tool, .. } => Some(tool),
256        }
257    }
258
259    /// Short human description used in reasons and audit lines.
260    #[must_use]
261    pub fn describe(&self) -> String {
262        match self {
263            Self::Shell { argv } => format!("shell \"{}\"", argv.join(" ")),
264            Self::Pipeline { argv } => format!("pipeline \"{}\"", argv.join(" ")),
265            Self::FsRead { path } => format!("read {path}"),
266            Self::FsWrite { path } => format!("write {path}"),
267            Self::Net { host } => format!("net {host}"),
268            Self::NetWord { host } => format!("net {host} (a word that looks like a host)"),
269            Self::Fetch { host } => format!("fetch {host}"),
270            Self::WebSearch { query } => format!("web search \"{query}\""),
271            Self::UnknownTool { host, tool } => format!("unknown {host} tool {tool}"),
272            Self::EnvRead { name } => format!("env read {name}"),
273            Self::EnvSet { name } => format!("env set {name}"),
274            Self::McpTool { name } => format!("mcp {name}"),
275        }
276    }
277}