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, 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 /// `ask` (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 /// The limit that stopped the adapter searching the arguments (nesting
139 /// depth, number of paths and URLs). What was not searched may name a
140 /// file or host, so the engine decides the call at least `ask`.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 unchecked: Option<String>,
143 },
144}
145
146impl Action {
147 /// The kind of action as a user would describe it (`Patch` writes files).
148 #[must_use]
149 pub fn kind(&self) -> Kind {
150 match self {
151 Self::Shell { .. } | Self::ForeignShell { .. } => Kind::Shell,
152 Self::FsRead { .. } | Self::ReadFiles { .. } => Kind::FsRead,
153 Self::FsWrite { .. } | Self::Patch { .. } => Kind::FsWrite,
154 Self::Net { .. } => Kind::Net,
155 Self::Fetch { .. } => Kind::Fetch,
156 Self::WebSearch { .. } => Kind::WebSearch,
157 Self::UnknownTool { .. } => Kind::UnknownTool,
158 Self::McpTool { .. } => Kind::Mcp,
159 Self::Unchecked { action, .. } => action.kind(),
160 }
161 }
162
163 /// An MCP call whose arguments are not interpreted.
164 #[must_use]
165 pub fn mcp(name: impl Into<String>) -> Self {
166 Self::McpTool {
167 name: name.into(),
168 reads: Vec::new(),
169 writes: Vec::new(),
170 hosts: Vec::new(),
171 opens: Vec::new(),
172 commands: Vec::new(),
173 unchecked: None,
174 }
175 }
176
177 /// An unknown tool of `host` whose arguments name nothing to check.
178 #[must_use]
179 pub fn unknown_tool(host: impl Into<String>, tool: impl Into<String>) -> Self {
180 Self::UnknownTool {
181 host: host.into(),
182 tool: tool.into(),
183 reads: Vec::new(),
184 writes: Vec::new(),
185 hosts: Vec::new(),
186 commands: Vec::new(),
187 }
188 }
189
190 /// `action`, decided at least `ask` because `reason` names what the
191 /// payload does not show ([`Action::Unchecked`]).
192 #[must_use]
193 pub fn unchecked(action: Self, reason: impl Into<String>) -> Self {
194 Self::Unchecked {
195 action: Box::new(action),
196 reason: reason.into(),
197 }
198 }
199
200 /// The action itself, without the [`Action::Unchecked`] wrapping around it.
201 #[must_use]
202 pub fn shown(&self) -> &Self {
203 match self {
204 Self::Unchecked { action, .. } => action.shown(),
205 other => other,
206 }
207 }
208}
209
210/// The rule id of a call the hook payload shows only in part
211/// ([`Action::Unchecked`]): it asks whatever else it is decided.
212pub const UNCHECKED_RULE: &str = "unchecked";
213
214/// The primitive operations an [`Action`] decomposes into.
215///
216/// One shell command typically yields several: the command itself, every
217/// path it touches, every host it may contact, every env var it reads or sets.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220#[non_exhaustive]
221pub enum AtomicAction {
222 /// Normalised argv of one (sub-)command.
223 Shell {
224 /// Program and arguments after quote removal.
225 argv: Vec<String>,
226 },
227 /// A whole pipeline / list (`a | b && c`) with separators kept as tokens, so
228 /// rules such as `curl * | sh` can match across sub-commands. Never carries
229 /// a default verdict (see `engine.rs`).
230 Pipeline {
231 /// Words and separator tokens of the whole pipeline or list.
232 argv: Vec<String>,
233 },
234 /// Absolute path in slash-separated canonical form (`~`, `${project}` and
235 /// `cwd` already applied).
236 FsRead {
237 /// Absolute path in canonical form.
238 path: String,
239 },
240 /// A file write, create, delete or move.
241 FsWrite {
242 /// Absolute path in canonical form.
243 path: String,
244 },
245 /// Host name only (no scheme, no port).
246 Net {
247 /// Lowercase host name.
248 host: String,
249 },
250 /// The host of a URL a fetch tool reads; matched by `fetch` and `net` rules.
251 Fetch {
252 /// Lowercase host name.
253 host: String,
254 },
255 /// A shell word with no scheme that looks like a host, outside the arguments
256 /// of a program that names hosts (ADR-025): it may be a JSON path or a method
257 /// call (`check_runs.0.id`, `app.run`). Matched by `net` rules; when none
258 /// matches, the policy's catch-all decides it, not `defaults.net`.
259 NetWord {
260 /// Lowercase host name.
261 host: String,
262 },
263 /// A web search query; no rule list names it, so `defaults` decide it.
264 WebSearch {
265 /// The query as the tool gave it.
266 query: String,
267 },
268 /// A host tool the adapter cannot classify; only `defaults` decide it.
269 UnknownTool {
270 /// The host's id.
271 host: String,
272 /// The tool name as the host sent it.
273 tool: String,
274 },
275 /// An environment variable read (`$NAME`, `printenv NAME`).
276 EnvRead {
277 /// Variable name.
278 name: String,
279 },
280 /// An environment variable set (`NAME=value`, `export NAME=…`).
281 EnvSet {
282 /// Variable name.
283 name: String,
284 },
285 /// An MCP tool invocation.
286 McpTool {
287 /// `mcp__<server>__<tool>`.
288 name: String,
289 },
290}
291
292impl AtomicAction {
293 /// The rule kind this atom is matched against.
294 #[must_use]
295 pub fn kind(&self) -> Kind {
296 match self {
297 Self::Shell { .. } | Self::Pipeline { .. } => Kind::Shell,
298 Self::FsRead { .. } => Kind::FsRead,
299 Self::FsWrite { .. } => Kind::FsWrite,
300 Self::Net { .. } | Self::NetWord { .. } => Kind::Net,
301 Self::Fetch { .. } => Kind::Fetch,
302 Self::WebSearch { .. } => Kind::WebSearch,
303 Self::UnknownTool { .. } => Kind::UnknownTool,
304 Self::EnvRead { .. } => Kind::EnvRead,
305 Self::EnvSet { .. } => Kind::EnvSet,
306 Self::McpTool { .. } => Kind::Mcp,
307 }
308 }
309
310 /// The same read or write of `path`; `None` for any other atom.
311 pub(crate) fn with_path(&self, path: String) -> Option<Self> {
312 match self {
313 Self::FsRead { .. } => Some(Self::FsRead { path }),
314 Self::FsWrite { .. } => Some(Self::FsWrite { path }),
315 _ => None,
316 }
317 }
318
319 /// The text a glob rule is matched against; `None` for shell atoms, which
320 /// are matched as argv.
321 pub(crate) fn subject(&self) -> Option<&str> {
322 match self {
323 Self::Shell { .. } | Self::Pipeline { .. } => None,
324 Self::FsRead { path } | Self::FsWrite { path } => Some(path),
325 Self::Net { host } | Self::Fetch { host } | Self::NetWord { host } => Some(host),
326 Self::EnvRead { name } | Self::EnvSet { name } | Self::McpTool { name } => Some(name),
327 Self::WebSearch { query } => Some(query),
328 Self::UnknownTool { tool, .. } => Some(tool),
329 }
330 }
331
332 /// Short human description used in reasons and audit lines.
333 #[must_use]
334 pub fn describe(&self) -> String {
335 match self {
336 // the near-miss atom names the package the person probably meant
337 Self::Shell { argv } => match argv.as_slice() {
338 [tag, ecosystem, name, popular] if tag == crate::shell::installs::NEAR_MISS => {
339 format!("{ecosystem} package \"{name}\" is not \"{popular}\"")
340 }
341 _ => format!("shell \"{}\"", argv.join(" ")),
342 },
343 Self::Pipeline { argv } => format!("pipeline \"{}\"", argv.join(" ")),
344 Self::FsRead { path } => format!("read {path}"),
345 Self::FsWrite { path } => format!("write {path}"),
346 Self::Net { host } => format!("net {host}"),
347 Self::NetWord { host } => format!("net {host} (a word that looks like a host)"),
348 Self::Fetch { host } => format!("fetch {host}"),
349 Self::WebSearch { query } => format!("web search \"{query}\""),
350 Self::UnknownTool { host, tool } => format!("unknown {host} tool {tool}"),
351 Self::EnvRead { name } => format!("env read {name}"),
352 Self::EnvSet { name } => format!("env set {name}"),
353 Self::McpTool { name } => format!("mcp {name}"),
354 }
355 }
356}