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/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("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
87/// Run `f` over the registry under an exclusive cross-process lock and
88/// persist the result.
89fn 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(&registry).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
115/// Read-only registry snapshot.
116pub 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
140/// SHA-256 of the running binary, cached by (len, mtime) — heartbeats may
141/// fire every minute and the binary is large; re-hashing is only needed
142/// when the file on disk actually changed (which is exactly the drift
143/// signal we care about, and it changes the mtime).
144fn binary_sha256() -> String {
145    use std::sync::{Mutex, OnceLock};
146    /// (binary len, binary mtime secs) → hex digest.
147    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        && *cached_key == key
169    {
170        return hash.clone();
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
179/// Best-effort attestation: hash the running binary and the role file.
180/// Detects drift; does NOT stop a determined attacker who controls the
181/// host (documented in docs/enterprise/agent-identity.md).
182pub 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    crate::core::agent_identity::hex_encode(&hasher.finalize())
208}
209
210/// Register a new agent identity. The role must exist; the owner is
211/// mandatory (accountability). Creating an identity also provisions its
212/// Ed25519 keypair.
213pub 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
265/// Heartbeat: liveness + re-attestation. Returns drift against the
266/// registration-time attestation, if any.
267pub 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
307/// Decommission closes the identity with a final audit entry; the record
308/// stays in the registry (auditability) but can never act again.
309pub 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
343/// Owner offboarding (SCIM `active=false` hook, GL #399): suspend every
344/// active agent owned by `owner`. Returns the suspended agent ids.
345pub 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
368/// Identity check for enforce paths (team-server middleware): registered
369/// AND active. Unregistered agents are reported (monitor mode logs,
370/// enforce mode rejects — the caller decides).
371pub 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
400/// SPIFFE-compatible workload identity:
401/// `spiffe://<trust_domain>/agent/<role>/<agent_id>`.
402pub 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    /// Per-test registry isolation (GL #556): lock + fresh data dir,
416    /// env restored on drop even when the test panics.
417    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}