Skip to main content

lean_ctx/core/
agent_identity.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7/// Canonical resolver for the current agent identity. Reads `LEAN_CTX_AGENT_ID`
8/// (or legacy `LCTX_AGENT_ID`), falling back to `"local"`. Resolved once per
9/// process and cached, so all subsystems (heatmap, savings ledger, audit)
10/// attribute traces to the same identity.
11#[must_use]
12pub fn current_agent_id() -> &'static str {
13    static CACHE: OnceLock<String> = OnceLock::new();
14    CACHE.get_or_init(|| {
15        std::env::var("LEAN_CTX_AGENT_ID")
16            .or_else(|_| std::env::var("LCTX_AGENT_ID"))
17            .unwrap_or_else(|_| "local".to_string())
18    })
19}
20
21/// The connected client of this single-client MCP process, set once at
22/// `initialize` (see [`set_session_attribution`]).
23static SESSION_ATTRIBUTION: OnceLock<String> = OnceLock::new();
24/// Set when a second, different client initialized in the same process: the
25/// process-wide attribution is then ambiguous and is no longer used.
26static SESSION_ATTRIBUTION_CONFLICT: std::sync::atomic::AtomicBool =
27    std::sync::atomic::AtomicBool::new(false);
28
29/// Attribution for savings-ledger events (`SavingsEvent::agent_id`), so
30/// corrections and savings can be told apart per agent instead of all
31/// reading `"local"`.
32///
33/// Priority (first available wins):
34/// 1. `LEAN_CTX_AGENT_ID` / `LCTX_AGENT_ID` — explicit operator identity
35///    (same value as [`current_agent_id`])
36/// 2. this process's MCP session: `<client>:<bus agent id>`, e.g.
37///    `claude-code:mcp-1274-…`, set at `initialize` for single-client servers
38/// 3. the host the process runs under (`claude`, `codex`, `cursor`) — hook and
39///    CLI processes spawned by an agent
40/// 4. `"local"`
41///
42/// Only the attribution changes: signing keys stay bound to
43/// [`current_agent_id`], and `agent_id` is an ordinary hashed field in every
44/// canonical event version, so existing entries keep verifying unchanged.
45#[must_use]
46pub(crate) fn ledger_agent_id() -> String {
47    let explicit = current_agent_id();
48    if explicit != "local" {
49        return explicit.to_string();
50    }
51    if !SESSION_ATTRIBUTION_CONFLICT.load(std::sync::atomic::Ordering::Relaxed)
52        && let Some(session) = SESSION_ATTRIBUTION.get()
53    {
54        return session.clone();
55    }
56    host_from_env(|key| std::env::var(key).ok())
57        .unwrap_or("local")
58        .to_string()
59}
60
61/// Record the MCP client and local bus identity of this process. Called once
62/// per `initialize` by single-client (personal) servers; a later initialize
63/// with a different identity marks the attribution ambiguous instead of
64/// silently re-labelling another client's events.
65pub fn set_session_attribution(client_name: &str, bus_agent_id: Option<&str>) {
66    let Some(value) = session_attribution_value(client_name, bus_agent_id) else {
67        return;
68    };
69    if let Err(value) = SESSION_ATTRIBUTION.set(value)
70        && SESSION_ATTRIBUTION.get() != Some(&value)
71    {
72        SESSION_ATTRIBUTION_CONFLICT.store(true, std::sync::atomic::Ordering::Relaxed);
73    }
74}
75
76/// Bounded, ledger-safe `<client>:<bus agent id>` label.
77fn session_attribution_value(client_name: &str, bus_agent_id: Option<&str>) -> Option<String> {
78    let client = attribution_component(client_name, 48);
79    let agent = bus_agent_id.and_then(|id| attribution_component(id, 72));
80    match (client, agent) {
81        (Some(client), Some(agent)) => Some(format!("{client}:{agent}")),
82        (Some(single), None) | (None, Some(single)) => Some(single),
83        (None, None) => None,
84    }
85}
86
87/// Lowercase `[a-z0-9._-]`, other characters folded to `-`, at most `max`
88/// bytes; `None` when nothing meaningful remains.
89fn attribution_component(raw: &str, max: usize) -> Option<String> {
90    let folded: String = raw
91        .trim()
92        .chars()
93        .map(|c| {
94            let c = c.to_ascii_lowercase();
95            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
96                c
97            } else {
98                '-'
99            }
100        })
101        .take(max)
102        .collect();
103    let trimmed = folded.trim_matches('-');
104    (!trimmed.is_empty()).then(|| trimmed.to_string())
105}
106
107/// The agent host this process was spawned by, from the markers each host
108/// sets in its children's environment (also the CLI's "runs under an
109/// agent" check of `lean-ctx tasks`).
110pub fn host_from_env(env: impl Fn(&str) -> Option<String>) -> Option<&'static str> {
111    let set = |key: &str| env(key).is_some_and(|v| !v.trim().is_empty());
112    if set("CLAUDECODE") {
113        Some("claude")
114    } else if set("CODEX_THREAD_ID") {
115        Some("codex")
116    } else if set("CURSOR_TASK_ID") {
117        Some("cursor")
118    } else {
119        None
120    }
121}
122
123/// Identity of this process in the cross-agent delivery registry (#1904).
124///
125/// Unlike [`current_agent_id`] (a stable, shared attribution id), delivery
126/// needs one id per *consumer*: the registry skips records whose `agent_id`
127/// matches the requester, so two consumers sharing an id could never see each
128/// other's deliveries, and a consumer matching a foreign id would be treated as
129/// the reader. `CLAUDECODE` is the constant `"1"` in every Claude Code process
130/// and was used verbatim before, which collapsed all Claude Code clients on a
131/// machine into a single "agent".
132///
133/// Priority (first non-empty wins):
134/// 1. `LEAN_CTX_AGENT_ID` / legacy `LCTX_AGENT_ID` — explicit operator identity,
135///    shared with [`current_agent_id`]
136/// 2. `CURSOR_TASK_ID` — Cursor's per-subagent id
137/// 3. `claude-{pid}` when `CLAUDECODE` is set
138/// 4. `codex-{pid}` when `CODEX_THREAD_ID` is set
139/// 5. `local-{pid}`
140///
141/// Resolved once per process, so the id is stable for the process lifetime.
142#[must_use]
143pub fn delivery_agent_id() -> &'static str {
144    static CACHE: OnceLock<String> = OnceLock::new();
145    CACHE.get_or_init(|| {
146        resolve_delivery_agent_id(|key| std::env::var(key).ok(), std::process::id())
147    })
148}
149
150/// Pure core of [`delivery_agent_id`]: every input explicit, so the priority
151/// matrix is testable without touching the process environment.
152fn resolve_delivery_agent_id(env: impl Fn(&str) -> Option<String>, pid: u32) -> String {
153    let non_empty = |key: &str| {
154        env(key)
155            .map(|v| v.trim().to_string())
156            .filter(|v| !v.is_empty())
157    };
158    if let Some(id) = non_empty("LEAN_CTX_AGENT_ID").or_else(|| non_empty("LCTX_AGENT_ID")) {
159        return id;
160    }
161    if let Some(task) = non_empty("CURSOR_TASK_ID") {
162        return task;
163    }
164    if non_empty("CLAUDECODE").is_some() {
165        return format!("claude-{pid}");
166    }
167    if non_empty("CODEX_THREAD_ID").is_some() {
168        return format!("codex-{pid}");
169    }
170    format!("local-{pid}")
171}
172
173pub fn get_or_create_keypair(agent_id: &str) -> Result<SigningKey, String> {
174    let path = key_path(agent_id)?;
175    if path.exists() {
176        load_key(&path)
177    } else {
178        generate_and_save(agent_id)
179    }
180}
181
182pub(crate) fn get_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
183    let key = get_or_create_keypair(agent_id)?;
184    Ok(key.verifying_key())
185}
186
187/// Signing consumers must not create authority as a side effect of a request.
188pub(crate) fn get_stored_signing_key(agent_id: &str) -> Result<SigningKey, String> {
189    load_key(&key_path(agent_id)?)
190}
191
192/// Resolve an existing trust key without creating identity state.
193pub fn get_stored_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
194    let bytes = read_key_file(&pub_key_path(agent_id)?, "trusted public key")?;
195    let bytes: [u8; 32] = bytes
196        .try_into()
197        .map_err(|_| "invalid public key file (expected 32 bytes)".to_string())?;
198    VerifyingKey::from_bytes(&bytes).map_err(|error| format!("invalid public key: {error}"))
199}
200
201/// Read-only compatibility entry point; never creates missing verifier state.
202pub(crate) fn load_public_key(agent_id: &str) -> Result<VerifyingKey, String> {
203    get_stored_public_key(agent_id)
204}
205pub(crate) fn sign_bytes(agent_id: &str, data: &[u8]) -> Result<Vec<u8>, String> {
206    let key = get_or_create_keypair(agent_id)?;
207    let sig = key.sign(data);
208    Ok(sig.to_bytes().to_vec())
209}
210
211/// Sign with an already-resolved keypair (no store access). Pair with
212/// [`get_or_create_keypair`] when the public key must be embedded in the
213/// payload *before* the signature is computed over it.
214#[must_use]
215pub fn sign_bytes_with(key: &SigningKey, data: &[u8]) -> Vec<u8> {
216    key.sign(data).to_bytes().to_vec()
217}
218
219pub fn verify_signature(public_key_bytes: &[u8], data: &[u8], signature_bytes: &[u8]) -> bool {
220    let pk_bytes: [u8; 32] = match public_key_bytes.try_into() {
221        Ok(b) => b,
222        Err(_) => return false,
223    };
224    let Ok(verifying_key) = VerifyingKey::from_bytes(&pk_bytes) else {
225        return false;
226    };
227    let sig_bytes: [u8; 64] = match signature_bytes.try_into() {
228        Ok(b) => b,
229        Err(_) => return false,
230    };
231    let signature = Signature::from_bytes(&sig_bytes);
232    verifying_key.verify(data, &signature).is_ok()
233}
234
235pub fn hex_encode(bytes: &[u8]) -> String {
236    use std::fmt::Write;
237    bytes.iter().fold(String::new(), |mut s, b| {
238        let _ = write!(s, "{b:02x}");
239        s
240    })
241}
242
243/// Reject malformed external signature material without slicing inside UTF-8.
244pub fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
245    if !s.len().is_multiple_of(2) {
246        return Err("odd-length hex string".to_string());
247    }
248    if !s.is_ascii() {
249        return Err("non-ASCII hex string".to_string());
250    }
251    (0..s.len())
252        .step_by(2)
253        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
254        .collect()
255}
256
257fn key_path(agent_id: &str) -> Result<PathBuf, String> {
258    validate_agent_id(agent_id)?;
259    let base = crate::core::data_dir::lean_ctx_data_dir()?;
260    Ok(base.join("keys").join(format!("{agent_id}.key")))
261}
262
263fn pub_key_path(agent_id: &str) -> Result<PathBuf, String> {
264    validate_agent_id(agent_id)?;
265    let base = crate::core::data_dir::lean_ctx_data_dir()?;
266    Ok(base.join("keys").join(format!("{agent_id}.pub")))
267}
268
269/// Validate the untrusted identity component before constructing any key path.
270/// IDs are deliberately a single bounded filename component: sanitizing path
271/// separators would turn an invalid identity into a different trusted identity.
272fn validate_agent_id(agent_id: &str) -> Result<(), String> {
273    if agent_id.is_empty()
274        || agent_id.len() > 128
275        || !agent_id.is_ascii()
276        || !agent_id.as_bytes()[0].is_ascii_alphanumeric()
277        || !agent_id
278            .bytes()
279            .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
280        || agent_id.as_bytes().windows(2).any(|window| window == b"..")
281    {
282        return Err("invalid agent identity id".to_string());
283    }
284    Ok(())
285}
286
287fn generate_and_save(agent_id: &str) -> Result<SigningKey, String> {
288    let mut seed = [0u8; 32];
289    getrandom::fill(&mut seed).map_err(|e| format!("CSPRNG unavailable: {e}"))?;
290    let signing_key = SigningKey::from_bytes(&seed);
291
292    let key_file = key_path(agent_id)?;
293    let pub_file = pub_key_path(agent_id)?;
294
295    if let Some(parent) = key_file.parent() {
296        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir keys: {e}"))?;
297    }
298
299    // Claim the key file atomically: of two first runs racing here, one key
300    // wins and both sign with it (an overwritten key would make the loser's
301    // signatures read as tampering).
302    let signing_key = match create_key_file(&key_file, &signing_key.to_bytes()) {
303        Ok(()) => signing_key,
304        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => load_settled_key(&key_file)?,
305        Err(e) => return Err(format!("write key: {e}")),
306    };
307
308    // Derived from the private key, so racing writers write the same bytes;
309    // the rename keeps a reader from seeing a partial file.
310    let tmp = pub_file.with_extension(format!("pub.{}.tmp", std::process::id()));
311    std::fs::write(&tmp, signing_key.verifying_key().to_bytes())
312        .and_then(|()| std::fs::rename(&tmp, &pub_file))
313        .map_err(|e| {
314            let _ = std::fs::remove_file(&tmp);
315            format!("write pub: {e}")
316        })?;
317
318    Ok(signing_key)
319}
320
321/// Create `path` only if it does not exist, owner-only from the first byte.
322fn create_key_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
323    use std::io::Write;
324    let mut options = std::fs::OpenOptions::new();
325    options.write(true).create_new(true);
326    #[cfg(unix)]
327    {
328        use std::os::unix::fs::OpenOptionsExt;
329        options.mode(0o600);
330    }
331    let mut file = options.open(path)?;
332    file.write_all(bytes)?;
333    file.sync_all()
334}
335
336/// The key another process just created: retried briefly while its bytes
337/// are still being written.
338fn load_settled_key(path: &Path) -> Result<SigningKey, String> {
339    let mut last = String::new();
340    for _ in 0..50 {
341        match load_key(path) {
342            Ok(key) => return Ok(key),
343            Err(e) => last = e,
344        }
345        std::thread::sleep(std::time::Duration::from_millis(5));
346    }
347    Err(last)
348}
349
350fn load_key(path: &Path) -> Result<SigningKey, String> {
351    let bytes = read_key_file(path, "key")?;
352    let arr: [u8; 32] = bytes
353        .try_into()
354        .map_err(|_| "invalid key file (expected 32 bytes)".to_string())?;
355    Ok(SigningKey::from_bytes(&arr))
356}
357
358/// Read one key file from a stable directory/file handle without following a
359/// symlink introduced between validation and the read. Unix uses an O_NOFOLLOW
360/// directory anchor plus openat; Windows uses a held directory handle and
361/// handle-relative NtCreateFile. Unsupported targets fail closed.
362fn read_key_file(path: &Path, label: &str) -> Result<Vec<u8>, String> {
363    let parent = path
364        .parent()
365        .ok_or_else(|| format!("{label} directory is unavailable"))?;
366
367    #[cfg(unix)]
368    {
369        use std::ffi::CString;
370        use std::io::Read;
371        use std::os::fd::{AsRawFd, FromRawFd};
372        use std::os::unix::ffi::OsStrExt;
373        use std::os::unix::fs::OpenOptionsExt;
374
375        let directory = std::fs::OpenOptions::new()
376            .read(true)
377            .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
378            .open(parent)
379            .map_err(|error| format!("open {label} directory without symlinks: {error}"))?;
380        if !directory
381            .metadata()
382            .map_err(|error| format!("inspect {label} directory: {error}"))?
383            .is_dir()
384        {
385            return Err(format!("{label} directory must be a directory"));
386        }
387        let file_name = path
388            .file_name()
389            .ok_or_else(|| format!("{label} filename is unavailable"))?;
390        let file_name = CString::new(file_name.as_bytes())
391            .map_err(|_| format!("{label} filename contains NUL"))?;
392        // SAFETY: the held directory fd and NUL-free filename are valid;
393        // O_NOFOLLOW rejects symlink traversal.
394        let fd = unsafe {
395            libc::openat(
396                directory.as_raw_fd(),
397                file_name.as_ptr(),
398                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
399            )
400        };
401        if fd < 0 {
402            return Err(format!(
403                "open {label} without symlinks: {}",
404                std::io::Error::last_os_error()
405            ));
406        }
407        // SAFETY: successful openat returned this uniquely-owned descriptor.
408        let file = unsafe { std::fs::File::from_raw_fd(fd) };
409        if !file
410            .metadata()
411            .map_err(|error| format!("inspect {label}: {error}"))?
412            .is_file()
413        {
414            return Err(format!("{label} must be a regular file"));
415        }
416        let mut bytes = Vec::new();
417        // Both stored Ed25519 keys have exactly 32 bytes. One excess byte lets
418        // the caller reject oversize input without reading an unbounded file.
419        file.take(33)
420            .read_to_end(&mut bytes)
421            .map_err(|error| format!("read {label}: {error}"))?;
422        Ok(bytes)
423    }
424
425    #[cfg(windows)]
426    {
427        use std::io::Read;
428        use std::os::windows::ffi::OsStrExt;
429        use std::os::windows::fs::MetadataExt;
430        use std::os::windows::io::FromRawHandle;
431        use windows_sys::Wdk::Storage::FileSystem::{
432            FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT,
433            FILE_SYNCHRONOUS_IO_NONALERT,
434        };
435        use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
436        use windows_sys::Win32::Storage::FileSystem::{
437            CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
438            FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES,
439            FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
440            SYNCHRONIZE,
441        };
442
443        const TRAVERSE_DIRECTORY_ACCESS: u32 =
444            FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE;
445        const READ_KEY_ACCESS: u32 = FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE;
446
447        let wide_parent = parent
448            .as_os_str()
449            .encode_wide()
450            .chain(std::iter::once(0))
451            .collect::<Vec<_>>();
452        // SAFETY: wide_parent is NUL-terminated and remains live through the call;
453        // security attributes and template handle are null.
454        let directory_handle = unsafe {
455            CreateFileW(
456                wide_parent.as_ptr(),
457                TRAVERSE_DIRECTORY_ACCESS,
458                FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
459                std::ptr::null(),
460                OPEN_EXISTING,
461                FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
462                std::ptr::null_mut(),
463            )
464        };
465        if directory_handle == INVALID_HANDLE_VALUE || directory_handle.is_null() {
466            let error = std::io::Error::last_os_error();
467            return Err(format!(
468                "open {label} directory without reparse points: {error}"
469            ));
470        }
471        // SAFETY: successful CreateFileW returned an owned handle.
472        let directory = unsafe { std::fs::File::from_raw_handle(directory_handle) };
473        let directory_metadata = directory
474            .metadata()
475            .map_err(|error| format!("inspect {label} directory: {error}"))?;
476        if directory_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
477            return Err(format!("{label} directory reparse point rejected"));
478        }
479        if !directory_metadata.is_dir() {
480            return Err(format!("{label} directory must be a directory"));
481        }
482
483        let file_name = path
484            .file_name()
485            .ok_or_else(|| format!("{label} filename is unavailable"))?
486            .encode_wide()
487            .collect::<Vec<_>>();
488        // NtCreateFile resolves this child against the held directory handle
489        // (RootDirectory), so a rename or replacement of the parent pathname
490        // cannot redirect the lookup. The shared opener adds OBJ_DONT_REPARSE;
491        // FILE_OPEN_REPARSE_POINT keeps the final reparse object unopened as a
492        // target so its handle attributes can be rejected below.
493        let file = leanctx_native_storage::windows_file::open_relative(
494            &directory,
495            &file_name,
496            READ_KEY_ACCESS,
497            FILE_OPEN,
498            FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
499        )
500        .map_err(|status| {
501            use leanctx_native_storage::windows_file::OpenError;
502            let reason = match status {
503                OpenError::Missing => "Missing (NotFound)",
504                OpenError::Reparse => "Reparse",
505                OpenError::Unsupported => "Unsupported",
506                OpenError::Failure | OpenError::Collision => "Failure",
507            };
508            format!("open {label} without reparse traversal: {reason}")
509        })?;
510        let metadata = file
511            .metadata()
512            .map_err(|error| format!("inspect {label}: {error}"))?;
513        if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
514            return Err(format!("{label} reparse point rejected"));
515        }
516        if !metadata.is_file() {
517            return Err(format!("{label} must be a regular file"));
518        }
519        let mut bytes = Vec::new();
520        // Stored Ed25519 keys have exactly 32 bytes; the excess byte lets the
521        // caller reject oversize input without reading an unbounded file.
522        file.take(33)
523            .read_to_end(&mut bytes)
524            .map_err(|error| format!("read {label}: {error}"))?;
525        Ok(bytes)
526    }
527
528    #[cfg(not(any(unix, windows)))]
529    {
530        let _ = (parent, path);
531        Err(format!(
532            "secure {label} reads are unavailable on this platform; refusing a race-prone key lookup"
533        ))
534    }
535}
536
537#[cfg(all(test, any(unix, windows)))]
538mod tests {
539    use super::*;
540
541    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
542        let owned: Vec<(String, String)> = pairs
543            .iter()
544            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
545            .collect();
546        move |key| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
547    }
548
549    #[test]
550    fn session_attribution_combines_client_and_bus_id() {
551        assert_eq!(
552            session_attribution_value("claude-code", Some("mcp-1274-fbba0c02")).as_deref(),
553            Some("claude-code:mcp-1274-fbba0c02")
554        );
555        assert_eq!(
556            session_attribution_value("Codex MCP Client", None).as_deref(),
557            Some("codex-mcp-client")
558        );
559        assert_eq!(
560            session_attribution_value("", Some("mcp-9-x")).as_deref(),
561            Some("mcp-9-x")
562        );
563        assert_eq!(session_attribution_value("  ", None), None);
564    }
565
566    #[test]
567    fn session_attribution_is_bounded_and_ledger_safe() {
568        let long = "x".repeat(500);
569        let value = session_attribution_value(&format!("{long}\n:/"), Some(&long)).unwrap();
570        assert!(value.len() <= 48 + 1 + 72, "{}", value.len());
571        assert!(
572            value
573                .chars()
574                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
575        );
576    }
577
578    #[test]
579    fn host_from_env_detects_agent_hosts() {
580        assert_eq!(
581            host_from_env(env_of(&[("CLAUDECODE", "1")])),
582            Some("claude")
583        );
584        assert_eq!(
585            host_from_env(env_of(&[("CODEX_THREAD_ID", "t-1")])),
586            Some("codex")
587        );
588        assert_eq!(
589            host_from_env(env_of(&[("CURSOR_TASK_ID", "task")])),
590            Some("cursor")
591        );
592        assert_eq!(host_from_env(env_of(&[("CLAUDECODE", " ")])), None);
593        assert_eq!(host_from_env(env_of(&[])), None);
594    }
595
596    #[test]
597    fn delivery_id_two_claude_processes_are_distinct() {
598        // #1904: both processes see CLAUDECODE=1; the id must still differ.
599        let env = env_of(&[("CLAUDECODE", "1")]);
600        let a = resolve_delivery_agent_id(&env, 100);
601        let b = resolve_delivery_agent_id(&env, 200);
602        assert_eq!(a, "claude-100");
603        assert_ne!(a, b, "two Claude Code clients must not share an identity");
604        assert_ne!(a, "1", "the constant CLAUDECODE value is not an identity");
605    }
606
607    #[test]
608    fn delivery_id_priority_matrix() {
609        let explicit = env_of(&[
610            ("LEAN_CTX_AGENT_ID", "ops-agent"),
611            ("CURSOR_TASK_ID", "t1"),
612            ("CLAUDECODE", "1"),
613        ]);
614        assert_eq!(resolve_delivery_agent_id(&explicit, 7), "ops-agent");
615        let legacy = env_of(&[("LCTX_AGENT_ID", "legacy"), ("CLAUDECODE", "1")]);
616        assert_eq!(resolve_delivery_agent_id(&legacy, 7), "legacy");
617        let cursor = env_of(&[("CURSOR_TASK_ID", "t1"), ("CLAUDECODE", "1")]);
618        assert_eq!(resolve_delivery_agent_id(&cursor, 7), "t1");
619        let codex = env_of(&[("CODEX_THREAD_ID", "th")]);
620        assert_eq!(resolve_delivery_agent_id(&codex, 7), "codex-7");
621        assert_eq!(resolve_delivery_agent_id(env_of(&[]), 7), "local-7");
622    }
623
624    #[test]
625    fn delivery_id_ignores_blank_values() {
626        let env = env_of(&[("LEAN_CTX_AGENT_ID", "  "), ("CURSOR_TASK_ID", "")]);
627        assert_eq!(resolve_delivery_agent_id(env, 9), "local-9");
628    }
629
630    #[test]
631    fn delivery_id_is_stable_within_process() {
632        assert_eq!(delivery_agent_id(), delivery_agent_id());
633    }
634    #[test]
635    fn sign_and_verify_roundtrip() {
636        let mut seed = [0u8; 32];
637        getrandom::fill(&mut seed).unwrap();
638        let key = SigningKey::from_bytes(&seed);
639        let data = b"test payload";
640        let sig = key.sign(data);
641
642        let pub_bytes = key.verifying_key().to_bytes();
643        assert!(verify_signature(&pub_bytes, data, &sig.to_bytes()));
644    }
645
646    #[test]
647    fn verify_rejects_tampered_data() {
648        let mut seed = [0u8; 32];
649        getrandom::fill(&mut seed).unwrap();
650        let key = SigningKey::from_bytes(&seed);
651        let sig = key.sign(b"original");
652
653        let pub_bytes = key.verifying_key().to_bytes();
654        assert!(!verify_signature(&pub_bytes, b"tampered", &sig.to_bytes()));
655    }
656
657    #[test]
658    fn hex_roundtrip() {
659        let data = vec![0xde, 0xad, 0xbe, 0xef];
660        let encoded = hex_encode(&data);
661        assert_eq!(encoded, "deadbeef");
662        let decoded = hex_decode(&encoded).unwrap();
663        assert_eq!(decoded, data);
664    }
665
666    #[test]
667    fn hex_decode_accepts_all_bytes_and_rejects_malformed_text() {
668        let bytes: Vec<u8> = (0..=255).collect();
669        let encoded = hex_encode(&bytes);
670        assert_eq!(hex_decode(&encoded).unwrap(), bytes);
671        assert_eq!(hex_decode(&encoded.to_uppercase()).unwrap(), bytes);
672        assert_eq!(hex_decode("").unwrap(), Vec::<u8>::new());
673        for malformed in ["0", "gg", " 0", "\u{1f512}", "0\u{20ac}", "\u{e9}\u{e9}"] {
674            assert!(hex_decode(malformed).is_err(), "{malformed:?}");
675        }
676        assert_eq!(hex_decode("0").unwrap_err(), "odd-length hex string");
677        assert_eq!(hex_decode("0\u{20ac}").unwrap_err(), "non-ASCII hex string");
678    }
679
680    #[test]
681    fn stored_public_key_is_read_only_and_ids_are_path_safe() {
682        let _isolated = crate::core::data_dir::isolated_data_dir();
683        assert!(get_stored_public_key("missing").is_err());
684        assert!(get_stored_public_key("../outside").is_err());
685        assert!(get_stored_public_key("/tmp/outside").is_err());
686        assert!(get_or_create_keypair("agent/child").is_err());
687
688        let expected = get_or_create_keypair("root-agent").unwrap().verifying_key();
689        assert_eq!(get_stored_public_key("root-agent").unwrap(), expected);
690    }
691
692    #[cfg(any(unix, windows))]
693    #[test]
694    fn stored_public_key_rejects_symlinks() {
695        let _isolated = crate::core::data_dir::isolated_data_dir();
696        let keys_dir = crate::core::data_dir::lean_ctx_data_dir()
697            .unwrap()
698            .join("keys");
699        std::fs::create_dir_all(&keys_dir).unwrap();
700        let outside = tempfile::tempdir().unwrap();
701        let outside_key = outside.path().join("outside.pub");
702        std::fs::write(&outside_key, [7_u8; 32]).unwrap();
703        create_file_symlink(&outside_key, &keys_dir.join("linked.pub"));
704        assert!(get_stored_public_key("linked").is_err());
705    }
706
707    #[cfg(any(unix, windows))]
708    #[test]
709    fn stored_public_key_rejects_traversal_absolute_and_symlink_paths() {
710        let _isolated = crate::core::data_dir::isolated_data_dir();
711        assert!(get_stored_public_key("../outside").is_err());
712        assert!(get_stored_public_key("/tmp/outside").is_err());
713        assert!(get_or_create_keypair("../outside").is_err());
714
715        let data_dir = crate::core::data_dir::lean_ctx_data_dir().unwrap();
716        let keys_dir = data_dir.join("keys");
717        std::fs::create_dir_all(&keys_dir).unwrap();
718        let outside = tempfile::tempdir().unwrap();
719        let outside_key = outside.path().join("outside.pub");
720        std::fs::write(
721            &outside_key,
722            SigningKey::from_bytes(&[19_u8; 32])
723                .verifying_key()
724                .to_bytes(),
725        )
726        .unwrap();
727        create_file_symlink(&outside_key, &keys_dir.join("linked.pub"));
728        assert!(get_stored_public_key("linked").is_err());
729        create_file_symlink(&outside_key, &keys_dir.join("linked.key"));
730        assert!(get_or_create_keypair("linked").is_err());
731
732        std::fs::remove_file(keys_dir.join("linked.pub")).unwrap();
733        std::fs::remove_file(keys_dir.join("linked.key")).unwrap();
734        std::fs::create_dir(keys_dir.join("directory.pub")).unwrap();
735        assert!(get_stored_public_key("directory").is_err());
736        std::fs::remove_dir(keys_dir.join("directory.pub")).unwrap();
737        std::fs::create_dir(keys_dir.join("directory.key")).unwrap();
738        assert!(get_or_create_keypair("directory").is_err());
739        std::fs::remove_dir(keys_dir.join("directory.key")).unwrap();
740
741        std::fs::remove_dir(&keys_dir).unwrap();
742        create_directory_symlink(outside.path(), &keys_dir);
743        assert!(get_stored_public_key("outside").is_err());
744    }
745
746    #[test]
747    fn agent_id_is_one_bounded_canonical_filename_component() {
748        assert!(validate_agent_id("agent-01.alpha").is_ok());
749        assert!(validate_agent_id("../outside").is_err());
750        assert!(validate_agent_id("agent/child").is_err());
751        assert!(validate_agent_id("agent\\child").is_err());
752        assert!(validate_agent_id("agent:key").is_err());
753        assert!(validate_agent_id("agent..child").is_err());
754        assert!(validate_agent_id(&"a".repeat(129)).is_err());
755    }
756
757    #[test]
758    fn stored_key_reads_are_bounded_and_reject_directories() {
759        let root = tempfile::tempdir().unwrap();
760        let path = root.path().join("key");
761        std::fs::write(&path, [7_u8; 32]).unwrap();
762        assert!(load_key(&path).is_ok());
763        std::fs::write(&path, vec![7_u8; 1024 * 1024]).unwrap();
764        assert_eq!(read_key_file(&path, "key").unwrap().len(), 33);
765        assert!(load_key(&path).is_err());
766
767        let directory = root.path().join("directory.key");
768        std::fs::create_dir(&directory).unwrap();
769        assert!(load_key(&directory).is_err());
770    }
771
772    #[cfg(unix)]
773    #[test]
774    fn stored_key_reads_reject_fifos_without_a_writer() {
775        use std::os::unix::ffi::OsStrExt;
776        let root = tempfile::tempdir().unwrap();
777        let fifo = root.path().join("fifo");
778        let raw = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
779        // SAFETY: NUL-free owned path inside this test's fresh directory.
780        assert_eq!(unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, 0);
781        assert!(load_key(&fifo).is_err());
782        assert!(load_key(&fifo.join("child")).is_err());
783    }
784
785    #[cfg(unix)]
786    fn create_file_symlink(target: &Path, link: &Path) {
787        std::os::unix::fs::symlink(target, link).unwrap();
788    }
789
790    #[cfg(windows)]
791    fn create_file_symlink(target: &Path, link: &Path) {
792        std::os::windows::fs::symlink_file(target, link).unwrap();
793    }
794
795    #[cfg(unix)]
796    fn create_directory_symlink(target: &Path, link: &Path) {
797        std::os::unix::fs::symlink(target, link).unwrap();
798    }
799
800    #[cfg(windows)]
801    fn create_directory_symlink(target: &Path, link: &Path) {
802        std::os::windows::fs::symlink_dir(target, link).unwrap();
803    }
804}