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