use std::path::{Path, PathBuf};
use crate::agent::{AgentProfile, NetworkOutboundMode, SpawnMode};
use crate::fleet::Fleet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecFacts {
Unrestricted,
Allowlist(Vec<String>),
Nothing,
}
#[derive(Debug, Clone)]
pub struct AgentFacts {
pub name: String,
pub role: String,
pub exec: ExecFacts,
pub writes: Vec<PathBuf>,
pub net: NetworkOutboundMode,
pub skills: Vec<String>,
pub model_ref: String,
pub effort: Option<crate::llm::Effort>,
pub running: bool,
pub drift: bool,
}
impl AgentFacts {
pub fn can_exec(&self, bin: &str) -> bool {
fn base(s: &str) -> &str {
Path::new(s)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(s)
}
match &self.exec {
ExecFacts::Unrestricted => true,
ExecFacts::Nothing => false,
ExecFacts::Allowlist(list) => {
let want = base(bin);
list.iter().any(|b| b == bin || base(b) == want)
}
}
}
pub fn can_write(&self, path: &Path) -> bool {
self.writes.iter().any(|w| path.starts_with(w))
}
pub fn privilege_breadth(&self) -> u32 {
let writes = self.writes.len() as u32 * 4;
let net = match self.net {
NetworkOutboundMode::Unrestricted => 8,
NetworkOutboundMode::Restricted => 2,
NetworkOutboundMode::ProxyOnly => 1,
NetworkOutboundMode::Off => 0,
};
let exec = match &self.exec {
ExecFacts::Unrestricted => 100,
ExecFacts::Allowlist(l) => l.len() as u32,
ExecFacts::Nothing => 0,
};
writes + net + exec
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Blocker {
NotAuthorized,
NoBudget,
}
impl Blocker {
pub fn as_str(&self) -> &'static str {
match self {
Blocker::NotAuthorized => "not authorized for this agent",
Blocker::NoBudget => "no loop.budget_usd",
}
}
}
#[derive(Debug, Clone)]
pub struct FleetFacts {
pub name: String,
pub members: Vec<AgentFacts>,
pub budget_usd: f64,
pub authorized: bool,
}
impl FleetFacts {
pub fn blocker(&self) -> Option<Blocker> {
if !self.authorized {
Some(Blocker::NotAuthorized)
} else if self.budget_usd <= 0.0 {
Some(Blocker::NoBudget)
} else {
None
}
}
pub fn members_with(&self, bin: &str) -> Vec<&AgentFacts> {
self.members.iter().filter(|m| m.can_exec(bin)).collect()
}
fn breadth_for(&self, bin: &str) -> u32 {
self.members_with(bin)
.iter()
.map(|m| m.privilege_breadth())
.min()
.unwrap_or(u32::MAX)
}
fn covers_cwd(&self, bin: &str, cwd: Option<&Path>) -> bool {
match cwd {
None => true,
Some(c) => self.members_with(bin).iter().any(|m| m.can_write(c)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ExecRoutes {
pub ready: Vec<FleetFacts>,
pub blocked: Vec<FleetFacts>,
}
fn expand(raw: &str, home: &Path, agent_home: &Path) -> PathBuf {
let s = raw.replace("{{agent_home}}", &agent_home.to_string_lossy());
if let Some(rest) = s.strip_prefix("~/") {
home.join(rest)
} else if s == "~" {
home.to_path_buf()
} else {
PathBuf::from(s)
}
}
fn merge_skill_names(installed: Vec<String>, refs: &[String]) -> Vec<String> {
let mut out = installed;
for r in refs {
let name = r.rsplit('/').next().unwrap_or(r).trim();
if !name.is_empty() && !out.iter().any(|s| s == name) {
out.push(name.to_string());
}
}
out
}
fn started_after_edits(agent_dir: &Path) -> Option<bool> {
let lock = std::fs::read_to_string(agent_dir.join("running.lock")).ok()?;
let v: serde_json::Value = serde_json::from_str(&lock).ok()?;
let started = chrono::DateTime::parse_from_rfc3339(v.get("started_at")?.as_str()?).ok()?;
let newest = ["profile.yaml", "sys_prompt.md"]
.iter()
.filter_map(|f| std::fs::metadata(agent_dir.join(f)).ok()?.modified().ok())
.max()?;
let newest: chrono::DateTime<chrono::Utc> = newest.into();
Some(started.with_timezone(&chrono::Utc) >= newest)
}
pub fn agent_facts(mur_home: &Path, name: &str) -> Option<AgentFacts> {
let agent_dir = mur_home.join("agents").join(name);
let raw = std::fs::read_to_string(agent_dir.join("profile.yaml")).ok()?;
let p: AgentProfile = serde_yaml_ng::from_str(&raw).ok()?;
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
let exec = match p.entitlements.processes.spawn.mode {
SpawnMode::Any => ExecFacts::Unrestricted,
SpawnMode::None => ExecFacts::Nothing,
SpawnMode::Allowlist | SpawnMode::Strict => {
let allowed = p.entitlements.processes.spawn.allowed.clone();
if allowed.is_empty() {
ExecFacts::Nothing
} else {
ExecFacts::Allowlist(allowed)
}
}
};
let boilerplate = format!("Agent {name}");
let role = p
.role
.clone()
.filter(|r| !r.trim().is_empty())
.or_else(|| {
Some(p.persona.description.clone()).filter(|d| !d.is_empty() && *d != boilerplate)
})
.unwrap_or_default();
let running = agent_dir.join("running.lock").is_file();
Some(AgentFacts {
name: name.to_string(),
role,
exec,
writes: p
.entitlements
.filesystem
.write
.iter()
.map(|w| expand(w, &home, &agent_dir))
.collect(),
net: p.entitlements.network.outbound.mode,
skills: merge_skill_names(
p.installed_skills.iter().map(|s| s.name.clone()).collect(),
&p.skills,
),
model_ref: p.model_ref.clone().unwrap_or_default(),
effort: p.effort,
drift: running && started_after_edits(&agent_dir) == Some(false),
running,
})
}
pub fn scan_agents(mur_home: &Path) -> Vec<AgentFacts> {
let mut out: Vec<AgentFacts> = std::fs::read_dir(mur_home.join("agents"))
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
(!name.starts_with('.')).then(|| agent_facts(mur_home, &name))?
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn scan_fleets(mur_home: &Path, requester: &str) -> Vec<FleetFacts> {
let cfg = crate::config::Config::load_or_default(&mur_home.join("config.yaml")).fleet_run;
let caller_ok = cfg.agents.iter().any(|a| a == requester);
let mut out: Vec<FleetFacts> = std::fs::read_dir(mur_home.join("fleets"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let raw = std::fs::read_to_string(e.path().join("fleet.yaml")).ok()?;
let f: Fleet = serde_yaml_ng::from_str(&raw).ok()?;
let name = f.name.clone();
Some(FleetFacts {
members: f
.members
.iter()
.filter_map(|m| agent_facts(mur_home, m))
.collect(),
budget_usd: f.loop_cfg.map(|l| l.budget_usd).unwrap_or(0.0),
authorized: caller_ok && cfg.fleets.contains(&name),
name,
})
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn who_can_exec(mur_home: &Path, requester: &str, bin: &str, cwd: Option<&Path>) -> ExecRoutes {
let mut routes = ExecRoutes::default();
for f in scan_fleets(mur_home, requester) {
if f.members_with(bin).is_empty() {
continue;
}
if f.blocker().is_some() {
routes.blocked.push(f);
} else {
routes.ready.push(f);
}
}
let rank = |f: &FleetFacts| (!f.covers_cwd(bin, cwd), f.breadth_for(bin), f.name.clone());
routes.ready.sort_by_key(rank);
routes.blocked.sort_by_key(rank);
routes
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(name: &str, exec: ExecFacts, writes: &[&str], net: NetworkOutboundMode) -> AgentFacts {
AgentFacts {
name: name.into(),
role: String::new(),
exec,
writes: writes.iter().map(PathBuf::from).collect(),
net,
skills: vec![],
model_ref: String::new(),
effort: None,
running: true,
drift: false,
}
}
#[test]
fn can_exec_is_conservative_and_path_aware() {
let a = facts(
"a",
ExecFacts::Allowlist(vec!["cargo".into(), "/opt/x/bin/tool".into()]),
&[],
NetworkOutboundMode::Off,
);
assert!(a.can_exec("cargo"));
assert!(a.can_exec("/Users/d/.cargo/bin/cargo"));
assert!(a.can_exec("tool"));
assert!(a.can_exec("/opt/x/bin/tool"));
assert!(!a.can_exec("git"));
assert!(!a.can_exec("/usr/bin/git"));
assert!(!a.can_exec("/evil/cargo-nope"));
assert!(facts("b", ExecFacts::Unrestricted, &[], NetworkOutboundMode::Off).can_exec("git"));
assert!(!facts("c", ExecFacts::Nothing, &[], NetworkOutboundMode::Off).can_exec("git"));
}
#[test]
fn privilege_breadth_prefers_the_narrow_agent() {
let narrow = facts(
"narrow",
ExecFacts::Allowlist(vec!["cargo".into()]),
&["/repo"],
NetworkOutboundMode::Restricted,
);
let wide = facts(
"wide",
ExecFacts::Allowlist(vec!["cargo".into(), "git".into(), "curl".into()]),
&["/repo", "/other", "/home"],
NetworkOutboundMode::Unrestricted,
);
assert!(narrow.privilege_breadth() < wide.privilege_breadth());
let any = facts(
"any",
ExecFacts::Unrestricted,
&[],
NetworkOutboundMode::Off,
);
assert!(any.privilege_breadth() > wide.privilege_breadth());
}
#[test]
fn can_write_matches_subpaths_only() {
let a = facts(
"a",
ExecFacts::Nothing,
&["/repo/mur"],
NetworkOutboundMode::Off,
);
assert!(a.can_write(Path::new("/repo/mur")));
assert!(a.can_write(Path::new("/repo/mur/src/lib.rs")));
assert!(!a.can_write(Path::new("/repo/other")));
assert!(!a.can_write(Path::new("/repo/mur2")));
}
#[test]
fn skill_names_merge_refs_dedup_and_drop_empties() {
let out = merge_skill_names(
vec!["code-review".into()],
&[
"skills/rust-async".into(),
"skills/code-review".into(),
"".into(),
"bare-name".into(),
],
);
assert_eq!(out, vec!["code-review", "rust-async", "bare-name"]);
}
#[test]
fn blocker_reports_authorization_before_budget() {
let mut f = FleetFacts {
name: "f".into(),
members: vec![],
budget_usd: 0.0,
authorized: false,
};
assert_eq!(f.blocker(), Some(Blocker::NotAuthorized));
f.authorized = true;
assert_eq!(f.blocker(), Some(Blocker::NoBudget));
f.budget_usd = 1.0;
assert_eq!(f.blocker(), None);
}
#[test]
fn fleet_breadth_is_the_narrowest_capable_member() {
let f = FleetFacts {
name: "f".into(),
members: vec![
facts(
"wide",
ExecFacts::Allowlist(vec!["cargo".into()]),
&["/a", "/b", "/c"],
NetworkOutboundMode::Unrestricted,
),
facts(
"narrow",
ExecFacts::Allowlist(vec!["cargo".into()]),
&["/a"],
NetworkOutboundMode::Restricted,
),
facts("idle", ExecFacts::Nothing, &[], NetworkOutboundMode::Off),
],
budget_usd: 1.0,
authorized: true,
};
assert_eq!(f.members_with("cargo").len(), 2);
assert_eq!(f.breadth_for("cargo"), 4 + 2 + 1);
}
}