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 #[serde(skip_serializing_if = "Option::is_none", default)]
49 pub drift: Option<DriftMark>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct DriftMark {
57 pub detected_at: String,
58 pub binary: bool,
60 pub config: bool,
62 pub evidence: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct HeartbeatOutcome {
75 pub drift: Option<DriftMark>,
76 pub newly_observed: bool,
77}
78
79#[cfg(test)]
80impl HeartbeatOutcome {
81 pub(crate) fn evidence(&self) -> Option<&str> {
83 self.drift.as_ref().map(|mark| mark.evidence.as_str())
84 }
85
86 pub(crate) fn is_none(&self) -> bool {
89 !self.newly_observed
90 }
91
92 pub(crate) fn expect(self, message: &str) -> String {
95 self.drift
96 .map(|mark| mark.evidence)
97 .unwrap_or_else(|| panic!("{message}"))
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DriftAcknowledgement {
104 pub agent_id: String,
105 pub evidence: String,
107 pub detected_at: String,
108 pub acknowledged_at: String,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub(crate) struct DriftDimensions {
116 pub binary: bool,
117 pub config: bool,
118}
119
120impl DriftDimensions {
121 fn any(self) -> bool {
122 self.binary || self.config
123 }
124
125 fn label(self) -> &'static str {
127 match (self.binary, self.config) {
128 (true, true) => "binary+config",
129 (true, false) => "binary",
130 (false, true) => "config",
131 (false, false) => "none",
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub(crate) struct DriftPolicy {
145 pub block_on_drift: bool,
146}
147
148impl DriftPolicy {
149 pub(crate) fn from_env() -> Self {
150 Self {
151 block_on_drift: std::env::var("LEAN_CTX_AGENT_BLOCK_ON_DRIFT")
152 .is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes")),
153 }
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct AgentRecord {
159 pub agent_id: String,
161 pub role: String,
163 pub owner: String,
165 pub status: AgentStatus,
166 pub created_at: String,
167 pub public_key: String,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
170 pub attestation: Option<Attestation>,
171 #[serde(skip_serializing_if = "Option::is_none", default)]
172 pub last_heartbeat: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none", default)]
174 pub suspended_reason: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub decommissioned_at: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize)]
182pub struct IdentityCheck {
183 pub agent_id: String,
184 pub registered: bool,
185 pub allowed: bool,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub status: Option<AgentStatus>,
189 pub drifted: bool,
192 pub detail: String,
193}
194
195fn registry_path() -> Result<PathBuf, String> {
196 let dir = crate::core::data_dir::lean_ctx_data_dir()
197 .map_err(|e| format!("data dir: {e}"))?
198 .join("agents");
199 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
200 Ok(dir.join("identity-registry.json"))
201}
202
203fn legacy_registry_path() -> Result<PathBuf, String> {
204 registry_path().map(|path| path.with_file_name("registry.json"))
205}
206
207fn load_unlocked(path: &PathBuf) -> Result<BTreeMap<String, AgentRecord>, String> {
208 let contents = match std::fs::read_to_string(path) {
209 Ok(contents) => contents,
210 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
211 return Ok(BTreeMap::new());
212 }
213 Err(error) => return Err(format!("read registry {}: {error}", path.display())),
214 };
215 serde_json::from_str(&contents)
216 .map_err(|error| format!("parse registry {}: {error}", path.display()))
217}
218
219fn load_registry(path: &PathBuf) -> Result<BTreeMap<String, AgentRecord>, String> {
220 if path.exists() {
221 return load_unlocked(path);
222 }
223 let legacy = legacy_registry_path()?;
224 match load_unlocked(&legacy) {
225 Ok(registry) => Ok(registry),
226 Err(identity_error) => {
227 let is_presence_registry = std::fs::read_to_string(&legacy)
232 .ok()
233 .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
234 .and_then(|value| value.get("agents").cloned())
235 .is_some_and(|agents| agents.is_array());
236 if is_presence_registry {
237 Ok(BTreeMap::new())
238 } else {
239 Err(identity_error)
240 }
241 }
242 }
243}
244
245fn load_registry_strict() -> Result<BTreeMap<String, AgentRecord>, String> {
248 let path = registry_path()?;
249 let path = if path.exists() {
250 path
251 } else {
252 legacy_registry_path()?
253 };
254 if !path.exists() {
255 return Ok(BTreeMap::new());
256 }
257 let contents = std::fs::read_to_string(&path)
258 .map_err(|error| format!("read durable identity registry: {error}"))?;
259 serde_json::from_str(&contents)
260 .map_err(|error| format!("durable identity registry is corrupt: {error}"))
261}
262
263fn with_registry<T>(
266 f: impl FnOnce(&mut BTreeMap<String, AgentRecord>) -> Result<T, String>,
267) -> Result<T, String> {
268 use fs2::FileExt;
269 use std::time::{Duration, Instant};
270
271 const LOCK_TIMEOUT: Duration = Duration::from_millis(750);
272 const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25);
273 let path = registry_path()?;
274 let lock_path = path.with_extension("lock");
275 let lock = std::fs::OpenOptions::new()
276 .create(true)
277 .truncate(false)
278 .write(true)
279 .open(&lock_path)
280 .map_err(|e| format!("registry lock: {e}"))?;
281 let deadline = Instant::now() + LOCK_TIMEOUT;
282 loop {
283 match lock.try_lock_exclusive() {
284 Ok(()) => break,
285 Err(error) if super::file_lock::is_contended(&error) && Instant::now() < deadline => {
286 std::thread::sleep(LOCK_RETRY_INTERVAL);
287 }
288 Err(error) if super::file_lock::is_contended(&error) => {
289 return Err(format!(
290 "registry lock timed out after {}ms; another agent operation is still active",
291 LOCK_TIMEOUT.as_millis()
292 ));
293 }
294 Err(error) => return Err(format!("registry lock: {error}")),
295 }
296 }
297
298 let mut registry = load_registry(&path)?;
299 let result = f(&mut registry);
300 let persisted = if result.is_ok() {
301 persist_registry(&path, ®istry)
302 } else {
303 Ok(())
304 };
305 let _ = FileExt::unlock(&lock);
306 persisted?;
307 result
308}
309
310fn persist_registry(
316 path: &PathBuf,
317 registry: &BTreeMap<String, AgentRecord>,
318) -> Result<(), String> {
319 use std::io::Write;
320
321 let json = serde_json::to_string_pretty(registry).map_err(|e| format!("serialize: {e}"))?;
322 let tmp = path.with_extension("json.tmp");
323 let written = (|| -> std::io::Result<()> {
324 let mut file = std::fs::File::create(&tmp)?;
325 file.write_all(json.as_bytes())?;
326 file.sync_all()
327 })();
328 if let Err(error) = written {
329 let _ = std::fs::remove_file(&tmp);
330 return Err(format!("persist registry: {error}"));
331 }
332 std::fs::rename(&tmp, path).map_err(|error| {
333 let _ = std::fs::remove_file(&tmp);
334 format!("persist registry: {error}")
335 })
336}
337
338#[cfg(test)]
340pub fn list() -> Vec<AgentRecord> {
341 try_list().unwrap_or_default()
342}
343
344pub fn try_list() -> Result<Vec<AgentRecord>, String> {
345 let path = registry_path()?;
346 Ok(load_registry(&path)?.into_values().collect())
347}
348
349#[cfg(test)]
352pub(crate) fn list_active() -> Vec<AgentRecord> {
353 list()
354 .into_iter()
355 .filter(|record| record.status == AgentStatus::Active)
356 .collect()
357}
358
359pub fn try_list_active() -> Result<Vec<AgentRecord>, String> {
360 Ok(try_list()?
361 .into_iter()
362 .filter(|record| record.status == AgentStatus::Active)
363 .collect())
364}
365
366#[cfg(test)]
367pub(crate) fn get(agent_id: &str) -> Option<AgentRecord> {
368 try_get(agent_id).ok().flatten()
369}
370
371pub fn try_get(agent_id: &str) -> Result<Option<AgentRecord>, String> {
372 let path = registry_path()?;
373 Ok(load_registry(&path)?.remove(agent_id))
374}
375
376pub(crate) fn require_active(agent_id: &str) -> Result<(), String> {
379 if agent_id.trim().is_empty() {
380 return Err("durable identity id must not be empty".to_string());
381 }
382 let registry = load_registry_strict()?;
383 let Some(record) = registry.get(agent_id) else {
384 return Err(format!("durable identity '{agent_id}' is not registered"));
385 };
386 match record.status {
387 AgentStatus::Active => Ok(()),
388 AgentStatus::Suspended => Err(format!("durable identity '{agent_id}' is suspended")),
389 AgentStatus::Decommissioned => {
390 Err(format!("durable identity '{agent_id}' is decommissioned"))
391 }
392 }
393}
394
395fn audit(event_type: AuditEventType, agent_id: &str, role: &str, detail: Option<String>) {
396 audit_trail::record(AuditEntryData {
397 agent_id: agent_id.to_string(),
398 tool: "agent_registry".to_string(),
399 action: detail,
400 input_hash: audit_trail::hash_input(&serde_json::Map::new()),
401 output_tokens: 0,
402 role: role.to_string(),
403 event_type,
404 });
405}
406
407fn binary_sha256() -> String {
418 use std::sync::{Mutex, OnceLock};
419 type HashCache = Mutex<Option<((u64, u64), String)>>;
421 static CACHE: OnceLock<HashCache> = OnceLock::new();
422
423 let Ok(exe) = std::env::current_exe() else {
424 return String::new();
425 };
426 let Ok(meta) = std::fs::metadata(&exe) else {
427 return String::new();
428 };
429 let mtime = meta
430 .modified()
431 .ok()
432 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
433 .map_or(0, |d| d.as_secs());
434 let key = (meta.len(), mtime);
435
436 let cache = CACHE.get_or_init(|| Mutex::new(None));
437 let mut slot = cache
438 .lock()
439 .unwrap_or_else(std::sync::PoisonError::into_inner);
440 if let Some((cached_key, hash)) = slot.as_ref()
441 && *cached_key == key
442 {
443 return hash.clone();
444 }
445 let hash = std::fs::read(&exe)
446 .map(|bytes| sha256_hex(&bytes))
447 .unwrap_or_default();
448 *slot = Some((key, hash.clone()));
449 hash
450}
451
452pub(crate) fn attest(role: &str) -> Attestation {
456 let binary_sha256 = binary_sha256();
457 let config_sha256 = role_file_path(role)
458 .and_then(|p| std::fs::read(p).ok())
459 .map(|bytes| sha256_hex(&bytes))
460 .unwrap_or_default();
461 Attestation {
462 binary_sha256,
463 config_sha256,
464 attested_at: chrono::Utc::now().to_rfc3339(),
465 drift: None,
466 }
467}
468
469fn diff_attestation(previous: &Attestation, fresh: &Attestation) -> DriftDimensions {
472 DriftDimensions {
473 binary: previous.binary_sha256 != fresh.binary_sha256,
474 config: previous.config_sha256 != fresh.config_sha256,
475 }
476}
477
478const EVIDENCE_DIGEST_PREFIX: usize = 12;
482
483fn digest_prefix(digest: &str) -> String {
487 if digest.is_empty() {
488 return "none".to_string();
489 }
490 digest
491 .chars()
492 .take(EVIDENCE_DIGEST_PREFIX)
493 .map(|c| if c.is_ascii_hexdigit() { c } else { '?' })
494 .collect()
495}
496
497fn drift_evidence(
502 previous: &Attestation,
503 fresh: &Attestation,
504 dimensions: DriftDimensions,
505) -> String {
506 use std::fmt::Write as _;
507
508 let mut evidence = format!("attestation drift dimensions={}", dimensions.label());
509 if dimensions.binary {
510 let _ = write!(
511 evidence,
512 " binary={}->{}",
513 digest_prefix(&previous.binary_sha256),
514 digest_prefix(&fresh.binary_sha256)
515 );
516 }
517 if dimensions.config {
518 let _ = write!(
519 evidence,
520 " config={}->{}",
521 digest_prefix(&previous.config_sha256),
522 digest_prefix(&fresh.config_sha256)
523 );
524 }
525 evidence
526}
527
528const EVIDENCE_MAX_CHARS: usize = 256;
532
533fn bounded_evidence(evidence: &str) -> String {
537 evidence
538 .chars()
539 .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
540 .take(EVIDENCE_MAX_CHARS)
541 .collect()
542}
543
544fn validate_drift_mark(mark: DriftMark) -> Result<DriftMark, String> {
545 let validated = bounded_evidence(&mark.evidence);
546 if validated.is_empty() || validated != mark.evidence {
547 return Err("stored drift evidence is invalid or oversized".to_string());
548 }
549 Ok(mark)
550}
551
552fn audit_drift(agent_id: &str, role: &str, evidence: &str) -> Result<String, String> {
555 audit_trail::try_record(AuditEntryData {
556 agent_id: agent_id.to_string(),
557 tool: "agent_registry".to_string(),
558 action: Some(evidence.to_string()),
559 input_hash: audit_trail::hash_input(&serde_json::Map::new()),
560 output_tokens: 0,
561 role: role.to_string(),
562 event_type: AuditEventType::AgentDriftDetected,
563 })
564 .map(|entry| entry.entry_hash)
565 .map_err(|error| format!("drift audit failed, attestation not updated: {error}"))
566}
567
568fn audit_drift_ack(agent_id: &str, role: &str, mark: &DriftMark) -> Result<(), String> {
574 let chain = audit_trail::verify_chain();
575 if !chain.source_present {
576 return Err("acknowledgement audit failed, audit trail unavailable".to_string());
577 }
578 if !chain.valid {
579 return Err(format!(
580 "acknowledgement audit failed, audit chain invalid at entry {}",
581 chain.first_invalid_at.unwrap_or(chain.total_entries)
582 ));
583 }
584 let evidence = bounded_evidence(&mark.evidence);
585 if evidence.is_empty() || evidence != mark.evidence {
586 return Err("acknowledgement evidence on record is invalid or oversized".to_string());
587 }
588 audit_trail::try_record(AuditEntryData {
589 agent_id: agent_id.to_string(),
590 tool: "agent_registry".to_string(),
591 action: Some(evidence),
592 input_hash: audit_trail::hash_input(&serde_json::Map::new()),
593 output_tokens: 0,
594 role: role.to_string(),
595 event_type: AuditEventType::AgentDriftAcknowledged,
596 })
597 .map(|_| ())
598 .map_err(|error| format!("acknowledgement audit failed, drift not cleared: {error}"))
599}
600
601fn drift_from_audit(agent_id: &str) -> Result<Option<DriftMark>, String> {
602 let Some((detected_at, evidence)) = audit_trail::latest_unacknowledged_drift(agent_id)? else {
603 return Ok(None);
604 };
605 let validated = bounded_evidence(&evidence);
606 if validated.is_empty() || validated != evidence {
607 return Err("audit drift evidence is invalid or oversized".to_string());
608 }
609 let dimensions = evidence
610 .split_whitespace()
611 .find_map(|part| part.strip_prefix("dimensions="))
612 .unwrap_or("none");
613 Ok(Some(DriftMark {
614 detected_at,
615 binary: matches!(dimensions, "binary" | "binary+config"),
616 config: matches!(dimensions, "config" | "binary+config"),
617 evidence,
618 }))
619}
620
621fn role_file_path(role: &str) -> Option<PathBuf> {
622 let dir = crate::core::data_dir::lean_ctx_data_dir()
623 .ok()?
624 .join("roles");
625 let path = dir.join(format!("{role}.toml"));
626 path.exists().then_some(path)
627}
628
629fn sha256_hex(bytes: &[u8]) -> String {
630 use sha2::{Digest, Sha256};
631 let mut hasher = Sha256::new();
632 hasher.update(bytes);
633 crate::core::agent_identity::hex_encode(&hasher.finalize())
634}
635
636pub fn register(agent_id: &str, role: &str, owner: &str) -> Result<AgentRecord, String> {
640 if agent_id.trim().is_empty()
641 || !agent_id
642 .chars()
643 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
644 {
645 return Err("agent_id must be non-empty [A-Za-z0-9_-]".to_string());
646 }
647 if owner.trim().is_empty() {
648 return Err(
649 "owner is mandatory — every agent identity has a human accountable for it".to_string(),
650 );
651 }
652 if crate::core::roles::load_role(role).is_none() {
653 return Err(format!(
654 "role '{role}' does not exist (see `lean-ctx roles list`)"
655 ));
656 }
657
658 let public_key = crate::core::agent_identity::get_public_key(agent_id)
659 .map(|k| crate::core::agent_identity::hex_encode(k.as_bytes()))
660 .map_err(|e| format!("keypair: {e}"))?;
661
662 let record = AgentRecord {
663 agent_id: agent_id.to_string(),
664 role: role.to_string(),
665 owner: owner.trim().to_string(),
666 status: AgentStatus::Active,
667 created_at: chrono::Utc::now().to_rfc3339(),
668 public_key,
669 attestation: Some(attest(role)),
670 last_heartbeat: None,
671 suspended_reason: None,
672 decommissioned_at: None,
673 };
674
675 with_registry(|reg| {
676 if reg.contains_key(agent_id) {
677 return Err(format!("agent '{agent_id}' is already registered"));
678 }
679 reg.insert(agent_id.to_string(), record.clone());
680 Ok(())
681 })?;
682 audit(
683 AuditEventType::AgentRegistered,
684 agent_id,
685 role,
686 Some(format!("owner={}", record.owner)),
687 );
688 Ok(record)
689}
690
691pub fn heartbeat(agent_id: &str) -> Result<HeartbeatOutcome, String> {
718 with_registry(|reg| {
719 let record = reg
720 .get_mut(agent_id)
721 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
722 if record.status == AgentStatus::Decommissioned {
723 return Err(format!("agent '{agent_id}' is decommissioned"));
724 }
725 let stored_drift = record
726 .attestation
727 .as_ref()
728 .and_then(|attestation| attestation.drift.clone())
729 .map(validate_drift_mark)
730 .transpose()?;
731 let recovered_drift = match drift_from_audit(agent_id) {
737 Ok(drift) => drift,
738 Err(error)
739 if record.attestation.is_none()
740 && record.last_heartbeat.is_none()
741 && error == "audit trail unavailable" =>
742 {
743 None
744 }
745 Err(error) => return Err(error),
746 };
747 let mut fresh = attest(&record.role);
748 let observed = record.attestation.as_ref().and_then(|previous| {
749 let dimensions = diff_attestation(previous, &fresh);
750 dimensions
751 .any()
752 .then(|| (dimensions, drift_evidence(previous, &fresh, dimensions)))
753 });
754
755 match &observed {
756 Some((dimensions, evidence)) => {
757 let observation_id = audit_drift(agent_id, &record.role, evidence)?;
758 fresh.drift = Some(DriftMark {
759 detected_at: fresh.attested_at.clone(),
760 binary: dimensions.binary,
761 config: dimensions.config,
762 evidence: format!("{evidence} observation={observation_id}"),
763 });
764 }
765 None => {
767 fresh.drift = recovered_drift.or(stored_drift);
768 }
769 }
770
771 record.last_heartbeat = Some(fresh.attested_at.clone());
772 let outcome = HeartbeatOutcome {
773 drift: fresh.drift.clone(),
774 newly_observed: observed.is_some(),
775 };
776 record.attestation = Some(fresh);
777 Ok(outcome)
778 })
779}
780
781pub fn suspend(agent_id: &str, reason: &str) -> Result<(), String> {
782 let role = transition(agent_id, AgentStatus::Suspended, Some(reason.to_string()))?;
783 audit(
784 AuditEventType::AgentSuspended,
785 agent_id,
786 &role,
787 Some(reason.to_string()),
788 );
789 Ok(())
790}
791
792pub fn resume(agent_id: &str) -> Result<(), String> {
799 let role = transition(agent_id, AgentStatus::Active, None)?;
800 audit(AuditEventType::AgentResumed, agent_id, &role, None);
801 Ok(())
802}
803
804pub fn acknowledge_drift(
823 agent_id: &str,
824 observed_evidence: &str,
825) -> Result<DriftAcknowledgement, String> {
826 let observed = observed_evidence.to_string();
827 with_registry(|reg| {
828 let record = reg
829 .get_mut(agent_id)
830 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
831 if record.status == AgentStatus::Decommissioned {
832 return Err(format!(
833 "agent '{agent_id}' is decommissioned — identities are never reactivated"
834 ));
835 }
836 let role = record.role.clone();
837 let mark = drift_from_audit(agent_id)?.ok_or_else(|| {
838 format!("agent '{agent_id}' has no audited unacknowledged attestation drift")
839 })?;
840 if mark.evidence != observed {
841 return Err(format!(
842 "acknowledgement does not match the drift on record — \
843 you acknowledged: {observed} — current: {} (detected at {}). \
844 Re-review and acknowledge the current evidence.",
845 mark.evidence, mark.detected_at
846 ));
847 }
848
849 audit_drift_ack(agent_id, &role, &mark)?;
850 if let Some(attestation) = record.attestation.as_mut() {
851 attestation.drift = None;
852 }
853 Ok(DriftAcknowledgement {
854 agent_id: agent_id.to_string(),
855 evidence: mark.evidence,
856 detected_at: mark.detected_at,
857 acknowledged_at: chrono::Utc::now().to_rfc3339(),
858 })
859 })
860}
861
862pub fn decommission(agent_id: &str) -> Result<(), String> {
865 let role = with_registry(|reg| {
866 let record = reg
867 .get_mut(agent_id)
868 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
869 record.status = AgentStatus::Decommissioned;
870 record.decommissioned_at = Some(chrono::Utc::now().to_rfc3339());
871 Ok(record.role.clone())
872 })?;
873 audit(
874 AuditEventType::AgentDecommissioned,
875 agent_id,
876 &role,
877 Some("audit-closing entry".to_string()),
878 );
879 Ok(())
880}
881
882fn transition(agent_id: &str, to: AgentStatus, reason: Option<String>) -> Result<String, String> {
883 with_registry(|reg| {
884 let record = reg
885 .get_mut(agent_id)
886 .ok_or_else(|| format!("agent '{agent_id}' is not registered"))?;
887 if record.status == AgentStatus::Decommissioned {
888 return Err(format!(
889 "agent '{agent_id}' is decommissioned — identities are never reactivated"
890 ));
891 }
892 record.status = to;
893 record.suspended_reason = reason;
894 Ok(record.role.clone())
895 })
896}
897
898pub fn suspend_agents_for_owner(owner: &str, reason: &str) -> Result<Vec<String>, String> {
901 let suspended = with_registry(|reg| {
902 let mut hit = Vec::new();
903 for record in reg.values_mut() {
904 if record.owner == owner && record.status == AgentStatus::Active {
905 record.status = AgentStatus::Suspended;
906 record.suspended_reason = Some(reason.to_string());
907 hit.push((record.agent_id.clone(), record.role.clone()));
908 }
909 }
910 Ok(hit)
911 })?;
912 for (agent_id, role) in &suspended {
913 audit(
914 AuditEventType::AgentSuspended,
915 agent_id,
916 role,
917 Some(format!("owner offboarded: {reason}")),
918 );
919 }
920 Ok(suspended.into_iter().map(|(id, _)| id).collect())
921}
922
923pub fn check(agent_id: &str) -> IdentityCheck {
930 check_with_policy(agent_id, DriftPolicy::from_env())
931}
932
933pub(crate) fn check_with_policy(agent_id: &str, policy: DriftPolicy) -> IdentityCheck {
937 let record = match try_get(agent_id) {
938 Ok(record) => record,
939 Err(error) => {
940 return IdentityCheck {
941 agent_id: agent_id.to_string(),
942 registered: false,
943 allowed: false,
944 status: None,
945 drifted: false,
946 detail: format!("denied, registry unavailable: {error}"),
947 };
948 }
949 };
950 match record {
951 None => IdentityCheck {
952 agent_id: agent_id.to_string(),
953 registered: false,
954 allowed: false,
955 status: None,
956 drifted: false,
957 detail: "not registered — register with `lean-ctx agent register`".to_string(),
958 },
959 Some(record) => {
960 let stored_drift = record
961 .attestation
962 .as_ref()
963 .and_then(|attestation| attestation.drift.clone())
964 .map(validate_drift_mark)
965 .transpose();
966 let (drift, audit_error) = match drift_from_audit(agent_id) {
967 Ok(audit_drift) => match stored_drift {
968 Ok(stored) => (audit_drift.or(stored), None),
969 Err(error) => (audit_drift, Some(error)),
970 },
971 Err(error) => (stored_drift.unwrap_or(None), Some(error)),
972 };
973 let blocked_by_drift = policy.block_on_drift && drift.is_some();
974 let allowed =
975 record.status == AgentStatus::Active && !blocked_by_drift && audit_error.is_none();
976 IdentityCheck {
977 agent_id: agent_id.to_string(),
978 registered: true,
979 allowed,
980 status: Some(record.status),
981 drifted: drift.is_some() || audit_error.is_some(),
983 detail: match record.status {
984 AgentStatus::Active => match (drift.as_ref(), audit_error.as_ref()) {
985 (_, Some(error)) => {
986 format!("denied, audit state unavailable: {error}")
987 }
988 (Some(mark), _) if policy.block_on_drift => {
989 format!("denied, unacknowledged {}", mark.evidence)
990 }
991 (Some(mark), _) => format!(
992 "active, owner {} (advisory: unacknowledged {})",
993 record.owner, mark.evidence
994 ),
995 (None, None) => format!("active, owner {}", record.owner),
996 },
997 AgentStatus::Suspended => format!(
998 "suspended: {}",
999 record.suspended_reason.as_deref().unwrap_or("no reason")
1000 ),
1001 AgentStatus::Decommissioned => "decommissioned".to_string(),
1002 },
1003 }
1004 }
1005 }
1006}
1007
1008pub fn spiffe_id(record: &AgentRecord, trust_domain: &str) -> String {
1011 format!(
1012 "spiffe://{}/agent/{}/{}",
1013 trust_domain.trim_matches('/'),
1014 record.role,
1015 record.agent_id
1016 )
1017}
1018
1019#[cfg(test)]
1020#[path = "agent_registry_tests.rs"]
1021mod tests;
1022
1023#[cfg(test)]
1025#[path = "agent_registry_drift_tests.rs"]
1026mod drift_tests;