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    /// Set when this attestation *replaced* a different one, i.e. drift was
36    /// observed and audited at `attested_at`. Sticky: heartbeat persists the
37    /// freshly observed state, so every later heartbeat is clean by
38    /// construction and auto-clearing would make `block_on_drift` expire
39    /// after a single beat. Cleared ONLY by
40    /// `lean-ctx agent ack-drift <agent-id> --evidence <evidence>`, which
41    /// binds the acknowledgement to the exact evidence the operator
42    /// reviewed. Resuming a suspended identity deliberately does not clear
43    /// it: un-suspending an identity after an unrelated incident must never
44    /// silently sign off attestation drift nobody looked at.
45    ///
46    /// `default` on purpose: a pre-Phase-18 registry has no `drift` key and
47    /// must keep round-tripping as "no unacknowledged drift".
48    #[serde(skip_serializing_if = "Option::is_none", default)]
49    pub drift: Option<DriftMark>,
50}
51
52/// Bounded, non-secret evidence of one observed drift, persisted next to
53/// the attestation and mirrored verbatim into the `AgentDriftDetected`
54/// audit entry.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct DriftMark {
57    pub detected_at: String,
58    /// The running binary changed.
59    pub binary: bool,
60    /// The active role file changed (independent of `binary`).
61    pub config: bool,
62    pub evidence: String,
63}
64
65/// Result of one heartbeat.
66///
67/// `drift` is the identity's *state* after the beat (sticky until
68/// acknowledged), `newly_observed` is the *transition*. Keeping them apart is
69/// the whole point: the monitoring signal must stay red on every beat while
70/// the drift is unacknowledged, but only the beat that first observed it may
71/// append an `AgentDriftDetected` entry — otherwise a one-minute heartbeat
72/// would write one audit entry per minute forever.
73#[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    /// Evidence of the unacknowledged drift, if any.
82    pub(crate) fn evidence(&self) -> Option<&str> {
83        self.drift.as_ref().map(|mark| mark.evidence.as_str())
84    }
85
86    /// Compatibility view for existing callers: `None` means this beat did
87    /// not observe a new transition, while [`Self::drift`] remains sticky.
88    pub(crate) fn is_none(&self) -> bool {
89        !self.newly_observed
90    }
91
92    /// Compatibility view for existing tests that extracted the newly
93    /// observed evidence from the old `Result<Option<String>, String>` shape.
94    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/// A completed, audited drift acknowledgement.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DriftAcknowledgement {
104    pub agent_id: String,
105    /// The evidence that was cleared — verbatim, as the operator saw it.
106    pub evidence: String,
107    pub detected_at: String,
108    pub acknowledged_at: String,
109}
110
111/// Which attestation dimensions changed. Two independent booleans on
112/// purpose: the previous `if / else if` chain reported binary drift and
113/// silently dropped a simultaneous role-config change.
114#[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    /// Stable, bounded label — the only place the two dimensions are joined.
126    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/// Whether unacknowledged attestation drift denies a `check`.
137///
138/// COMPATIBILITY DEFAULT: `block_on_drift = false`. Before this slice drift
139/// was advisory only (the heartbeat printed it, nothing acted on it), so the
140/// default keeps every existing caller's allow/deny bit identical and only
141/// enriches `detail`. Operators opt in with
142/// `LEAN_CTX_AGENT_BLOCK_ON_DRIFT=1|true|yes`.
143#[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    /// Stable identity (key of the registry).
160    pub agent_id: String,
161    /// Role name under `roles/*.toml` / built-ins.
162    pub role: String,
163    /// Human accountable for this agent — mandatory, never empty.
164    pub owner: String,
165    pub status: AgentStatus,
166    pub created_at: String,
167    /// Ed25519 public key (hex) bound to this identity.
168    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/// Outcome of an identity check on a call path (team server middleware,
180/// enforce mode).
181#[derive(Debug, Clone, Serialize)]
182pub struct IdentityCheck {
183    pub agent_id: String,
184    pub registered: bool,
185    /// Active = may act. Suspended/decommissioned/unregistered = may not.
186    pub allowed: bool,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub status: Option<AgentStatus>,
189    /// Unacknowledged attestation drift is recorded on the identity. Whether
190    /// it also flips `allowed` is the caller's `DriftPolicy` decision.
191    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            // `agents/registry.json` is also the MCP presence registry. Its
228            // versioned top-level `agents` array was never an identity map and
229            // must be left untouched rather than treated as corrupt legacy
230            // identity state.
231            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
245/// Read durable identities without interpreting malformed state as an empty
246/// registry. The legacy path is migration input only, never presence state.
247fn 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
263/// Run `f` over the registry under an exclusive cross-process lock and
264/// persist the result.
265fn 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, &registry)
302    } else {
303        Ok(())
304    };
305    let _ = FileExt::unlock(&lock);
306    persisted?;
307    result
308}
309
310/// Atomic replace: write a sibling temp file, fsync it, rename it over the
311/// registry. An interrupted in-place write left a truncated
312/// `identity-registry.json` behind, and a truncated registry parses as an
313/// *empty* one — silently resurrecting decommissioned identities. A failed
314/// write now leaves the previous snapshot byte-identical and drops the temp.
315fn 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/// Read-only registry snapshot.
339#[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/// Active identities are the peer view. Lifecycle history remains available
350/// through `list()` for audit and operator use.
351#[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
376/// Fail closed unless the explicitly bound durable identity currently exists
377/// and remains active.
378pub(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
407/// SHA-256 of the running binary, cached by (len, mtime) — heartbeats may
408/// fire every minute and the binary is large; re-hashing is only needed
409/// when the file on disk actually changed (which is exactly the drift
410/// signal we care about, and it changes the mtime).
411///
412/// KNOWN LIMIT: the cache key is metadata, not content. Inside a long-lived
413/// process (the MCP server), a swap that preserves both length and mtime
414/// (`touch -r`) is not re-hashed and evades the binary dimension until the
415/// process restarts. Attestation is a drift detector, not a defence against
416/// an attacker who already controls the host.
417fn binary_sha256() -> String {
418    use std::sync::{Mutex, OnceLock};
419    /// (binary len, binary mtime secs) → hex digest.
420    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
452/// Best-effort attestation: hash the running binary and the role file.
453/// Detects drift; does NOT stop a determined attacker who controls the
454/// host (documented in docs/enterprise/agent-identity.md).
455pub(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
469/// Compare two attestations per dimension. Both dimensions are evaluated
470/// unconditionally, so simultaneous binary+config drift reports *both*.
471fn 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
478/// Hex characters of each digest kept in the evidence. 48 bits is more than
479/// enough to tell two attestations apart in an audit review and keeps the
480/// evidence string bounded; the digests are public hashes, not secrets.
481const EVIDENCE_DIGEST_PREFIX: usize = 12;
482
483/// Bounded, deterministic, injection-proof rendering of one digest.
484/// Non-hex bytes from a hand-edited registry become `?`, so evidence can
485/// never smuggle a newline or a quote into the JSONL audit line.
486fn 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
497/// Deterministic drift evidence for the audit entry: the changed-dimension
498/// label plus, per changed dimension, the previous and freshly observed
499/// digest prefixes. No paths, no file contents, no owner data — and at most
500/// 110 characters regardless of what the registry holds.
501fn 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
528/// Upper bound on any evidence string that reaches an audit line. Matches
529/// what [`drift_evidence`] can produce; applied again on the way *out* of the
530/// registry because that file is operator-writable.
531const EVIDENCE_MAX_CHARS: usize = 256;
532
533/// Re-clamp evidence read back from the registry before it enters a JSONL
534/// audit line: control characters and quoting characters are dropped and the
535/// length is bounded, exactly as for freshly generated evidence.
536fn 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
552/// Append the typed drift event. Fallible on purpose: the caller must not
553/// commit the fresh attestation when the evidence could not be persisted.
554fn 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
568/// Append the typed acknowledgement event, naming the exact evidence being
569/// cleared. Fallible for the same reason as [`audit_drift`], in the opposite
570/// direction: an acknowledgement that cannot be recorded must not clear the
571/// mark, or the identity would be allowed again with nothing in the trail
572/// saying anyone signed it off.
573fn 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
636/// Register a new agent identity. The role must exist; the owner is
637/// mandatory (accountability). Creating an identity also provisions its
638/// Ed25519 keypair.
639pub 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
691/// Heartbeat: liveness + re-attestation.
692///
693/// Returns the identity's unacknowledged-drift STATE plus whether this beat
694/// is the one that observed it. Every beat reports sticky drift, so a monitor
695/// wired to this call cannot flip back to green one minute after the incident
696/// while `check` still denies; only the observing beat appends evidence.
697///
698/// The freshly observed attestation *replaces* the stored one, so the next
699/// heartbeat compares against reality instead of against registration time
700/// (drift used to be re-reported forever, and a rolled-back binary was
701/// indistinguishable from a fixed one).
702///
703/// Ordering is deliberate and is the fail-safe: the `AgentDriftDetected`
704/// entry is appended BEFORE the registry write, and `with_registry` persists
705/// only when this closure returns `Ok`. A failing audit sink therefore
706/// leaves the stored attestation and `last_heartbeat` untouched and the very
707/// same drift is re-detected on the next beat — never a silent state update
708/// that loses the evidence. The opposite failure (evidence durable, registry
709/// write fails) is safe by the same argument: the registry keeps its
710/// previous snapshot atomically and the next beat re-emits the same
711/// deterministic evidence.
712///
713/// Evidence and registry state are two separate files with no cross-file
714/// atomicity, so the guarantee is AT-LEAST-ONCE, never exactly-once: a crash
715/// between the fsynced append and the registry rename can duplicate an
716/// observation on the next beat. Losing one is what the ordering rules out.
717pub 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        // The append-only audit trail is canonical and is verified on every
732        // heartbeat for an attested identity, even when the registry already
733        // carries a sticky mark. A genuinely pre-attestation legacy record has
734        // no drift state to recover or mask, so its first heartbeat may create
735        // the initial attestation before an audit trail exists.
736        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            // Carry an unacknowledged mark forward — see `Attestation::drift`.
766            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
792/// Resume is a lifecycle transition ONLY: suspended → active. It deliberately
793/// does not touch the sticky [`DriftMark`] — un-suspending an identity after
794/// an unrelated incident must not silently sign off attestation drift the
795/// operator never saw, and a beat landing between review and resume must not
796/// get acknowledged along the way. Drift is cleared exclusively by
797/// [`acknowledge_drift`], which names the evidence.
798pub 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
804/// Explicit operator acknowledgement of ONE observed attestation drift.
805///
806/// `observed_evidence` must equal the evidence currently on the identity, so
807/// an acknowledgement can only ever clear the drift the operator actually
808/// reviewed: if a heartbeat observed a newer change in the meantime, the
809/// stored evidence differs and this fails with both strings, leaving the mark
810/// intact. The typed `AgentDriftAcknowledged` entry is appended BEFORE the
811/// registry mutation and inside the same registry lock, so a failing audit
812/// sink aborts the whole acknowledgement (`with_registry` persists only on
813/// `Ok`) rather than clearing the mark with nothing in the trail.
814///
815/// The `AgentDriftDetected` evidence stays in the trail — acknowledging never
816/// erases history.
817///
818/// The audit trail and registry snapshot are separate files: durability is
819/// AT-LEAST-ONCE, not exactly-once. A crash after the audit fsync but before
820/// the registry rename may require the operator to retry the same evidence;
821/// it can never turn an unrecorded acknowledgement into a cleared mark.
822pub 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
862/// Decommission closes the identity with a final audit entry; the record
863/// stays in the registry (auditability) but can never act again.
864pub 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
898/// Owner offboarding (SCIM `active=false` hook, GL #399): suspend every
899/// active agent owned by `owner`. Returns the suspended agent ids.
900pub 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
923/// Identity check for enforce paths (team-server middleware): registered
924/// AND active. Unregistered agents are reported (monitor mode logs,
925/// enforce mode rejects — the caller decides).
926///
927/// Drift policy comes from the environment; see `DriftPolicy` for the
928/// documented compatibility default (drift is reported, never blocking).
929pub fn check(agent_id: &str) -> IdentityCheck {
930    check_with_policy(agent_id, DriftPolicy::from_env())
931}
932
933/// [`check`] with an explicit drift decision — the deterministic entry point
934/// for callers that carry their own policy instead of reading the process
935/// environment.
936pub(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                // Evidence loss is an unknown/red state, never a green signal.
982                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
1008/// SPIFFE-compatible workload identity:
1009/// `spiffe://<trust_domain>/agent/<role>/<agent_id>`.
1010pub 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/// Adversarial attestation-drift coverage (Phase 18 S3), with access to private helpers.
1024#[cfg(test)]
1025#[path = "agent_registry_drift_tests.rs"]
1026mod drift_tests;