1use std::path::{Path, PathBuf};
24
25use crate::agent::{AgentProfile, NetworkOutboundMode, SpawnMode};
26use crate::fleet::Fleet;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ExecFacts {
31 Unrestricted,
33 Allowlist(Vec<String>),
35 Nothing,
37}
38
39#[derive(Debug, Clone)]
41pub struct AgentFacts {
42 pub name: String,
43 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 pub effort: Option<crate::llm::Effort>,
55 pub running: bool,
56 pub drift: bool,
60}
61
62impl AgentFacts {
63 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 pub fn can_write(&self, path: &Path) -> bool {
97 self.writes.iter().any(|w| path.starts_with(w))
98 }
99
100 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 ExecFacts::Unrestricted => 100,
120 ExecFacts::Allowlist(l) => l.len() as u32,
121 ExecFacts::Nothing => 0,
122 };
123 writes + net + exec
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Blocker {
130 NotAuthorized,
132 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#[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 pub fn members_with(&self, bin: &str) -> Vec<&AgentFacts> {
168 self.members.iter().filter(|m| m.can_exec(bin)).collect()
169 }
170
171 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#[derive(Debug, Clone, Default)]
197pub struct ExecRoutes {
198 pub ready: Vec<FleetFacts>,
199 pub blocked: Vec<FleetFacts>,
200}
201
202fn 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
214fn 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
233fn 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
254pub 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 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 drift: running && started_after_edits(&agent_dir) == Some(false),
311 running,
312 })
313}
314
315pub 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
331pub 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
361pub 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 assert!(a.can_exec("/Users/d/.cargo/bin/cargo"));
415 assert!(a.can_exec("tool"));
417 assert!(a.can_exec("/opt/x/bin/tool"));
418 assert!(!a.can_exec("git"));
421 assert!(!a.can_exec("/usr/bin/git"));
422 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 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 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 "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 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}