mermaid_model/safety.rs
1//! The safety-policy vocabulary: modes, risk classes, requests, decisions.
2//!
3//! Pure data and its immediate logic -- no classification, no engine. It
4//! lives in this bottom crate so the pure MVU core (`mermaid-domain`) can
5//! speak safety modes and floors without depending on `mermaid-runtime`
6//! (whose manifest carries rusqlite and the OS surface). The engine that
7//! folds this vocabulary into a verdict is `mermaid-runtime`'s
8//! `policy::engine`; the shell classifier that feeds it lives beside it,
9//! and `mermaid-runtime` re-exports these names for its own API surface.
10
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14/// Marker embedded verbatim in every read-only policy-denial `reason` (see
15/// the runtime engine's `PolicyEngine::decide`). Exposed so the
16/// message-history layer can detect a denial that a since-loosened safety
17/// mode has superseded, without re-hardcoding the wording in a second place.
18pub const READ_ONLY_DENIAL_MARKER: &str = "read-only safety mode";
19
20/// Marker embedded verbatim in every plan-mode policy-denial `reason` (the
21/// policy gate rewrites the read-only mode-default deny to a plan-flavored one
22/// while a plan is being drafted). Sibling of [`READ_ONLY_DENIAL_MARKER`]: the
23/// message-history layer matches `"blocked by policy: "` + this marker to
24/// neutralize denials once plan mode ends.
25pub const PLAN_DENIAL_MARKER: &str = "plan mode";
26
27#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SafetyMode {
30 /// A plan is being drafted: a read-only floor plus the plan-mode
31 /// carve-outs the policy gate layers on (the plan file is writable,
32 /// `[plan]` permissions may re-open memory/builds/web).
33 ///
34 /// Plan is a MODE, not a flag alongside one, and it is a full position in
35 /// the Shift+Tab cycle — the strictest one. It used to be a separate
36 /// `Session.plan: Option<_>` orthogonal to `safety_mode`, which meant the
37 /// two could disagree: Shift+Tab while planning set `full_access` and the
38 /// harness then told the model "safety mode changed to `full_access`" while
39 /// the plan read-only floor was still in force — a contradiction the model
40 /// resolved by attempting mutations and collecting denials. With one mode
41 /// value that state is unrepresentable. `Session.plan` still carries the
42 /// plan DATA (path, saved overrides), never the fact of being in plan mode,
43 /// and it never carries a mode to "restore": leaving plan means picking
44 /// another mode, like leaving any other.
45 Plan,
46 ReadOnly,
47 #[default]
48 Ask,
49 Auto,
50 FullAccess,
51}
52
53impl SafetyMode {
54 /// Canonical serialized name — matches the serde `snake_case` rename.
55 #[must_use]
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Plan => "plan",
59 Self::ReadOnly => "read_only",
60 Self::Ask => "ask",
61 Self::Auto => "auto",
62 Self::FullAccess => "full_access",
63 }
64 }
65
66 /// Parse a canonical mode name. Accepts ONLY the canonical `snake_case`
67 /// names — no legacy aliases (the old `"auto_review"` is gone).
68 #[must_use]
69 pub fn parse(s: &str) -> Option<Self> {
70 match s {
71 "plan" => Some(Self::Plan),
72 "read_only" => Some(Self::ReadOnly),
73 "ask" => Some(Self::Ask),
74 "auto" => Some(Self::Auto),
75 "full_access" => Some(Self::FullAccess),
76 _ => None,
77 }
78 }
79
80 /// Is a plan being drafted? The single source of truth — never infer this
81 /// from `Session.plan`, which is the plan's DATA and outlives nothing.
82 #[must_use]
83 pub fn is_planning(self) -> bool {
84 matches!(self, Self::Plan)
85 }
86
87 /// Permissiveness rank for combining modes: `plan/read_only` are strictest,
88 /// `full_access` loosest. Plan ranks below read-only because its carve-outs
89 /// only ever open paths the gate re-checks, and a subagent must never
90 /// inherit "planning" as a ceiling (children explore, they don't plan).
91 #[must_use]
92 pub fn permissiveness(self) -> u8 {
93 match self {
94 Self::Plan => 0,
95 Self::ReadOnly => 1,
96 Self::Ask => 2,
97 Self::Auto => 3,
98 Self::FullAccess => 4,
99 }
100 }
101
102 /// The stricter of two modes. Used to apply an agent type's safety
103 /// ceiling to a session's live mode — a ceiling can only tighten what
104 /// the parent already allows, never loosen it.
105 #[must_use]
106 pub fn least_permissive(a: Self, b: Self) -> Self {
107 if a.permissiveness() <= b.permissiveness() {
108 a
109 } else {
110 b
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ToolCategory {
118 Read,
119 Edit,
120 Shell,
121 Web,
122 ExternalDirectory,
123 ComputerUse,
124 Mcp,
125 Subagent,
126 Network,
127 Git,
128 Process,
129 /// Agent-owned durable memory writes. Ungated in every mode except
130 /// read-only (see `decide`); transparency comes from the surfaced
131 /// transcript action, the plain editable files, and git for shared.
132 Memory,
133}
134
135impl ToolCategory {
136 #[must_use]
137 pub fn as_str(self) -> &'static str {
138 match self {
139 Self::Read => "read",
140 Self::Memory => "memory",
141 Self::Edit => "edit",
142 Self::Shell => "shell",
143 Self::Web => "web",
144 Self::ExternalDirectory => "external_directory",
145 Self::ComputerUse => "computer_use",
146 Self::Mcp => "mcp",
147 Self::Subagent => "subagent",
148 Self::Network => "network",
149 Self::Git => "git",
150 Self::Process => "process",
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum RiskClass {
158 ReadOnly,
159 LowMutation,
160 FileMutation,
161 ShellMutation,
162 Network,
163 Process,
164 ExternalAccess,
165 /// Machine-scoped package operations (`npm -g`, `cargo install`,
166 /// `pip install`, `brew`/`apt`/`winget` installs): they mutate the
167 /// MACHINE, not the project — outside checkpoint reach, visible to every
168 /// other project — so the `system_installs` floor vets them even in
169 /// `full_access`. Project-local installs (`npm install`, `cargo add`)
170 /// deliberately stay Process.
171 SystemMutation,
172 Destructive,
173}
174
175impl RiskClass {
176 #[must_use]
177 pub fn as_str(self) -> &'static str {
178 match self {
179 Self::ReadOnly => "read_only",
180 Self::LowMutation => "low_mutation",
181 Self::FileMutation => "file_mutation",
182 Self::ShellMutation => "shell_mutation",
183 Self::Network => "network",
184 Self::Process => "process",
185 Self::ExternalAccess => "external_access",
186 Self::SystemMutation => "system_mutation",
187 Self::Destructive => "destructive",
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct ActionRequest {
194 pub tool: String,
195 pub category: ToolCategory,
196 pub summary: String,
197 pub command: Option<String>,
198 pub path: Option<String>,
199 /// Complete structured tool arguments. Treat as untrusted input and redact
200 /// before sending it to an external classifier or persistence sink.
201 pub arguments: Option<serde_json::Value>,
202 /// For `ToolCategory::Mcp` only: the server-advertised `readOnlyHint`.
203 /// UNTRUSTED (servers self-declare), so it can only keep a read at the
204 /// permissiveness every MCP tool had before the external-writes floor
205 /// existed — it never grants more than the safety mode gives. `false`
206 /// (the default, and every unannotated tool) means write-shaped and
207 /// subject to the floor.
208 pub mcp_read_only_hint: bool,
209 /// The directory `command` will actually run in, when that is not the
210 /// project root — i.e. an explicit `working_dir` argument.
211 ///
212 /// Relative paths in a command resolve against THIS, not the project root.
213 /// The gate used to match the plan-file carve-out against the project root
214 /// while the shell ran the command elsewhere, so
215 /// `execute_command{command: "echo … > .mermaid/plans/x.md",
216 /// working_dir: "other/tree"}` was approved as a plan write and landed
217 /// somewhere else entirely. Carrying the cwd on the request keeps the
218 /// wrong value out of reach: see [`ActionRequest::resolve_dir`].
219 pub cwd: Option<std::path::PathBuf>,
220}
221
222impl ActionRequest {
223 pub fn new(
224 tool: impl Into<String>,
225 category: ToolCategory,
226 summary: impl Into<String>,
227 ) -> Self {
228 Self {
229 tool: tool.into(),
230 category,
231 summary: summary.into(),
232 command: None,
233 path: None,
234 arguments: None,
235 mcp_read_only_hint: false,
236 cwd: None,
237 }
238 }
239
240 /// The directory command-relative paths must resolve against: the
241 /// request's own cwd when it has one, else `fallback` (the project root).
242 #[must_use]
243 pub fn resolve_dir<'a>(&'a self, fallback: &'a Path) -> &'a Path {
244 self.cwd.as_deref().unwrap_or(fallback)
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum PolicyDecision {
251 Allow {
252 risk: RiskClass,
253 checkpoint: bool,
254 },
255 Ask {
256 risk: RiskClass,
257 checkpoint: bool,
258 },
259 /// Auto mode only: a borderline action the rule engine won't decide
260 /// alone. The caller (the `mermaid-cli` policy gate) resolves it by
261 /// asking the LLM classifier to vet the action against the user's
262 /// intent — aligned ⇒ proceed, otherwise escalate to a human approval.
263 /// The runtime crate stays model-free; it only signals "needs vetting".
264 Classify {
265 risk: RiskClass,
266 checkpoint: bool,
267 },
268 Deny {
269 risk: RiskClass,
270 reason: String,
271 },
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum PolicyOverrideDecision {
277 Allow,
278 Ask,
279 Deny,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(default)]
284pub struct PolicyOverride {
285 pub category: Option<ToolCategory>,
286 pub tool: Option<String>,
287 pub pattern: Option<String>,
288 pub decision: PolicyOverrideDecision,
289 pub checkpoint: Option<bool>,
290 pub reason: Option<String>,
291}
292
293impl Default for PolicyOverride {
294 fn default() -> Self {
295 Self {
296 category: None,
297 tool: None,
298 pattern: None,
299 decision: PolicyOverrideDecision::Ask,
300 checkpoint: None,
301 reason: None,
302 }
303 }
304}
305
306impl PolicyDecision {
307 #[must_use]
308 pub fn risk(&self) -> RiskClass {
309 match self {
310 Self::Allow { risk, .. }
311 | Self::Ask { risk, .. }
312 | Self::Classify { risk, .. }
313 | Self::Deny { risk, .. } => *risk,
314 }
315 }
316
317 #[must_use]
318 pub fn label(&self) -> &'static str {
319 match self {
320 Self::Allow { .. } => "allow",
321 Self::Ask { .. } => "ask",
322 Self::Classify { .. } => "classify",
323 Self::Deny { .. } => "deny",
324 }
325 }
326}
327
328/// Enforcement floor for actions whose blast radius exceeds the project:
329/// write-shaped MCP tools (`external_writes`) and machine-scoped package
330/// operations (`system_installs`). Safety mode alone never authorizes them:
331/// the mode's decision is strengthened to at least this level (severity
332/// order `Allow < Auto < Ask < Deny`). Default `Auto`: the intent
333/// classifier vets the call against the user's request — aligned runs
334/// silently, off-task escalates — even in `full_access`. `allow` restores
335/// the old unconditional-allow behavior per knob.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum FloorLevel {
339 Allow,
340 #[default]
341 Auto,
342 Ask,
343 Deny,
344}
345
346/// Which shell `execute_command` hands model commands to on this host.
347///
348/// THE single answer to "what interpreter runs shell commands?": the exec
349/// tool's spawn (`shell_invocation`), risk classification
350/// (`classify_command_for`), the plan-mode carve-outs
351/// (`is_plan_safe_build_command`, `is_plan_file_only_write`), and the
352/// transcript label (`display_info_for`) all key on this one value, so they
353/// cannot drift apart again — classifying (or labeling) for a different
354/// interpreter than the one that executes is exactly the bug family that
355/// made plan mode deny every read-only PowerShell pipeline on Windows while
356/// the transcript wrapped those pipelines in `Bash(...)`.
357///
358/// Windows executes under PowerShell (`pwsh` when installed, Windows
359/// PowerShell 5.1 otherwise); everywhere else `sh`. [`Self::current`] is the
360/// only `cfg!` site for the decision.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum HostShell {
363 Posix,
364 PowerShell,
365}
366
367impl HostShell {
368 /// The shell of the machine this binary runs on.
369 #[must_use]
370 pub const fn current() -> Self {
371 if cfg!(target_os = "windows") {
372 Self::PowerShell
373 } else {
374 Self::Posix
375 }
376 }
377
378 /// Transcript label for an `execute_command` row (`Bash(cargo test)`,
379 /// `PowerShell(Get-ChildItem)`). "Bash" is the colloquial POSIX label —
380 /// the interpreter is `sh` — kept for familiarity.
381 #[must_use]
382 pub const fn display_name(self) -> &'static str {
383 match self {
384 Self::Posix => "Bash",
385 Self::PowerShell => "PowerShell",
386 }
387 }
388
389 /// Prompt sigil an approval modal puts in front of a command so it reads
390 /// as one. Dialect-specific for the same reason the label is: `$ ` in
391 /// front of `Get-ChildItem` tells the reader they are approving a POSIX
392 /// shell command, which is not what will run.
393 #[must_use]
394 pub const fn prompt_sigil(self) -> &'static str {
395 match self {
396 Self::Posix => "$ ",
397 Self::PowerShell => "PS> ",
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::SafetyMode;
405
406 #[test]
407 fn least_permissive_picks_the_stricter_mode() {
408 use SafetyMode::*;
409 // A ceiling can only tighten: whichever side is stricter wins.
410 assert_eq!(SafetyMode::least_permissive(FullAccess, ReadOnly), ReadOnly);
411 assert_eq!(SafetyMode::least_permissive(ReadOnly, FullAccess), ReadOnly);
412 assert_eq!(SafetyMode::least_permissive(Ask, Auto), Ask);
413 assert_eq!(SafetyMode::least_permissive(Auto, Ask), Ask);
414 // Identity: combining a mode with itself changes nothing.
415 for m in [ReadOnly, Ask, Auto, FullAccess] {
416 assert_eq!(SafetyMode::least_permissive(m, m), m);
417 }
418 // A FullAccess ceiling is a no-op for every live mode.
419 for m in [ReadOnly, Ask, Auto, FullAccess] {
420 assert_eq!(SafetyMode::least_permissive(m, FullAccess), m);
421 }
422 }
423}