Skip to main content

mur_common/
entitlements_pin.rs

1//! Out-of-tree pin of an agent's `entitlements` (#712, Linux).
2//!
3//! On Linux, Landlock is allow-only: the agent's own home is granted whole, so
4//! the agent's bash tool can rewrite its own `profile.yaml` — and with it the
5//! entitlements the next start seals. macOS denies that write in SBPL; Linux
6//! cannot express the carve-out. The pin closes it for both: the supervisor
7//! refuses to start when the profile's entitlements differ from the copy kept
8//! at `<mur_home>/entitlement-pins/<name>.json`, a directory no agent sandbox
9//! grants write to (see `LaunchChain::deny_paths`).
10//!
11//! Scope is `entitlements` only, not the whole profile: model, skills and
12//! companion fields are edited by hand and by many writers, and none of them
13//! widens what the kernel lets the agent do.
14//!
15//! The pin stores the canonical JSON, not a hash. Comparison re-reads the
16//! stored JSON through the CURRENT `Entitlements` schema, so a runtime upgrade
17//! that adds a defaulted field compares equal instead of bricking every agent.
18
19use std::io;
20use std::path::{Path, PathBuf};
21
22use crate::agent::{AgentProfile, Entitlements};
23
24/// Directory under `<mur_home>` holding one pin per agent.
25pub const PINS_DIR: &str = "entitlement-pins";
26
27const PIN_EXT: &str = "json";
28
29/// Pin file format version, for a future change of layout.
30const PIN_VERSION: u32 = 1;
31
32#[derive(serde::Serialize, serde::Deserialize)]
33struct PinFile {
34    version: u32,
35    entitlements: serde_json::Value,
36}
37
38/// `<mur_home>/entitlement-pins/<name>.json`.
39pub fn pin_path(mur_home: &Path, agent: &str) -> PathBuf {
40    mur_home.join(PINS_DIR).join(format!("{agent}.{PIN_EXT}"))
41}
42
43/// Outcome of comparing a profile against its pin.
44#[derive(Debug, PartialEq, Eq)]
45pub enum PinCheck {
46    /// The profile's entitlements equal the pinned ones.
47    Match,
48    /// No pin yet (install predates #712, or the pin was deleted).
49    Missing,
50    /// Entitlements differ. Lists the top-level entitlement keys that changed.
51    Mismatch { changed: Vec<String> },
52}
53
54#[derive(Debug, thiserror::Error)]
55pub enum PinError {
56    #[error("io error on {path}: {source}")]
57    Io { path: PathBuf, source: io::Error },
58    #[error("invalid pin file {path}: {msg}")]
59    Invalid { path: PathBuf, msg: String },
60    #[error("invalid profile {path}: {msg}")]
61    Profile { path: PathBuf, msg: String },
62    #[error("invalid agent name '{0}'")]
63    Name(String),
64}
65
66fn canonical(ent: &Entitlements) -> serde_json::Value {
67    // Entitlements holds only plain data; serialization cannot fail.
68    serde_json::to_value(ent).expect("Entitlements serializes to JSON")
69}
70
71fn checked_name(agent: &str) -> Result<(), PinError> {
72    crate::agent_name::validate_agent_name(agent).map_err(|_| PinError::Name(agent.to_string()))
73}
74
75/// Pin `ent` for `agent`. Temp file + rename, so a crash never leaves a torn
76/// pin that would block the next start.
77pub fn write_pin(mur_home: &Path, agent: &str, ent: &Entitlements) -> Result<(), PinError> {
78    checked_name(agent)?;
79    let path = pin_path(mur_home, agent);
80    let io_err = |source| PinError::Io {
81        path: path.clone(),
82        source,
83    };
84    let dir = mur_home.join(PINS_DIR);
85    std::fs::create_dir_all(&dir).map_err(io_err)?;
86    let body = PinFile {
87        version: PIN_VERSION,
88        entitlements: canonical(ent),
89    };
90    let bytes = crate::jcs::to_jcs_for(&body);
91    let mut tmp = tempfile::NamedTempFile::new_in(&dir).map_err(io_err)?;
92    io::Write::write_all(&mut tmp, &bytes).map_err(io_err)?;
93    tmp.persist(&path).map_err(|e| io_err(e.error))?;
94    Ok(())
95}
96
97/// Parse the entitlements out of raw (unexpanded) profile YAML.
98///
99/// Unexpanded on purpose: writers persist `{{agent_home}}` templates, and the
100/// pin must compare what is on disk, not a machine-specific expansion.
101pub fn entitlements_from_yaml(yaml: &str, path: &Path) -> Result<Entitlements, PinError> {
102    serde_yaml_ng::from_str::<AgentProfile>(yaml)
103        .map(|p| p.entitlements)
104        .map_err(|e| PinError::Profile {
105            path: path.to_path_buf(),
106            msg: e.to_string(),
107        })
108}
109
110/// Re-pin `agent` from its on-disk `profile.yaml`. Every trusted writer that
111/// changes entitlements calls this after its write lands; so does
112/// `mur agent perm reseal` after the user confirms a change.
113pub fn repin_from_profile(mur_home: &Path, agent: &str) -> Result<(), PinError> {
114    checked_name(agent)?;
115    let path = mur_home.join("agents").join(agent).join("profile.yaml");
116    let yaml = std::fs::read_to_string(&path).map_err(|source| PinError::Io {
117        path: path.clone(),
118        source,
119    })?;
120    let ent = entitlements_from_yaml(&yaml, &path)?;
121    write_pin(mur_home, agent, &ent)
122}
123
124/// Advance the pin after a trusted writer saved `new`, but only from a trusted
125/// state: the entitlements on disk BEFORE the write (`prior`) must match the
126/// pin, or there must be no pin yet. Otherwise a tampered profile would be
127/// laundered into the pin by the next unrelated save (`mur agent model set`
128/// reads the tampered file and writes it back); instead the pin stays stale and
129/// the next start refuses until the user runs `mur agent perm reseal`.
130///
131/// Returns whether the pin was advanced.
132pub fn advance_pin(
133    mur_home: &Path,
134    agent: &str,
135    prior: Option<&Entitlements>,
136    new: &Entitlements,
137) -> Result<bool, PinError> {
138    let trusted = match prior {
139        // Fresh profile (create / import): nothing on disk to have tampered.
140        None => true,
141        Some(p) => matches!(
142            check(mur_home, agent, p)?,
143            PinCheck::Match | PinCheck::Missing
144        ),
145    };
146    if trusted {
147        write_pin(mur_home, agent, new)?;
148    }
149    Ok(trusted)
150}
151
152/// Drop `agent`'s pin (purge), so a later agent of the same name starts clean
153/// instead of refusing on a stranger's pin. Missing is not an error.
154pub fn remove_pin(mur_home: &Path, agent: &str) -> Result<(), PinError> {
155    checked_name(agent)?;
156    let path = pin_path(mur_home, agent);
157    match std::fs::remove_file(&path) {
158        Ok(()) => Ok(()),
159        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
160        Err(source) => Err(PinError::Io { path, source }),
161    }
162}
163
164/// Carry the pin across `mur agent rename`. No pin to move is not an error.
165pub fn rename_pin(mur_home: &Path, old: &str, new: &str) -> Result<(), PinError> {
166    checked_name(old)?;
167    checked_name(new)?;
168    let (from, to) = (pin_path(mur_home, old), pin_path(mur_home, new));
169    match std::fs::rename(&from, &to) {
170        Ok(()) => Ok(()),
171        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
172        Err(source) => Err(PinError::Io { path: from, source }),
173    }
174}
175
176/// Compare `ent` against the pin for `agent`.
177pub fn check(mur_home: &Path, agent: &str, ent: &Entitlements) -> Result<PinCheck, PinError> {
178    checked_name(agent)?;
179    let path = pin_path(mur_home, agent);
180    let raw = match std::fs::read(&path) {
181        Ok(b) => b,
182        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(PinCheck::Missing),
183        Err(source) => return Err(PinError::Io { path, source }),
184    };
185    let invalid = |msg: String| PinError::Invalid {
186        path: path.clone(),
187        msg,
188    };
189    let file: PinFile = serde_json::from_slice(&raw).map_err(|e| invalid(e.to_string()))?;
190    if file.version != PIN_VERSION {
191        return Err(invalid(format!("unsupported version {}", file.version)));
192    }
193    // Round-trip through the current schema: fields added since the pin was
194    // written take their defaults on both sides and compare equal.
195    let pinned: Entitlements =
196        serde_json::from_value(file.entitlements).map_err(|e| invalid(e.to_string()))?;
197    let (a, b) = (canonical(&pinned), canonical(ent));
198    if a == b {
199        return Ok(PinCheck::Match);
200    }
201    Ok(PinCheck::Mismatch {
202        changed: changed_keys(&a, &b),
203    })
204}
205
206fn changed_keys(a: &serde_json::Value, b: &serde_json::Value) -> Vec<String> {
207    let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
208        return vec!["entitlements".to_string()];
209    };
210    let mut keys: Vec<String> = a
211        .keys()
212        .chain(b.keys())
213        .filter(|k| a.get(*k) != b.get(*k))
214        .cloned()
215        .collect();
216    keys.sort();
217    keys.dedup();
218    keys
219}
220
221#[cfg(test)]
222mod tests;