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