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}