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")]
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}