Skip to main content

lean_ctx/core/
agent_registry.rs

1//! First-class agent identities: registry + lifecycle (GL #433, H3 Epic D).
2//!
3//! An agent stops being an anonymous process with a role config and
4//! becomes a registered identity: stable `agent_id`, mandatory human
5//! `owner` (accountability principle — orphaned agents are the security
6//! hole of the agent era), lifecycle state, best-effort attestation and a
7//! SPIFFE-compatible identity string for workload-IAM integration.
8//!
9//! Storage: `<data_dir>/agents/identity-registry.json`, advisory-file-locked like
10//! the audit trail (multiple concurrent agent processes are LeanCTX's
11//! normal operating mode). Every lifecycle transition writes a
12//! tamper-evident audit entry (OCP Part 4, additive event types).
13
14use 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    /// SHA-256 of the running binary at registration/heartbeat time.
31    pub binary_sha256: String,
32    /// SHA-256 of the active role file (empty when the role is built-in).
33    pub config_sha256: String,
34    pub attested_at: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AgentRecord {
39    /// Stable identity (key of the registry).
40    pub agent_id: String,
41    /// Role name under `roles/*.toml` / built-ins.
42    pub role: String,
43    /// Human accountable for this agent — mandatory, never empty.
44    pub owner: String,
45    pub status: AgentStatus,
46    pub created_at: String,
47    /// Ed25519 public key (hex) bound to this identity.
48    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/// Outcome of an identity check on a call path (team server middleware,
60/// enforce mode).
61#[derive(Debug, Clone, Serialize)]
62pub struct IdentityCheck {
63    pub agent_id: String,
64    pub registered: bool,
65    /// Active = may act. Suspended/decommissioned/unregistered = may not.
66    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
100/// Run `f` over the registry under an exclusive cross-process lock and
101/// persist the result.
102fn 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(&registry).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
128/// Read-only registry snapshot.
129pub 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
153/// SHA-256 of the running binary, cached by (len, mtime) — heartbeats may
154/// fire every minute and the binary is large; re-hashing is only needed
155/// when the file on disk actually changed (which is exactly the drift
156/// signal we care about, and it changes the mtime).
157fn binary_sha256() -> String {
158    use std::sync::{Mutex, OnceLock};
159    /// (binary len, binary mtime secs) → hex digest.
160    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
192/// Best-effort attestation: hash the running binary and the role file.
193/// Detects drift; does NOT stop a determined attacker who controls the
194/// host (documented in docs/enterprise/agent-identity.md).
195pub 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
223/// Register a new agent identity. The role must exist; the owner is
224/// mandatory (accountability). Creating an identity also provisions its
225/// Ed25519 keypair.
226pub 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
278/// Heartbeat: liveness + re-attestation. Returns drift against the
279/// registration-time attestation, if any.
280pub 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
320/// Decommission closes the identity with a final audit entry; the record
321/// stays in the registry (auditability) but can never act again.
322pub 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
356/// Owner offboarding (SCIM `active=false` hook, GL #399): suspend every
357/// active agent owned by `owner`. Returns the suspended agent ids.
358pub 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
381/// Identity check for enforce paths (team-server middleware): registered
382/// AND active. Unregistered agents are reported (monitor mode logs,
383/// enforce mode rejects — the caller decides).
384pub 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
413/// SPIFFE-compatible workload identity:
414/// `spiffe://<trust_domain>/agent/<role>/<agent_id>`.
415pub 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    /// Per-test registry isolation (GL #556): lock + fresh data dir,
429    /// env restored on drop even when the test panics.
430    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}