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