Skip to main content

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}