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("registry.json"))
78}
79
80fn load_unlocked(path: &PathBuf) -> BTreeMap<String, AgentRecord> {
81 std::fs::read_to_string(path)
82 .ok()
83 .and_then(|c| serde_json::from_str(&c).ok())
84 .unwrap_or_default()
85}
86
87fn with_registry<T>(
90 f: impl FnOnce(&mut BTreeMap<String, AgentRecord>) -> Result<T, String>,
91) -> Result<T, String> {
92 use fs2::FileExt;
93 let path = registry_path()?;
94 let lock_path = path.with_extension("lock");
95 let lock = std::fs::OpenOptions::new()
96 .create(true)
97 .truncate(false)
98 .write(true)
99 .open(&lock_path)
100 .map_err(|e| format!("registry lock: {e}"))?;
101 lock.lock_exclusive()
102 .map_err(|e| format!("registry lock: {e}"))?;
103
104 let mut registry = load_unlocked(&path);
105 let result = f(&mut registry);
106 if result.is_ok() {
107 let json =
108 serde_json::to_string_pretty(®istry).map_err(|e| format!("serialize: {e}"))?;
109 std::fs::write(&path, json).map_err(|e| format!("persist registry: {e}"))?;
110 }
111 let _ = FileExt::unlock(&lock);
112 result
113}
114
115pub fn list() -> Vec<AgentRecord> {
117 registry_path()
118 .map(|p| load_unlocked(&p).into_values().collect())
119 .unwrap_or_default()
120}
121
122pub fn get(agent_id: &str) -> Option<AgentRecord> {
123 registry_path()
124 .ok()
125 .and_then(|p| load_unlocked(&p).remove(agent_id))
126}
127
128fn audit(event_type: AuditEventType, agent_id: &str, role: &str, detail: Option<String>) {
129 audit_trail::record(AuditEntryData {
130 agent_id: agent_id.to_string(),
131 tool: "agent_registry".to_string(),
132 action: detail,
133 input_hash: audit_trail::hash_input(&serde_json::Map::new()),
134 output_tokens: 0,
135 role: role.to_string(),
136 event_type,
137 });
138}
139
140fn binary_sha256() -> String {
145 use std::sync::{Mutex, OnceLock};
146 type HashCache = Mutex<Option<((u64, u64), String)>>;
148 static CACHE: OnceLock<HashCache> = OnceLock::new();
149
150 let Ok(exe) = std::env::current_exe() else {
151 return String::new();
152 };
153 let Ok(meta) = std::fs::metadata(&exe) else {
154 return String::new();
155 };
156 let mtime = meta
157 .modified()
158 .ok()
159 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
160 .map_or(0, |d| d.as_secs());
161 let key = (meta.len(), mtime);
162
163 let cache = CACHE.get_or_init(|| Mutex::new(None));
164 let mut slot = cache
165 .lock()
166 .unwrap_or_else(std::sync::PoisonError::into_inner);
167 if let Some((cached_key, hash)) = slot.as_ref() {
168 if *cached_key == key {
169 return hash.clone();
170 }
171 }
172 let hash = std::fs::read(&exe)
173 .map(|bytes| sha256_hex(&bytes))
174 .unwrap_or_default();
175 *slot = Some((key, hash.clone()));
176 hash
177}
178
179pub fn attest(role: &str) -> Attestation {
183 let binary_sha256 = binary_sha256();
184 let config_sha256 = role_file_path(role)
185 .and_then(|p| std::fs::read(p).ok())
186 .map(|bytes| sha256_hex(&bytes))
187 .unwrap_or_default();
188 Attestation {
189 binary_sha256,
190 config_sha256,
191 attested_at: chrono::Utc::now().to_rfc3339(),
192 }
193}
194
195fn role_file_path(role: &str) -> Option<PathBuf> {
196 let dir = crate::core::data_dir::lean_ctx_data_dir()
197 .ok()?
198 .join("roles");
199 let path = dir.join(format!("{role}.toml"));
200 path.exists().then_some(path)
201}
202
203fn sha256_hex(bytes: &[u8]) -> String {
204 use sha2::{Digest, Sha256};
205 let mut hasher = Sha256::new();
206 hasher.update(bytes);
207 format!("{:x}", hasher.finalize())
208}
209
210pub fn register(agent_id: &str, role: &str, owner: &str) -> Result<AgentRecord, String> {
214 if agent_id.trim().is_empty()
215 || !agent_id
216 .chars()
217 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
218 {
219 return Err("agent_id must be non-empty [A-Za-z0-9_-]".to_string());
220 }
221 if owner.trim().is_empty() {
222 return Err(
223 "owner is mandatory — every agent identity has a human accountable for it".to_string(),
224 );
225 }
226 if crate::core::roles::load_role(role).is_none() {
227 return Err(format!(
228 "role '{role}' does not exist (see `lean-ctx roles list`)"
229 ));
230 }
231
232 let public_key = crate::core::agent_identity::get_public_key(agent_id)
233 .map(|k| crate::core::agent_identity::hex_encode(k.as_bytes()))
234 .map_err(|e| format!("keypair: {e}"))?;
235
236 let record = AgentRecord {
237 agent_id: agent_id.to_string(),
238 role: role.to_string(),
239 owner: owner.trim().to_string(),
240 status: AgentStatus::Active,
241 created_at: chrono::Utc::now().to_rfc3339(),
242 public_key,
243 attestation: Some(attest(role)),
244 last_heartbeat: None,
245 suspended_reason: None,
246 decommissioned_at: None,
247 };
248
249 with_registry(|reg| {
250 if reg.contains_key(agent_id) {
251 return Err(format!("agent '{agent_id}' is already registered"));
252 }
253 reg.insert(agent_id.to_string(), record.clone());
254 Ok(())
255 })?;
256 audit(
257 AuditEventType::AgentRegistered,
258 agent_id,
259 role,
260 Some(format!("owner={}", record.owner)),
261 );
262 Ok(record)
263}
264
265pub fn heartbeat(agent_id: &str) -> Result<Option<String>, String> {
268 with_registry(|reg| {
269 let record = reg
270 .get_mut(agent_id)
271 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
272 if record.status == AgentStatus::Decommissioned {
273 return Err(format!("agent '{agent_id}' is decommissioned"));
274 }
275 let fresh = attest(&record.role);
276 let drift = match &record.attestation {
277 Some(prev) if prev.binary_sha256 != fresh.binary_sha256 => {
278 Some("binary hash changed since registration".to_string())
279 }
280 Some(prev) if prev.config_sha256 != fresh.config_sha256 => {
281 Some("role config changed since registration".to_string())
282 }
283 _ => None,
284 };
285 record.last_heartbeat = Some(fresh.attested_at.clone());
286 Ok(drift)
287 })
288}
289
290pub fn suspend(agent_id: &str, reason: &str) -> Result<(), String> {
291 let role = transition(agent_id, AgentStatus::Suspended, Some(reason.to_string()))?;
292 audit(
293 AuditEventType::AgentSuspended,
294 agent_id,
295 &role,
296 Some(reason.to_string()),
297 );
298 Ok(())
299}
300
301pub fn resume(agent_id: &str) -> Result<(), String> {
302 let role = transition(agent_id, AgentStatus::Active, None)?;
303 audit(AuditEventType::AgentResumed, agent_id, &role, None);
304 Ok(())
305}
306
307pub fn decommission(agent_id: &str) -> Result<(), String> {
310 let role = with_registry(|reg| {
311 let record = reg
312 .get_mut(agent_id)
313 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
314 record.status = AgentStatus::Decommissioned;
315 record.decommissioned_at = Some(chrono::Utc::now().to_rfc3339());
316 Ok(record.role.clone())
317 })?;
318 audit(
319 AuditEventType::AgentDecommissioned,
320 agent_id,
321 &role,
322 Some("audit-closing entry".to_string()),
323 );
324 Ok(())
325}
326
327fn transition(agent_id: &str, to: AgentStatus, reason: Option<String>) -> Result<String, String> {
328 with_registry(|reg| {
329 let record = reg
330 .get_mut(agent_id)
331 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
332 if record.status == AgentStatus::Decommissioned {
333 return Err(format!(
334 "agent '{agent_id}' is decommissioned — identities are never reactivated"
335 ));
336 }
337 record.status = to;
338 record.suspended_reason = reason;
339 Ok(record.role.clone())
340 })
341}
342
343pub fn suspend_agents_for_owner(owner: &str, reason: &str) -> Result<Vec<String>, String> {
346 let suspended = with_registry(|reg| {
347 let mut hit = Vec::new();
348 for record in reg.values_mut() {
349 if record.owner == owner && record.status == AgentStatus::Active {
350 record.status = AgentStatus::Suspended;
351 record.suspended_reason = Some(reason.to_string());
352 hit.push((record.agent_id.clone(), record.role.clone()));
353 }
354 }
355 Ok(hit)
356 })?;
357 for (agent_id, role) in &suspended {
358 audit(
359 AuditEventType::AgentSuspended,
360 agent_id,
361 role,
362 Some(format!("owner offboarded: {reason}")),
363 );
364 }
365 Ok(suspended.into_iter().map(|(id, _)| id).collect())
366}
367
368pub fn check(agent_id: &str) -> IdentityCheck {
372 match get(agent_id) {
373 None => IdentityCheck {
374 agent_id: agent_id.to_string(),
375 registered: false,
376 allowed: false,
377 status: None,
378 detail: "not registered — register with `lean-ctx agent register`".to_string(),
379 },
380 Some(record) => {
381 let allowed = record.status == AgentStatus::Active;
382 IdentityCheck {
383 agent_id: agent_id.to_string(),
384 registered: true,
385 allowed,
386 status: Some(record.status),
387 detail: match record.status {
388 AgentStatus::Active => format!("active, owner {}", record.owner),
389 AgentStatus::Suspended => format!(
390 "suspended: {}",
391 record.suspended_reason.as_deref().unwrap_or("no reason")
392 ),
393 AgentStatus::Decommissioned => "decommissioned".to_string(),
394 },
395 }
396 }
397 }
398}
399
400pub fn spiffe_id(record: &AgentRecord, trust_domain: &str) -> String {
403 format!(
404 "spiffe://{}/agent/{}/{}",
405 trust_domain.trim_matches('/'),
406 record.role,
407 record.agent_id
408 )
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn isolated() -> crate::core::data_dir::IsolatedDataDir {
418 crate::core::data_dir::isolated_data_dir()
419 }
420
421 #[test]
422 fn owner_is_mandatory_and_role_must_exist() {
423 let _iso = isolated();
424 assert!(register("a1", "coder", " ").is_err());
425 assert!(register("a1", "no-such-role", "yves@org").is_err());
426 assert!(register("a/1", "coder", "yves@org").is_err());
427 }
428
429 #[test]
430 fn lifecycle_register_suspend_resume_decommission() {
431 let _iso = isolated();
432 let rec = register("agent-x", "coder", "yves@org").expect("register");
433 assert_eq!(rec.status, AgentStatus::Active);
434 assert_eq!(rec.public_key.len(), 64);
435 assert!(rec.attestation.is_some());
436 assert!(
437 register("agent-x", "coder", "yves@org").is_err(),
438 "no double registration"
439 );
440
441 assert!(check("agent-x").allowed);
442 suspend("agent-x", "incident review").expect("suspend");
443 assert!(!check("agent-x").allowed);
444 resume("agent-x").expect("resume");
445 assert!(check("agent-x").allowed);
446
447 decommission("agent-x").expect("decommission");
448 assert!(!check("agent-x").allowed);
449 assert!(resume("agent-x").is_err(), "decommissioned is final");
450 assert!(get("agent-x").expect("kept").decommissioned_at.is_some());
451 }
452
453 #[test]
454 fn owner_offboarding_suspends_only_their_active_agents() {
455 let _iso = isolated();
456 register("a-alice-1", "coder", "alice@org").expect("r1");
457 register("a-alice-2", "reviewer", "alice@org").expect("r2");
458 register("a-bob-1", "coder", "bob@org").expect("r3");
459 decommission("a-alice-2").expect("gone");
460
461 let hit = suspend_agents_for_owner("alice@org", "SCIM deactivated").expect("offboard");
462 assert_eq!(hit, vec!["a-alice-1".to_string()]);
463 assert_eq!(get("a-bob-1").expect("bob").status, AgentStatus::Active);
464 assert_eq!(
465 get("a-alice-1").expect("alice").suspended_reason.as_deref(),
466 Some("SCIM deactivated")
467 );
468 }
469
470 #[test]
471 fn unregistered_agents_are_flagged() {
472 let _iso = isolated();
473 let check = check("ghost");
474 assert!(!check.registered);
475 assert!(!check.allowed);
476 }
477
478 #[test]
479 fn spiffe_id_shape() {
480 let record = AgentRecord {
481 agent_id: "ci-7".to_string(),
482 role: "coder".to_string(),
483 owner: "ops@org".to_string(),
484 status: AgentStatus::Active,
485 created_at: String::new(),
486 public_key: String::new(),
487 attestation: None,
488 last_heartbeat: None,
489 suspended_reason: None,
490 decommissioned_at: None,
491 };
492 assert_eq!(
493 spiffe_id(&record, "org.example"),
494 "spiffe://org.example/agent/coder/ci-7"
495 );
496 }
497
498 #[test]
499 fn heartbeat_updates_liveness_and_reports_no_false_drift() {
500 let _iso = isolated();
501 register("hb-1", "coder", "yves@org").expect("register");
502 let drift = heartbeat("hb-1").expect("heartbeat");
503 assert!(
504 drift.is_none(),
505 "same binary+config must not drift: {drift:?}"
506 );
507 assert!(get("hb-1").expect("rec").last_heartbeat.is_some());
508 assert!(heartbeat("ghost").is_err());
509 }
510}