Skip to main content

mur_common/
agent_facts.rs

1//! Dispatch index: derived facts about the agents and fleets on this machine.
2//!
3//! Every field here is READ from what the kernel actually enforces
4//! (`entitlements`) plus the profile's own metadata. Nothing is declared in a
5//! separate list, so the index cannot drift from reality the way a hand-written
6//! capability table would — a new agent appears the moment its `profile.yaml`
7//! exists, with no registration step.
8//!
9//! Three properties this module deliberately keeps apart:
10//!
11//! * **Hard** (`exec`, `writes`, `net`) — kernel-enforced, authoritative in the
12//!   NEGATIVE direction: absent from the allowlist means the agent physically
13//!   cannot do it. Present does NOT mean it is good at it. Use to FILTER.
14//! * **Soft** (`role`, `skills`, `model_ref`) — human-written or heuristic, can
15//!   be stale or overstated. Use to RANK and to explain, never to filter.
16//! * **Authorization** (`FleetFacts::authorized`) — read from the global config
17//!   the agents cannot write. Never inferred, never widened here.
18//!
19//! Lives in `mur-common` because both consumers need it and the dependency
20//! graph is `mur-core -> mur-agent-runtime -> mur-common`: the runtime's bash
21//! tool cannot reach mur-core, so anything shared has to sit at the bottom.
22
23use std::path::{Path, PathBuf};
24
25use crate::agent::{AgentProfile, NetworkOutboundMode, SpawnMode};
26use crate::fleet::Fleet;
27
28/// What the sandbox lets an agent exec.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ExecFacts {
31    /// `spawn.mode: any` — no exec restriction at all.
32    Unrestricted,
33    /// Binaries named in `spawn.allowed`.
34    Allowlist(Vec<String>),
35    /// `spawn.mode: none` / `strict` with nothing granted.
36    Nothing,
37}
38
39/// One agent, as the kernel and the profile describe it.
40#[derive(Debug, Clone)]
41pub struct AgentFacts {
42    pub name: String,
43    /// `profile.role`, falling back to a non-boilerplate `persona.description`.
44    /// Empty means nobody has said what this agent is for.
45    pub role: String,
46    pub exec: ExecFacts,
47    pub writes: Vec<PathBuf>,
48    pub net: NetworkOutboundMode,
49    pub skills: Vec<String>,
50    pub model_ref: String,
51    /// Per-turn effort from the profile. `None` means unset — which is the API
52    /// default (`high`), not "no effort"; `mur agent who` says so explicitly
53    /// because the difference is the whole point.
54    pub effort: Option<crate::llm::Effort>,
55    pub running: bool,
56    /// `profile.yaml` (or `sys_prompt.md`) was edited after the running process
57    /// started, so the live agent is NOT what this index describes. See
58    /// [`started_after_edits`].
59    pub drift: bool,
60}
61
62impl AgentFacts {
63    /// Does this agent explicitly hold `bin`?
64    ///
65    /// `bin` may be a bare name (`cargo`) or the absolute path the kernel
66    /// refused (`/Users/d/.cargo/bin/cargo`) — a denial always reports the
67    /// latter, so both sides are compared by file name. An allowlist entry may
68    /// itself be absolute, which is why the normalisation is symmetric.
69    ///
70    /// Deliberately CONSERVATIVE: under `Allowlist` mode the sandbox also
71    /// re-allows the system exec paths (`/usr/bin`, `/bin`, …), so an agent can
72    /// in fact run `/usr/bin/git` without naming it. Resolving that would mean
73    /// replicating the runtime's `PATH` augmentation and Seatbelt's system-path
74    /// exemption down here, and it would answer the wrong question anyway: a
75    /// binary that resolves to a system path is one nobody needed to delegate.
76    /// Under-reporting routes work to an agent that provably holds the binary;
77    /// over-reporting would route it to one that dies with the same EPERM.
78    pub fn can_exec(&self, bin: &str) -> bool {
79        fn base(s: &str) -> &str {
80            Path::new(s)
81                .file_name()
82                .and_then(|f| f.to_str())
83                .unwrap_or(s)
84        }
85        match &self.exec {
86            ExecFacts::Unrestricted => true,
87            ExecFacts::Nothing => false,
88            ExecFacts::Allowlist(list) => {
89                let want = base(bin);
90                list.iter().any(|b| b == bin || base(b) == want)
91            }
92        }
93    }
94
95    /// Can it write anywhere at or under `path`?
96    pub fn can_write(&self, path: &Path) -> bool {
97        self.writes.iter().any(|w| path.starts_with(w))
98    }
99
100    /// How much privilege this agent carries, for least-privilege dispatch
101    /// (P4): among the agents that CAN do the job, prefer the one carrying the
102    /// least unrelated power. Without this the ranking silently prefers the
103    /// most capable agent — which is the one that undoes every containment
104    /// decision made elsewhere.
105    ///
106    /// A heuristic, and openly so: writable roots dominate (they are what an
107    /// escaped task can damage), then egress (what it can exfiltrate to), then
108    /// breadth of exec.
109    pub fn privilege_breadth(&self) -> u32 {
110        let writes = self.writes.len() as u32 * 4;
111        let net = match self.net {
112            NetworkOutboundMode::Unrestricted => 8,
113            NetworkOutboundMode::Restricted => 2,
114            NetworkOutboundMode::ProxyOnly => 1,
115            NetworkOutboundMode::Off => 0,
116        };
117        let exec = match &self.exec {
118            // Anything at all: strictly broader than any enumerable list.
119            ExecFacts::Unrestricted => 100,
120            ExecFacts::Allowlist(l) => l.len() as u32,
121            ExecFacts::Nothing => 0,
122        };
123        writes + net + exec
124    }
125}
126
127/// Why a fleet cannot be dispatched to right now.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Blocker {
130    /// Not in `fleet_run.fleets` (or the caller is not in `fleet_run.agents`).
131    NotAuthorized,
132    /// `fleet_run` refuses fleets without an enforced budget.
133    NoBudget,
134}
135
136impl Blocker {
137    pub fn as_str(&self) -> &'static str {
138        match self {
139            Blocker::NotAuthorized => "not authorized for this agent",
140            Blocker::NoBudget => "no loop.budget_usd",
141        }
142    }
143}
144
145/// One fleet plus the members that give it its abilities. A fleet declares no
146/// capabilities of its own — it has exactly what its members have.
147#[derive(Debug, Clone)]
148pub struct FleetFacts {
149    pub name: String,
150    pub members: Vec<AgentFacts>,
151    pub budget_usd: f64,
152    pub authorized: bool,
153}
154
155impl FleetFacts {
156    pub fn blocker(&self) -> Option<Blocker> {
157        if !self.authorized {
158            Some(Blocker::NotAuthorized)
159        } else if self.budget_usd <= 0.0 {
160            Some(Blocker::NoBudget)
161        } else {
162            None
163        }
164    }
165
166    /// The members that could actually run `bin`.
167    pub fn members_with(&self, bin: &str) -> Vec<&AgentFacts> {
168        self.members.iter().filter(|m| m.can_exec(bin)).collect()
169    }
170
171    /// Least-privileged capable member's breadth — the fleet is only as broad
172    /// as the member that will end up doing the work.
173    fn breadth_for(&self, bin: &str) -> u32 {
174        self.members_with(bin)
175            .iter()
176            .map(|m| m.privilege_breadth())
177            .min()
178            .unwrap_or(u32::MAX)
179    }
180
181    fn covers_cwd(&self, bin: &str, cwd: Option<&Path>) -> bool {
182        match cwd {
183            None => true,
184            Some(c) => self.members_with(bin).iter().any(|m| m.can_write(c)),
185        }
186    }
187}
188
189/// Routes for one denied binary, split by whether they can be used right now.
190///
191/// The split is the security design, not presentation: `ready` is safe to put
192/// in an agent's context, `blocked` is for the HUMAN. A list of "powerful
193/// fleets you are not allowed to use" handed to a prompt-injected agent is an
194/// attack map; handed to the user it is the grant path that stops them from
195/// disabling the sandbox out of frustration.
196#[derive(Debug, Clone, Default)]
197pub struct ExecRoutes {
198    pub ready: Vec<FleetFacts>,
199    pub blocked: Vec<FleetFacts>,
200}
201
202/// Expand `~` and `{{agent_home}}` the way the runtime's profile loader does.
203fn expand(raw: &str, home: &Path, agent_home: &Path) -> PathBuf {
204    let s = raw.replace("{{agent_home}}", &agent_home.to_string_lossy());
205    if let Some(rest) = s.strip_prefix("~/") {
206        home.join(rest)
207    } else if s == "~" {
208        home.to_path_buf()
209    } else {
210        PathBuf::from(s)
211    }
212}
213
214/// Skill names, from BOTH places a profile records them: the `installed_skills`
215/// cards and the bare `skills:` path refs (`skills/idiomatic-rust-2024`) that
216/// most agents actually use. Reading only the former left the soft layer empty
217/// for nearly every agent on a real machine, which would make a capability
218/// roster worthless.
219///
220/// Takes plain lists rather than the profile so it stays testable without a
221/// sixty-field `AgentProfile` fixture.
222fn merge_skill_names(installed: Vec<String>, refs: &[String]) -> Vec<String> {
223    let mut out = installed;
224    for r in refs {
225        let name = r.rsplit('/').next().unwrap_or(r).trim();
226        if !name.is_empty() && !out.iter().any(|s| s == name) {
227            out.push(name.to_string());
228        }
229    }
230    out
231}
232
233/// True when the running process started AFTER the newest edit to the files it
234/// loads once at boot. `perm allow-spawn` warns exactly once at edit time and
235/// nothing reminds you afterwards, so an index built from disk alone will
236/// happily claim an ability the live process does not have.
237///
238/// Uses `running.lock`'s `started_at` against file mtimes — the profile digest
239/// in that lock is computed by the runtime after `{{agent_home}}` expansion, so
240/// recomputing it here would mean duplicating loader logic across a crate
241/// boundary for a strictly worse reason.
242fn started_after_edits(agent_dir: &Path) -> Option<bool> {
243    let lock = std::fs::read_to_string(agent_dir.join("running.lock")).ok()?;
244    let v: serde_json::Value = serde_json::from_str(&lock).ok()?;
245    let started = chrono::DateTime::parse_from_rfc3339(v.get("started_at")?.as_str()?).ok()?;
246    let newest = ["profile.yaml", "sys_prompt.md"]
247        .iter()
248        .filter_map(|f| std::fs::metadata(agent_dir.join(f)).ok()?.modified().ok())
249        .max()?;
250    let newest: chrono::DateTime<chrono::Utc> = newest.into();
251    Some(started.with_timezone(&chrono::Utc) >= newest)
252}
253
254/// Read one agent's facts. `None` when there is no readable profile — every
255/// filesystem failure here is "this agent contributes nothing to the index",
256/// never a hard error: a dispatch hint must not fail because one agent
257/// directory is broken.
258pub fn agent_facts(mur_home: &Path, name: &str) -> Option<AgentFacts> {
259    let agent_dir = mur_home.join("agents").join(name);
260    let raw = std::fs::read_to_string(agent_dir.join("profile.yaml")).ok()?;
261    let p: AgentProfile = serde_yaml_ng::from_str(&raw).ok()?;
262    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
263
264    let exec = match p.entitlements.processes.spawn.mode {
265        SpawnMode::Any => ExecFacts::Unrestricted,
266        SpawnMode::None => ExecFacts::Nothing,
267        SpawnMode::Allowlist | SpawnMode::Strict => {
268            let allowed = p.entitlements.processes.spawn.allowed.clone();
269            if allowed.is_empty() {
270                ExecFacts::Nothing
271            } else {
272                ExecFacts::Allowlist(allowed)
273            }
274        }
275    };
276
277    // `persona.description` is auto-filled with "Agent <name>" at creation
278    // (cmd/agent/lifecycle.rs), which says nothing — treat it as unset rather
279    // than showing it as a role.
280    let boilerplate = format!("Agent {name}");
281    let role = p
282        .role
283        .clone()
284        .filter(|r| !r.trim().is_empty())
285        .or_else(|| {
286            Some(p.persona.description.clone()).filter(|d| !d.is_empty() && *d != boilerplate)
287        })
288        .unwrap_or_default();
289
290    let running = agent_dir.join("running.lock").is_file();
291    Some(AgentFacts {
292        name: name.to_string(),
293        role,
294        exec,
295        writes: p
296            .entitlements
297            .filesystem
298            .write
299            .iter()
300            .map(|w| expand(w, &home, &agent_dir))
301            .collect(),
302        net: p.entitlements.network.outbound.mode,
303        skills: merge_skill_names(
304            p.installed_skills.iter().map(|s| s.name.clone()).collect(),
305            &p.skills,
306        ),
307        model_ref: p.model_ref.clone().unwrap_or_default(),
308        effort: p.effort,
309        // Not running => nothing to drift from; the next start reads disk.
310        drift: running && started_after_edits(&agent_dir) == Some(false),
311        running,
312    })
313}
314
315/// Every agent with a readable profile, sorted by name.
316pub fn scan_agents(mur_home: &Path) -> Vec<AgentFacts> {
317    let mut out: Vec<AgentFacts> = std::fs::read_dir(mur_home.join("agents"))
318        .into_iter()
319        .flatten()
320        .flatten()
321        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
322        .filter_map(|e| {
323            let name = e.file_name().to_string_lossy().into_owned();
324            (!name.starts_with('.')).then(|| agent_facts(mur_home, &name))?
325        })
326        .collect();
327    out.sort_by(|a, b| a.name.cmp(&b.name));
328    out
329}
330
331/// Every fleet, with each member's facts resolved and authorization decided
332/// for `requester` (the agent that would call `fleet_run`).
333pub fn scan_fleets(mur_home: &Path, requester: &str) -> Vec<FleetFacts> {
334    let cfg = crate::config::Config::load_or_default(&mur_home.join("config.yaml")).fleet_run;
335    let caller_ok = cfg.agents.iter().any(|a| a == requester);
336
337    let mut out: Vec<FleetFacts> = std::fs::read_dir(mur_home.join("fleets"))
338        .into_iter()
339        .flatten()
340        .flatten()
341        .filter_map(|e| {
342            let raw = std::fs::read_to_string(e.path().join("fleet.yaml")).ok()?;
343            let f: Fleet = serde_yaml_ng::from_str(&raw).ok()?;
344            let name = f.name.clone();
345            Some(FleetFacts {
346                members: f
347                    .members
348                    .iter()
349                    .filter_map(|m| agent_facts(mur_home, m))
350                    .collect(),
351                budget_usd: f.loop_cfg.map(|l| l.budget_usd).unwrap_or(0.0),
352                authorized: caller_ok && cfg.fleets.contains(&name),
353                name,
354            })
355        })
356        .collect();
357    out.sort_by(|a, b| a.name.cmp(&b.name));
358    out
359}
360
361/// Which fleets can run `bin` (optionally, in a directory they can write).
362///
363/// Ranking, best first: fleets whose capable member can also write `cwd`, then
364/// LEAST privilege (P4), then name for determinism.
365pub fn who_can_exec(mur_home: &Path, requester: &str, bin: &str, cwd: Option<&Path>) -> ExecRoutes {
366    let mut routes = ExecRoutes::default();
367    for f in scan_fleets(mur_home, requester) {
368        if f.members_with(bin).is_empty() {
369            continue;
370        }
371        if f.blocker().is_some() {
372            routes.blocked.push(f);
373        } else {
374            routes.ready.push(f);
375        }
376    }
377    let rank = |f: &FleetFacts| (!f.covers_cwd(bin, cwd), f.breadth_for(bin), f.name.clone());
378    routes.ready.sort_by_key(rank);
379    routes.blocked.sort_by_key(rank);
380    routes
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn facts(name: &str, exec: ExecFacts, writes: &[&str], net: NetworkOutboundMode) -> AgentFacts {
388        AgentFacts {
389            name: name.into(),
390            role: String::new(),
391            exec,
392            writes: writes.iter().map(PathBuf::from).collect(),
393            net,
394            skills: vec![],
395            model_ref: String::new(),
396            effort: None,
397            running: true,
398            drift: false,
399        }
400    }
401
402    #[test]
403    fn can_exec_is_conservative_and_path_aware() {
404        let a = facts(
405            "a",
406            ExecFacts::Allowlist(vec!["cargo".into(), "/opt/x/bin/tool".into()]),
407            &[],
408            NetworkOutboundMode::Off,
409        );
410        assert!(a.can_exec("cargo"));
411        // The kernel reports the ABSOLUTE path it refused, which is what the
412        // dispatch hint hands us — matching only bare names here left every
413        // route empty in the live path.
414        assert!(a.can_exec("/Users/d/.cargo/bin/cargo"));
415        // An absolute allowlist entry still matches a bare request.
416        assert!(a.can_exec("tool"));
417        assert!(a.can_exec("/opt/x/bin/tool"));
418        // Reachable via /usr/bin in reality, but not explicitly held: we do NOT
419        // claim it (see can_exec's doc).
420        assert!(!a.can_exec("git"));
421        assert!(!a.can_exec("/usr/bin/git"));
422        // Basename matching must not turn a different binary into a match.
423        assert!(!a.can_exec("/evil/cargo-nope"));
424
425        assert!(facts("b", ExecFacts::Unrestricted, &[], NetworkOutboundMode::Off).can_exec("git"));
426        assert!(!facts("c", ExecFacts::Nothing, &[], NetworkOutboundMode::Off).can_exec("git"));
427    }
428
429    #[test]
430    fn privilege_breadth_prefers_the_narrow_agent() {
431        let narrow = facts(
432            "narrow",
433            ExecFacts::Allowlist(vec!["cargo".into()]),
434            &["/repo"],
435            NetworkOutboundMode::Restricted,
436        );
437        let wide = facts(
438            "wide",
439            ExecFacts::Allowlist(vec!["cargo".into(), "git".into(), "curl".into()]),
440            &["/repo", "/other", "/home"],
441            NetworkOutboundMode::Unrestricted,
442        );
443        assert!(narrow.privilege_breadth() < wide.privilege_breadth());
444        // Unrestricted exec must outrank any enumerable allowlist.
445        let any = facts(
446            "any",
447            ExecFacts::Unrestricted,
448            &[],
449            NetworkOutboundMode::Off,
450        );
451        assert!(any.privilege_breadth() > wide.privilege_breadth());
452    }
453
454    #[test]
455    fn can_write_matches_subpaths_only() {
456        let a = facts(
457            "a",
458            ExecFacts::Nothing,
459            &["/repo/mur"],
460            NetworkOutboundMode::Off,
461        );
462        assert!(a.can_write(Path::new("/repo/mur")));
463        assert!(a.can_write(Path::new("/repo/mur/src/lib.rs")));
464        assert!(!a.can_write(Path::new("/repo/other")));
465        // Prefix-of-a-sibling must not match (`/repo/mur2` vs `/repo/mur`).
466        assert!(!a.can_write(Path::new("/repo/mur2")));
467    }
468
469    #[test]
470    fn skill_names_merge_refs_dedup_and_drop_empties() {
471        let out = merge_skill_names(
472            vec!["code-review".into()],
473            &[
474                "skills/rust-async".into(),
475                // Already present as an installed card — must not double up.
476                "skills/code-review".into(),
477                "".into(),
478                "bare-name".into(),
479            ],
480        );
481        assert_eq!(out, vec!["code-review", "rust-async", "bare-name"]);
482    }
483
484    #[test]
485    fn blocker_reports_authorization_before_budget() {
486        let mut f = FleetFacts {
487            name: "f".into(),
488            members: vec![],
489            budget_usd: 0.0,
490            authorized: false,
491        };
492        assert_eq!(f.blocker(), Some(Blocker::NotAuthorized));
493        f.authorized = true;
494        assert_eq!(f.blocker(), Some(Blocker::NoBudget));
495        f.budget_usd = 1.0;
496        assert_eq!(f.blocker(), None);
497    }
498
499    #[test]
500    fn fleet_breadth_is_the_narrowest_capable_member() {
501        let f = FleetFacts {
502            name: "f".into(),
503            members: vec![
504                facts(
505                    "wide",
506                    ExecFacts::Allowlist(vec!["cargo".into()]),
507                    &["/a", "/b", "/c"],
508                    NetworkOutboundMode::Unrestricted,
509                ),
510                facts(
511                    "narrow",
512                    ExecFacts::Allowlist(vec!["cargo".into()]),
513                    &["/a"],
514                    NetworkOutboundMode::Restricted,
515                ),
516                // Cannot run it at all — must not lower the fleet's breadth.
517                facts("idle", ExecFacts::Nothing, &[], NetworkOutboundMode::Off),
518            ],
519            budget_usd: 1.0,
520            authorized: true,
521        };
522        assert_eq!(f.members_with("cargo").len(), 2);
523        assert_eq!(f.breadth_for("cargo"), 4 + 2 + 1);
524    }
525}