1use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::path::PathBuf;
17
18use super::audit_trail::{self, AuditEntryData, AuditEventType};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AgentStatus {
23 Active,
24 Suspended,
25 Decommissioned,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Attestation {
30 pub binary_sha256: String,
32 pub config_sha256: String,
34 pub attested_at: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AgentRecord {
39 pub agent_id: String,
41 pub role: String,
43 pub owner: String,
45 pub status: AgentStatus,
46 pub created_at: String,
47 pub public_key: String,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
50 pub attestation: Option<Attestation>,
51 #[serde(skip_serializing_if = "Option::is_none", default)]
52 pub last_heartbeat: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub suspended_reason: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none", default)]
56 pub decommissioned_at: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
62pub struct IdentityCheck {
63 pub agent_id: String,
64 pub registered: bool,
65 pub allowed: bool,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub status: Option<AgentStatus>,
69 pub detail: String,
70}
71
72fn registry_path() -> Result<PathBuf, String> {
73 let dir = crate::core::data_dir::lean_ctx_data_dir()
74 .map_err(|e| format!("data dir: {e}"))?
75 .join("agents");
76 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
77 Ok(dir.join("identity-registry.json"))
78}
79
80fn legacy_registry_path() -> Result<PathBuf, String> {
81 registry_path().map(|path| path.with_file_name("registry.json"))
82}
83
84fn load_unlocked(path: &PathBuf) -> BTreeMap<String, AgentRecord> {
85 std::fs::read_to_string(path)
86 .ok()
87 .and_then(|c| serde_json::from_str(&c).ok())
88 .unwrap_or_default()
89}
90
91fn load_registry(path: &PathBuf) -> BTreeMap<String, AgentRecord> {
92 if path.exists() {
93 return load_unlocked(path);
94 }
95 legacy_registry_path()
96 .map(|legacy| load_unlocked(&legacy))
97 .unwrap_or_default()
98}
99
100fn with_registry<T>(
103 f: impl FnOnce(&mut BTreeMap<String, AgentRecord>) -> Result<T, String>,
104) -> Result<T, String> {
105 use fs2::FileExt;
106 let path = registry_path()?;
107 let lock_path = path.with_extension("lock");
108 let lock = std::fs::OpenOptions::new()
109 .create(true)
110 .truncate(false)
111 .write(true)
112 .open(&lock_path)
113 .map_err(|e| format!("registry lock: {e}"))?;
114 lock.lock_exclusive()
115 .map_err(|e| format!("registry lock: {e}"))?;
116
117 let mut registry = load_registry(&path);
118 let result = f(&mut registry);
119 if result.is_ok() {
120 let json =
121 serde_json::to_string_pretty(®istry).map_err(|e| format!("serialize: {e}"))?;
122 std::fs::write(&path, json).map_err(|e| format!("persist registry: {e}"))?;
123 }
124 let _ = FileExt::unlock(&lock);
125 result
126}
127
128pub fn list() -> Vec<AgentRecord> {
130 registry_path()
131 .map(|p| load_registry(&p).into_values().collect())
132 .unwrap_or_default()
133}
134
135pub fn get(agent_id: &str) -> Option<AgentRecord> {
136 registry_path()
137 .ok()
138 .and_then(|p| load_registry(&p).remove(agent_id))
139}
140
141fn audit(event_type: AuditEventType, agent_id: &str, role: &str, detail: Option<String>) {
142 audit_trail::record(AuditEntryData {
143 agent_id: agent_id.to_string(),
144 tool: "agent_registry".to_string(),
145 action: detail,
146 input_hash: audit_trail::hash_input(&serde_json::Map::new()),
147 output_tokens: 0,
148 role: role.to_string(),
149 event_type,
150 });
151}
152
153fn binary_sha256() -> String {
158 use std::sync::{Mutex, OnceLock};
159 type HashCache = Mutex<Option<((u64, u64), String)>>;
161 static CACHE: OnceLock<HashCache> = OnceLock::new();
162
163 let Ok(exe) = std::env::current_exe() else {
164 return String::new();
165 };
166 let Ok(meta) = std::fs::metadata(&exe) else {
167 return String::new();
168 };
169 let mtime = meta
170 .modified()
171 .ok()
172 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
173 .map_or(0, |d| d.as_secs());
174 let key = (meta.len(), mtime);
175
176 let cache = CACHE.get_or_init(|| Mutex::new(None));
177 let mut slot = cache
178 .lock()
179 .unwrap_or_else(std::sync::PoisonError::into_inner);
180 if let Some((cached_key, hash)) = slot.as_ref()
181 && *cached_key == key
182 {
183 return hash.clone();
184 }
185 let hash = std::fs::read(&exe)
186 .map(|bytes| sha256_hex(&bytes))
187 .unwrap_or_default();
188 *slot = Some((key, hash.clone()));
189 hash
190}
191
192pub fn attest(role: &str) -> Attestation {
196 let binary_sha256 = binary_sha256();
197 let config_sha256 = role_file_path(role)
198 .and_then(|p| std::fs::read(p).ok())
199 .map(|bytes| sha256_hex(&bytes))
200 .unwrap_or_default();
201 Attestation {
202 binary_sha256,
203 config_sha256,
204 attested_at: chrono::Utc::now().to_rfc3339(),
205 }
206}
207
208fn role_file_path(role: &str) -> Option<PathBuf> {
209 let dir = crate::core::data_dir::lean_ctx_data_dir()
210 .ok()?
211 .join("roles");
212 let path = dir.join(format!("{role}.toml"));
213 path.exists().then_some(path)
214}
215
216fn sha256_hex(bytes: &[u8]) -> String {
217 use sha2::{Digest, Sha256};
218 let mut hasher = Sha256::new();
219 hasher.update(bytes);
220 crate::core::agent_identity::hex_encode(&hasher.finalize())
221}
222
223pub fn register(agent_id: &str, role: &str, owner: &str) -> Result<AgentRecord, String> {
227 if agent_id.trim().is_empty()
228 || !agent_id
229 .chars()
230 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
231 {
232 return Err("agent_id must be non-empty [A-Za-z0-9_-]".to_string());
233 }
234 if owner.trim().is_empty() {
235 return Err(
236 "owner is mandatory — every agent identity has a human accountable for it".to_string(),
237 );
238 }
239 if crate::core::roles::load_role(role).is_none() {
240 return Err(format!(
241 "role '{role}' does not exist (see `lean-ctx roles list`)"
242 ));
243 }
244
245 let public_key = crate::core::agent_identity::get_public_key(agent_id)
246 .map(|k| crate::core::agent_identity::hex_encode(k.as_bytes()))
247 .map_err(|e| format!("keypair: {e}"))?;
248
249 let record = AgentRecord {
250 agent_id: agent_id.to_string(),
251 role: role.to_string(),
252 owner: owner.trim().to_string(),
253 status: AgentStatus::Active,
254 created_at: chrono::Utc::now().to_rfc3339(),
255 public_key,
256 attestation: Some(attest(role)),
257 last_heartbeat: None,
258 suspended_reason: None,
259 decommissioned_at: None,
260 };
261
262 with_registry(|reg| {
263 if reg.contains_key(agent_id) {
264 return Err(format!("agent '{agent_id}' is already registered"));
265 }
266 reg.insert(agent_id.to_string(), record.clone());
267 Ok(())
268 })?;
269 audit(
270 AuditEventType::AgentRegistered,
271 agent_id,
272 role,
273 Some(format!("owner={}", record.owner)),
274 );
275 Ok(record)
276}
277
278pub fn heartbeat(agent_id: &str) -> Result<Option<String>, String> {
281 with_registry(|reg| {
282 let record = reg
283 .get_mut(agent_id)
284 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
285 if record.status == AgentStatus::Decommissioned {
286 return Err(format!("agent '{agent_id}' is decommissioned"));
287 }
288 let fresh = attest(&record.role);
289 let drift = match &record.attestation {
290 Some(prev) if prev.binary_sha256 != fresh.binary_sha256 => {
291 Some("binary hash changed since registration".to_string())
292 }
293 Some(prev) if prev.config_sha256 != fresh.config_sha256 => {
294 Some("role config changed since registration".to_string())
295 }
296 _ => None,
297 };
298 record.last_heartbeat = Some(fresh.attested_at.clone());
299 Ok(drift)
300 })
301}
302
303pub fn suspend(agent_id: &str, reason: &str) -> Result<(), String> {
304 let role = transition(agent_id, AgentStatus::Suspended, Some(reason.to_string()))?;
305 audit(
306 AuditEventType::AgentSuspended,
307 agent_id,
308 &role,
309 Some(reason.to_string()),
310 );
311 Ok(())
312}
313
314pub fn resume(agent_id: &str) -> Result<(), String> {
315 let role = transition(agent_id, AgentStatus::Active, None)?;
316 audit(AuditEventType::AgentResumed, agent_id, &role, None);
317 Ok(())
318}
319
320pub fn decommission(agent_id: &str) -> Result<(), String> {
323 let role = with_registry(|reg| {
324 let record = reg
325 .get_mut(agent_id)
326 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
327 record.status = AgentStatus::Decommissioned;
328 record.decommissioned_at = Some(chrono::Utc::now().to_rfc3339());
329 Ok(record.role.clone())
330 })?;
331 audit(
332 AuditEventType::AgentDecommissioned,
333 agent_id,
334 &role,
335 Some("audit-closing entry".to_string()),
336 );
337 Ok(())
338}
339
340fn transition(agent_id: &str, to: AgentStatus, reason: Option<String>) -> Result<String, String> {
341 with_registry(|reg| {
342 let record = reg
343 .get_mut(agent_id)
344 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
345 if record.status == AgentStatus::Decommissioned {
346 return Err(format!(
347 "agent '{agent_id}' is decommissioned — identities are never reactivated"
348 ));
349 }
350 record.status = to;
351 record.suspended_reason = reason;
352 Ok(record.role.clone())
353 })
354}
355
356pub fn suspend_agents_for_owner(owner: &str, reason: &str) -> Result<Vec<String>, String> {
359 let suspended = with_registry(|reg| {
360 let mut hit = Vec::new();
361 for record in reg.values_mut() {
362 if record.owner == owner && record.status == AgentStatus::Active {
363 record.status = AgentStatus::Suspended;
364 record.suspended_reason = Some(reason.to_string());
365 hit.push((record.agent_id.clone(), record.role.clone()));
366 }
367 }
368 Ok(hit)
369 })?;
370 for (agent_id, role) in &suspended {
371 audit(
372 AuditEventType::AgentSuspended,
373 agent_id,
374 role,
375 Some(format!("owner offboarded: {reason}")),
376 );
377 }
378 Ok(suspended.into_iter().map(|(id, _)| id).collect())
379}
380
381pub fn check(agent_id: &str) -> IdentityCheck {
385 match get(agent_id) {
386 None => IdentityCheck {
387 agent_id: agent_id.to_string(),
388 registered: false,
389 allowed: false,
390 status: None,
391 detail: "not registered — register with `lean-ctx agent register`".to_string(),
392 },
393 Some(record) => {
394 let allowed = record.status == AgentStatus::Active;
395 IdentityCheck {
396 agent_id: agent_id.to_string(),
397 registered: true,
398 allowed,
399 status: Some(record.status),
400 detail: match record.status {
401 AgentStatus::Active => format!("active, owner {}", record.owner),
402 AgentStatus::Suspended => format!(
403 "suspended: {}",
404 record.suspended_reason.as_deref().unwrap_or("no reason")
405 ),
406 AgentStatus::Decommissioned => "decommissioned".to_string(),
407 },
408 }
409 }
410 }
411}
412
413pub fn spiffe_id(record: &AgentRecord, trust_domain: &str) -> String {
416 format!(
417 "spiffe://{}/agent/{}/{}",
418 trust_domain.trim_matches('/'),
419 record.role,
420 record.agent_id
421 )
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn isolated() -> crate::core::data_dir::IsolatedDataDir {
431 crate::core::data_dir::isolated_data_dir()
432 }
433
434 #[test]
435 fn owner_is_mandatory_and_role_must_exist() {
436 let _iso = isolated();
437 assert!(register("a1", "coder", " ").is_err());
438 assert!(register("a1", "no-such-role", "yves@org").is_err());
439 assert!(register("a/1", "coder", "yves@org").is_err());
440 }
441
442 #[test]
443 fn identity_registry_does_not_overwrite_mcp_presence_registry() {
444 let iso = isolated();
445 let agents_dir = iso.path().join("agents");
446 std::fs::create_dir_all(&agents_dir).expect("agents dir");
447 let presence_path = agents_dir.join("registry.json");
448 let presence = r#"{"agents":[{"agent_id":"mcp-1"}]}"#;
449 std::fs::write(&presence_path, presence).expect("presence registry");
450
451 register("identity-1", "coder", "yves@org").expect("register identity");
452
453 assert_eq!(
454 std::fs::read_to_string(presence_path).expect("presence survives"),
455 presence
456 );
457 assert!(agents_dir.join("identity-registry.json").exists());
458 }
459
460 #[test]
461 fn legacy_identity_registry_migrates_on_next_write() {
462 let iso = isolated();
463 let agents_dir = iso.path().join("agents");
464 std::fs::create_dir_all(&agents_dir).expect("agents dir");
465 let legacy_path = agents_dir.join("registry.json");
466 let legacy = AgentRecord {
467 agent_id: "legacy-1".to_string(),
468 role: "coder".to_string(),
469 owner: "yves@org".to_string(),
470 status: AgentStatus::Active,
471 created_at: String::new(),
472 public_key: String::new(),
473 attestation: None,
474 last_heartbeat: None,
475 suspended_reason: None,
476 decommissioned_at: None,
477 };
478 let records = BTreeMap::from([(legacy.agent_id.clone(), legacy)]);
479 std::fs::write(&legacy_path, serde_json::to_string(&records).expect("JSON"))
480 .expect("legacy registry");
481
482 assert!(get("legacy-1").is_some());
483 heartbeat("legacy-1").expect("migrate heartbeat");
484 assert!(agents_dir.join("identity-registry.json").exists());
485 }
486
487 #[test]
488 fn lifecycle_register_suspend_resume_decommission() {
489 let _iso = isolated();
490 let rec = register("agent-x", "coder", "yves@org").expect("register");
491 assert_eq!(rec.status, AgentStatus::Active);
492 assert_eq!(rec.public_key.len(), 64);
493 assert!(rec.attestation.is_some());
494 assert!(
495 register("agent-x", "coder", "yves@org").is_err(),
496 "no double registration"
497 );
498
499 assert!(check("agent-x").allowed);
500 suspend("agent-x", "incident review").expect("suspend");
501 assert!(!check("agent-x").allowed);
502 resume("agent-x").expect("resume");
503 assert!(check("agent-x").allowed);
504
505 decommission("agent-x").expect("decommission");
506 assert!(!check("agent-x").allowed);
507 assert!(resume("agent-x").is_err(), "decommissioned is final");
508 assert!(get("agent-x").expect("kept").decommissioned_at.is_some());
509 }
510
511 #[test]
512 fn owner_offboarding_suspends_only_their_active_agents() {
513 let _iso = isolated();
514 register("a-alice-1", "coder", "alice@org").expect("r1");
515 register("a-alice-2", "reviewer", "alice@org").expect("r2");
516 register("a-bob-1", "coder", "bob@org").expect("r3");
517 decommission("a-alice-2").expect("gone");
518
519 let hit = suspend_agents_for_owner("alice@org", "SCIM deactivated").expect("offboard");
520 assert_eq!(hit, vec!["a-alice-1".to_string()]);
521 assert_eq!(get("a-bob-1").expect("bob").status, AgentStatus::Active);
522 assert_eq!(
523 get("a-alice-1").expect("alice").suspended_reason.as_deref(),
524 Some("SCIM deactivated")
525 );
526 }
527
528 #[test]
529 fn unregistered_agents_are_flagged() {
530 let _iso = isolated();
531 let check = check("ghost");
532 assert!(!check.registered);
533 assert!(!check.allowed);
534 }
535
536 #[test]
537 fn spiffe_id_shape() {
538 let record = AgentRecord {
539 agent_id: "ci-7".to_string(),
540 role: "coder".to_string(),
541 owner: "ops@org".to_string(),
542 status: AgentStatus::Active,
543 created_at: String::new(),
544 public_key: String::new(),
545 attestation: None,
546 last_heartbeat: None,
547 suspended_reason: None,
548 decommissioned_at: None,
549 };
550 assert_eq!(
551 spiffe_id(&record, "org.example"),
552 "spiffe://org.example/agent/coder/ci-7"
553 );
554 }
555
556 #[test]
557 fn heartbeat_updates_liveness_and_reports_no_false_drift() {
558 let _iso = isolated();
559 register("hb-1", "coder", "yves@org").expect("register");
560 let drift = heartbeat("hb-1").expect("heartbeat");
561 assert!(
562 drift.is_none(),
563 "same binary+config must not drift: {drift:?}"
564 );
565 assert!(get("hb-1").expect("rec").last_heartbeat.is_some());
566 assert!(heartbeat("ghost").is_err());
567 }
568}