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