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