Skip to main content

tsafe_agent/
lib.rs

1//! tsafe Agent daemon — library entry point exposed for the tsafe meta-crate.
2//!
3//! All daemon logic is defined here so the meta-crate can link against it.
4//! `main.rs` is a thin shim that calls [`run`].
5//!
6//! The agent supports safe command execution and bound MCP workflows by keeping
7//! vault unlock state local to a named pipe or Unix socket. Agent Authority
8//! Firewall status can report whether the agent is reachable, but bound MCP
9//! tools still execute through contracts and do not receive secret material in
10//! model context.
11
12#[cfg(not(target_os = "windows"))]
13use std::collections::HashMap;
14use std::io::{BufRead, BufReader, Write};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17#[cfg(not(target_os = "windows"))]
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result};
22use zeroize::{Zeroize, ZeroizeOnDrop};
23
24#[cfg(not(target_os = "windows"))]
25use tsafe_core::agent::{cellos_socket_path, CellRecord, CellState, CellosRequest, CellosResponse};
26use tsafe_core::agent::{
27    clear_agent_sock, pipe_name, write_agent_sock, AgentRequest, AgentResponse, AgentSession,
28};
29#[cfg(not(target_os = "windows"))]
30use tsafe_core::audit::{AuditCellosContext, AuditContext, AuditEntry, AuditLog};
31use tsafe_core::profile;
32use tsafe_core::{keyring_store, vault::Vault};
33
34const HELP: &str = "\
35tsafe-agent — local session agent for tsafe
36
37Usage:
38  tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>
39
40This companion runtime is normally launched by `tsafe agent unlock`.
41It holds one profile's unlock state in memory for the configured TTL and serves
42same-user callers over a local named pipe or Unix socket. It does not send telemetry
43or analytics. It also does not send crash reports, update pings, secret names,
44or secret values to a vendor service.
45
46Local surfaces:
47  - named pipe or Unix socket IPC
48  - in-memory unlock state until idle or absolute TTL expiry
49  - per-profile local audit receipts written by the calling tsafe workflow
50
51Revoke:
52  tsafe agent lock
53";
54
55// ── Password holder — zeroed on drop ─────────────────────────────────────────
56
57#[derive(Zeroize, ZeroizeOnDrop)]
58struct Password(String);
59
60// ── CellOS cell cache ─────────────────────────────────────────────────────────
61
62#[cfg(not(target_os = "windows"))]
63type CellCache = Arc<Mutex<HashMap<String, CellState>>>;
64
65/// Lock the cell cache, recovering from poison if a previous holder panicked.
66///
67/// `cell_cache` is just a token store; partial-update panics cannot leave the
68/// `HashMap` in a structurally invalid state — at worst a single cell entry is
69/// half-written, which the next request will overwrite cleanly. Recovering is
70/// strictly preferable to the alternative (`.unwrap()`), which would propagate
71/// the original panic and kill the long-lived agent on every subsequent
72/// request.
73#[cfg(not(target_os = "windows"))]
74fn lock_cell_cache(
75    cell_cache: &CellCache,
76) -> std::sync::MutexGuard<'_, HashMap<String, CellState>> {
77    match cell_cache.lock() {
78        Ok(g) => g,
79        Err(poisoned) => poisoned.into_inner(),
80    }
81}
82
83/// Install process signal handlers for daemon lifecycle.
84///
85/// Without this, `kill <agent-pid>` (or a logout-time SIGTERM) terminates the
86/// process abruptly and leaves the unix socket file orphaned on disk —
87/// subsequent `tsafe agent unlock` runs then fail to bind. With the handler in
88/// place, the accept loop observes `stop` on its next iteration, returns from
89/// `serve()`, and the `SocketCleanup` RAII guard unlinks the socket file
90/// before the process exits.
91///
92/// SIGHUP is different: this is a background unlock daemon with explicit TTL
93/// and lock controls, so terminal hangup should not kill it immediately after
94/// the interactive launcher exits.
95#[cfg(not(target_os = "windows"))]
96fn install_signal_handlers(stop: Arc<AtomicBool>) -> std::io::Result<()> {
97    use signal_hook::consts::{SIGINT, SIGTERM};
98    use signal_hook::flag;
99    flag::register(SIGTERM, Arc::clone(&stop))?;
100    flag::register(SIGINT, Arc::clone(&stop))?;
101    // SAFETY: signal() is called during single-threaded daemon startup before
102    // request-serving threads are spawned. Ignoring SIGHUP is process-global
103    // and intentional for this short-lived TTL-bound background agent.
104    unsafe {
105        libc::signal(libc::SIGHUP, libc::SIG_IGN);
106    }
107    Ok(())
108}
109
110// ── Daemon entry ──────────────────────────────────────────────────────────────
111
112fn daemon_main() -> Result<()> {
113    let args: Vec<String> = std::env::args().collect();
114    if args
115        .iter()
116        .skip(1)
117        .any(|arg| arg == "--help" || arg == "-h")
118    {
119        print!("{HELP}");
120        return Ok(());
121    }
122    if args.len() != 6 {
123        eprintln!(
124            "usage: tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>"
125        );
126        std::process::exit(1);
127    }
128
129    let profile_name = &args[1];
130    let session_token = &args[2];
131    let requesting_pid: u32 = args[3].parse().context("invalid requesting_pid")?;
132    let idle_ttl_secs: u64 = args[4].parse().context("invalid idle_ttl_secs")?;
133    let absolute_ttl_secs: u64 = args[5].parse().context("invalid absolute_ttl_secs")?;
134
135    // Acquire the vault password: keychain → TSAFE_VAULT_PASSWORD env → interactive prompt.
136    #[cfg(not(target_os = "windows"))]
137    let raw_password = acquire_password(profile_name)?;
138    #[cfg(not(target_os = "windows"))]
139    let pw = Password(raw_password.clone());
140    #[cfg(target_os = "windows")]
141    let pw = Password(acquire_password(profile_name)?);
142
143    // Validate the password works before we start serving.
144    {
145        let path = profile::vault_path(profile_name);
146        Vault::open(&path, pw.0.as_bytes()).context("wrong password — agent will not start")?;
147    }
148
149    let agent_pid = std::process::id();
150    let pipe = pipe_name(agent_pid);
151
152    // Write the socket address to stdout so `tsafe agent unlock` can read it.
153    //
154    // IMPORTANT: this println is the agent→shell handshake. It MUST be consumed
155    // by `eval $(tsafe agent unlock)` so the token never lands in shell
156    // history. Anyone copy-pasting this output is exposing their session
157    // token. Do not change the format without updating the unlock-shell
158    // helper — the `KEY=PIPE::TOKEN` shape is load-bearing.
159    println!("TSAFE_AGENT_SOCK={pipe}::{session_token}");
160    let _ = std::io::stdout().flush();
161
162    // Persist the socket address so any tsafe invocation (not just the unlock
163    // shell) can find the running agent via the state file.
164    write_agent_sock(&format!("{pipe}::{session_token}"));
165
166    let stop = Arc::new(AtomicBool::new(false));
167    // Install SIGTERM/SIGINT handlers on unix so the accept loop drains and
168    // the SocketCleanup Drop guard unlinks the socket file. Without this, a
169    // `kill <agent-pid>` would orphan the socket and break the next unlock.
170    #[cfg(not(target_os = "windows"))]
171    install_signal_handlers(Arc::clone(&stop))
172        .context("failed to install SIGTERM/SIGINT handlers")?;
173    let absolute_deadline = Instant::now() + Duration::from_secs(absolute_ttl_secs);
174    let mut session =
175        AgentSession::new(session_token.to_string(), idle_ttl_secs, absolute_deadline);
176    spawn_expiry_watchdog(pipe.clone(), absolute_deadline, Arc::clone(&stop));
177
178    // Spawn the CellOS broker socket in a background thread (Unix only).
179    // The thread shares the vault password (Arc) and the revocation cache.
180    #[cfg(not(target_os = "windows"))]
181    {
182        let cell_cache: CellCache = Arc::new(Mutex::new(HashMap::new()));
183        let profile = profile_name.clone();
184        let shared_pw = Arc::new(raw_password);
185        let cache = Arc::clone(&cell_cache);
186        let stop_clone = Arc::clone(&stop);
187        std::thread::spawn(move || {
188            if let Err(e) = serve_cellos(&profile, shared_pw, cache, stop_clone) {
189                eprintln!("tsafe-agent: CellOS socket error: {e:#}");
190            }
191        });
192    }
193
194    serve(
195        &pipe,
196        &pw,
197        &mut session,
198        requesting_pid,
199        absolute_deadline,
200        stop,
201    )?;
202
203    // Remove the state file so stale sock addresses don't linger.
204    clear_agent_sock();
205
206    Ok(())
207}
208
209/// Launch the tsafe-agent daemon.
210///
211/// Parses `std::env::args()`, validates the vault password, binds the IPC
212/// socket/pipe, and serves requests until the TTL expires or a Lock request
213/// is received. Exits the process with code 1 on fatal error.
214pub fn run() {
215    if let Err(e) = daemon_main() {
216        eprintln!("tsafe-agent: {e:#}");
217        std::process::exit(1);
218    }
219}
220
221// ── Password acquisition ──────────────────────────────────────────────────────
222
223/// Acquire the vault password using a priority chain:
224///   1. OS keychain (macOS Keychain / Linux Secret Service)
225///   2. `TSAFE_VAULT_PASSWORD` env var — loud warning (ends up in /proc/self/environ)
226///   3. Interactive TTY prompt via rpassword
227fn acquire_password(profile: &str) -> Result<String> {
228    // 1. Try the OS keychain first (uses the same entry as `tsafe biometric enable`).
229    //
230    // We do NOT pre-check with `has_password`: on non-macOS the generic backend
231    // has no no-UI existence probe, so `has_password` performs a full
232    // `get_password` and `retrieve_password` performs a second one — surfacing
233    // as a double OS-keychain prompt for the user. `retrieve_password` already
234    // returns `Ok(None)` when the entry is absent, so a single call is
235    // sufficient and on every platform fires at most one keychain interaction.
236    match keyring_store::retrieve_password(profile) {
237        Ok(Some(pw)) => return Ok(pw),
238        Ok(None) => {}
239        Err(e) => eprintln!("tsafe-agent: keychain lookup failed: {e}; falling back"),
240    }
241
242    // 2. TSAFE_VAULT_PASSWORD env var — security risk, warn loudly.
243    if let Ok(env_pw) = std::env::var("TSAFE_VAULT_PASSWORD") {
244        eprintln!(
245            "tsafe-agent: WARNING — using TSAFE_VAULT_PASSWORD from environment. \
246             This value is visible in /proc/self/environ, `docker inspect`, and shell \
247             history. Use `tsafe biometric enable` to store the password in the OS \
248             keychain instead."
249        );
250        return Ok(env_pw);
251    }
252
253    // 3. Interactive prompt (original behaviour).
254    rpassword_read()
255}
256
257// ── CellOS socket server (Unix-only) ─────────────────────────────────────────
258
259/// Serve the CellOS broker socket at `cellos_socket_path()`.
260///
261/// Authentication: caller UID must match the daemon's own UID (SO_PEERCRED / getpeereid).
262/// Handles `Resolve` and `RevokeForCell` requests over newline-terminated JSON.
263#[cfg(not(target_os = "windows"))]
264fn serve_cellos(
265    profile: &str,
266    password: Arc<String>,
267    cell_cache: CellCache,
268    stop: Arc<AtomicBool>,
269) -> Result<()> {
270    use std::os::unix::net::UnixListener;
271
272    let sock_path = cellos_socket_path();
273    if let Some(parent) = sock_path.parent() {
274        std::fs::create_dir_all(parent)?;
275    }
276    let _ = std::fs::remove_file(&sock_path);
277    let listener = UnixListener::bind(&sock_path)
278        .with_context(|| format!("CellOS: failed to bind {}", sock_path.display()))?;
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::PermissionsExt;
282        let _ = std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600));
283    }
284    let _cleanup = SocketCleanup(sock_path.to_string_lossy().into_owned());
285
286    listener.set_nonblocking(true)?;
287    // SAFETY: getuid() is a leaf POSIX libc call that takes no arguments and
288    // reads/writes no user memory. Per POSIX it cannot fail and has no
289    // preconditions on the caller, so calling it is unconditionally sound.
290    let daemon_uid = unsafe { libc::getuid() };
291    let vault_path = profile::vault_path(profile);
292    let audit = AuditLog::new(&profile::audit_log_path(profile));
293
294    loop {
295        if stop.load(Ordering::Relaxed) {
296            break;
297        }
298        match listener.accept() {
299            Ok((stream, _)) => {
300                stream.set_nonblocking(false)?;
301                let cred = match unix_peer_credential(&stream) {
302                    Ok(c) => c,
303                    Err(e) => {
304                        eprintln!("tsafe-agent: CellOS: peer credential failed: {e}");
305                        continue;
306                    }
307                };
308                if cred.uid != daemon_uid {
309                    let resp = CellosResponse::Err {
310                        error: "uid mismatch".to_string(),
311                    };
312                    let mut w = &stream;
313                    let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
314                    continue;
315                }
316                handle_cellos_connection(
317                    &stream,
318                    cred.pid,
319                    &vault_path,
320                    &password,
321                    profile,
322                    &cell_cache,
323                    &audit,
324                );
325            }
326            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
327                std::thread::sleep(Duration::from_millis(200));
328            }
329            Err(e) => return Err(e.into()),
330        }
331    }
332    Ok(())
333}
334
335#[cfg(not(target_os = "windows"))]
336fn handle_cellos_connection(
337    stream: &std::os::unix::net::UnixStream,
338    peer_pid: u32,
339    vault_path: &std::path::Path,
340    password: &str,
341    profile: &str,
342    cell_cache: &CellCache,
343    audit: &AuditLog,
344) {
345    use std::io::BufRead;
346
347    let mut reader = BufReader::new(stream);
348    let mut line = String::new();
349    if reader.read_line(&mut line).unwrap_or(0) == 0 {
350        return;
351    }
352
353    let req: CellosRequest = match serde_json::from_str(line.trim()) {
354        Ok(r) => r,
355        Err(e) => {
356            let resp = CellosResponse::Err {
357                error: format!("bad request: {e}"),
358            };
359            let mut w = stream;
360            let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
361            return;
362        }
363    };
364
365    let resp = dispatch_cellos(
366        req, peer_pid, vault_path, password, profile, cell_cache, audit,
367    );
368    let mut w = stream;
369    let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
370}
371
372#[cfg(not(target_os = "windows"))]
373fn dispatch_cellos(
374    req: CellosRequest,
375    peer_pid: u32,
376    vault_path: &std::path::Path,
377    password: &str,
378    profile: &str,
379    cell_cache: &CellCache,
380    audit: &AuditLog,
381) -> CellosResponse {
382    match req {
383        CellosRequest::Resolve {
384            key,
385            cell_id,
386            ttl_seconds: _,
387            cell_token,
388        } => {
389            // Validate or register the cell.
390            {
391                let mut cache = lock_cell_cache(cell_cache);
392                match cache.get(&cell_id) {
393                    Some(CellState::Revoked) => {
394                        return CellosResponse::Err {
395                            error: "cell revoked".to_string(),
396                        };
397                    }
398                    Some(CellState::Active(record)) => {
399                        if !tsafe_core::crypto::ct_eq_str(&record.token, &cell_token) {
400                            return CellosResponse::Err {
401                                error: "cell_token mismatch".to_string(),
402                            };
403                        }
404                    }
405                    None => {
406                        // First Resolve for this cell — register it.
407                        cache.insert(
408                            cell_id.clone(),
409                            CellState::Active(CellRecord {
410                                pid: peer_pid,
411                                token: cell_token.clone(),
412                            }),
413                        );
414                    }
415                }
416            }
417
418            // Open vault and look up the secret.
419            let value = match Vault::open_read_only(vault_path, password.as_bytes()) {
420                Ok(v) => match v.get(&key) {
421                    Ok(s) => s.to_string(),
422                    Err(_) => {
423                        return CellosResponse::Err {
424                            error: format!("key not found: {key}"),
425                        };
426                    }
427                },
428                Err(e) => {
429                    return CellosResponse::Err {
430                        error: format!("vault error: {e}"),
431                    };
432                }
433            };
434
435            // Audit the resolve.
436            audit
437                .append(
438                    &AuditEntry::success(profile, "cellos-resolve", Some(&key)).with_context(
439                        AuditContext::from_cellos(AuditCellosContext {
440                            cellos_cell_id: cell_id,
441                            cell_token: Some(cell_token),
442                        }),
443                    ),
444                )
445                .ok();
446
447            CellosResponse::Value { value }
448        }
449
450        CellosRequest::RevokeForCell { cell_id } => {
451            {
452                let mut cache = lock_cell_cache(cell_cache);
453                cache.insert(cell_id.clone(), CellState::Revoked);
454            }
455
456            audit
457                .append(
458                    &AuditEntry::success(profile, "cellos-revoke", None).with_context(
459                        AuditContext::from_cellos(AuditCellosContext {
460                            cellos_cell_id: cell_id,
461                            cell_token: None,
462                        }),
463                    ),
464                )
465                .ok();
466
467            CellosResponse::Ok
468        }
469    }
470}
471
472// ── Named-pipe server ─────────────────────────────────────────────────────────
473
474#[cfg(target_os = "windows")]
475fn serve(
476    pipe: &str,
477    pw: &Password,
478    session: &mut AgentSession,
479    _requesting_pid: u32,
480    deadline: Instant,
481    stop: Arc<AtomicBool>,
482) -> Result<()> {
483    use std::fs::File;
484    use std::os::windows::io::FromRawHandle;
485
486    // Strip the leading \\.\pipe\ prefix — CreateNamedPipeW takes the full path.
487    let pipe_wide: Vec<u16> = pipe.encode_utf16().chain(std::iter::once(0)).collect();
488
489    loop {
490        if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
491            break;
492        }
493        let handle = unsafe { windows_create_named_pipe(&pipe_wide)? };
494
495        // ConnectNamedPipe with a timeout so we can check the deadline.
496        let connected = unsafe { windows_connect_with_timeout(handle, 5_000) };
497        if !connected {
498            unsafe { windows_close_handle(handle) };
499            continue;
500        }
501
502        let client_file = unsafe { File::from_raw_handle(handle as _) };
503        let mut reader = BufReader::new(&client_file);
504        let mut writer = &client_file;
505        handle_connection(
506            &mut reader,
507            &mut writer,
508            pw,
509            session,
510            Some(unsafe { windows_get_named_pipe_client_process_id(handle)? }),
511            &stop,
512        )?;
513    }
514
515    Ok(())
516}
517
518pub(crate) fn handle_connection(
519    reader: &mut impl BufRead,
520    writer: &mut impl Write,
521    pw: &Password,
522    session: &mut AgentSession,
523    peer_pid: Option<u32>,
524    stop: &Arc<AtomicBool>,
525) -> Result<()> {
526    let mut line = String::new();
527    if reader.read_line(&mut line).unwrap_or(0) == 0 {
528        return Ok(());
529    }
530
531    let req: AgentRequest = match serde_json::from_str(line.trim()) {
532        Ok(r) => r,
533        Err(e) => {
534            let resp = AgentResponse::Err {
535                reason: format!("bad request: {e}"),
536            };
537            let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
538            return Ok(());
539        }
540    };
541
542    let outcome = session.handle_request(&req, peer_pid, &pw.0, Instant::now());
543    if outcome.stop {
544        stop.store(true, Ordering::Relaxed);
545    }
546    let resp = outcome.response;
547
548    let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
549    Ok(())
550}
551
552pub(crate) fn spawn_expiry_watchdog(pipe: String, deadline: Instant, stop: Arc<AtomicBool>) {
553    std::thread::spawn(move || loop {
554        if stop.load(Ordering::Relaxed) {
555            return;
556        }
557
558        let now = Instant::now();
559        if now >= deadline {
560            stop.store(true, Ordering::Relaxed);
561            wake_listener(&pipe);
562            return;
563        }
564
565        std::thread::sleep((deadline - now).min(Duration::from_millis(200)));
566    });
567}
568
569#[cfg(target_os = "windows")]
570fn wake_listener(pipe: &str) {
571    let _ = windows_connect_pipe_client(pipe);
572}
573
574#[cfg(not(target_os = "windows"))]
575fn wake_listener(pipe: &str) {
576    let _ = std::os::unix::net::UnixStream::connect(pipe);
577}
578
579// ── Password prompt ───────────────────────────────────────────────────────────
580
581fn rpassword_read() -> Result<String> {
582    // The agent is always launched interactively (the user just approved the toast).
583    rpassword::prompt_password("Vault password: ").context("failed to read password")
584}
585
586// ── Windows FFI shims ─────────────────────────────────────────────────────────
587
588#[cfg(target_os = "windows")]
589mod ffi {
590    use std::ffi::c_void;
591
592    extern "system" {
593        pub fn CreateNamedPipeW(
594            name: *const u16,
595            open_mode: u32,
596            pipe_mode: u32,
597            max_instances: u32,
598            out_buf: u32,
599            in_buf: u32,
600            default_timeout: u32,
601            security: *mut c_void,
602        ) -> *mut c_void;
603        pub fn CreateFileW(
604            name: *const u16,
605            access: u32,
606            share: u32,
607            security: *mut c_void,
608            creation: u32,
609            flags: u32,
610            template: *mut c_void,
611        ) -> *mut c_void;
612        pub fn ConnectNamedPipe(pipe: *mut c_void, overlapped: *mut c_void) -> i32;
613        pub fn CloseHandle(handle: *mut c_void) -> i32;
614        pub fn GetNamedPipeClientProcessId(pipe: *mut c_void, client_process_id: *mut u32) -> i32;
615        #[allow(dead_code)]
616        pub fn WaitForSingleObject(handle: *mut c_void, ms: u32) -> u32;
617        // Build a self-relative SECURITY_DESCRIPTOR from an SDDL string so the
618        // pipe can be created with an explicit owner-only DACL instead of the
619        // implicit default-token DACL.
620        pub fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
621            string_security_descriptor: *const u16,
622            string_sd_revision: u32,
623            security_descriptor: *mut *mut c_void,
624            security_descriptor_size: *mut u32,
625        ) -> i32;
626        pub fn LocalFree(mem: *mut c_void) -> *mut c_void;
627    }
628
629    /// Minimal SECURITY_ATTRIBUTES layout (matches the Win32 ABI).
630    #[repr(C)]
631    pub struct SecurityAttributes {
632        pub n_length: u32,
633        pub lp_security_descriptor: *mut c_void,
634        pub b_inherit_handle: i32,
635    }
636}
637
638#[cfg(target_os = "windows")]
639unsafe fn windows_create_named_pipe(pipe_wide: &[u16]) -> Result<*mut std::ffi::c_void> {
640    use std::os::windows::ffi::OsStrExt;
641
642    // Build an explicit owner-only DACL instead of relying on the default-token
643    // DACL. SDDL: protected DACL ("P" — no inheritance), granting GENERIC_ALL
644    // ("GA") only to the object's OWNER ("OW", i.e. the creating user) and to
645    // the local SYSTEM account ("SY"). No ACE for Everyone/Users, so no other
646    // user — even an admin's non-elevated process on a shared host — can open
647    // the pipe and receive the session token.
648    const SDDL_REVISION_1: u32 = 1;
649    let sddl: Vec<u16> = std::ffi::OsStr::new("D:P(A;;GA;;;OW)(A;;GA;;;SY)")
650        .encode_wide()
651        .chain(std::iter::once(0))
652        .collect();
653
654    let mut psd: *mut std::ffi::c_void = std::ptr::null_mut();
655    let ok = ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW(
656        sddl.as_ptr(),
657        SDDL_REVISION_1,
658        &mut psd,
659        std::ptr::null_mut(),
660    );
661    if ok == 0 || psd.is_null() {
662        anyhow::bail!("failed to build named-pipe security descriptor");
663    }
664
665    let mut sa = ffi::SecurityAttributes {
666        n_length: std::mem::size_of::<ffi::SecurityAttributes>() as u32,
667        lp_security_descriptor: psd,
668        b_inherit_handle: 0,
669    };
670
671    // PIPE_ACCESS_DUPLEX = 3, PIPE_TYPE_BYTE | PIPE_WAIT = 0, 1 instance, 4096 bufs
672    let h = ffi::CreateNamedPipeW(
673        pipe_wide.as_ptr(),
674        3,    // PIPE_ACCESS_DUPLEX
675        0x00, // PIPE_TYPE_BYTE | PIPE_WAIT
676        1,    // 1 instance — only the approved process can connect
677        4096,
678        4096,
679        0,
680        &mut sa as *mut _ as *mut std::ffi::c_void,
681    );
682
683    // The descriptor is copied into the kernel object at creation; free our copy.
684    ffi::LocalFree(psd);
685
686    if h as isize == -1 || h.is_null() {
687        anyhow::bail!("CreateNamedPipeW failed");
688    }
689    Ok(h)
690}
691
692#[cfg(target_os = "windows")]
693unsafe fn windows_connect_with_timeout(handle: *mut std::ffi::c_void, _ms: u32) -> bool {
694    // ConnectNamedPipe blocks until a client connects or the handle is closed.
695    // A watchdog connection wakes the server when the session expires.
696    ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) != 0
697}
698
699#[cfg(target_os = "windows")]
700unsafe fn windows_close_handle(handle: *mut std::ffi::c_void) {
701    ffi::CloseHandle(handle);
702}
703
704#[cfg(target_os = "windows")]
705unsafe fn windows_get_named_pipe_client_process_id(handle: *mut std::ffi::c_void) -> Result<u32> {
706    let mut pid = 0u32;
707    if ffi::GetNamedPipeClientProcessId(handle, &mut pid) == 0 {
708        anyhow::bail!("GetNamedPipeClientProcessId failed");
709    }
710    Ok(pid)
711}
712
713#[cfg(target_os = "windows")]
714fn windows_connect_pipe_client(pipe: &str) -> Result<std::fs::File> {
715    use std::os::windows::ffi::OsStrExt;
716    use std::os::windows::io::FromRawHandle;
717
718    let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
719        .encode_wide()
720        .chain(std::iter::once(0))
721        .collect();
722
723    let handle = unsafe {
724        ffi::CreateFileW(
725            wide.as_ptr(),
726            0xC000_0000, // GENERIC_READ | GENERIC_WRITE
727            0,
728            std::ptr::null_mut(),
729            3,   // OPEN_EXISTING
730            128, // FILE_ATTRIBUTE_NORMAL
731            std::ptr::null_mut(),
732        )
733    };
734
735    if handle.is_null() || handle as isize == -1 {
736        anyhow::bail!("CreateFileW failed");
737    }
738
739    Ok(unsafe { std::fs::File::from_raw_handle(handle as _) })
740}
741
742// Unix domain socket server — mirrors the Windows named-pipe server above.
743#[cfg(not(target_os = "windows"))]
744fn serve(
745    pipe: &str,
746    pw: &Password,
747    session: &mut AgentSession,
748    _requesting_pid: u32,
749    deadline: Instant,
750    stop: Arc<AtomicBool>,
751) -> Result<()> {
752    use std::os::unix::net::UnixListener;
753
754    // Clean up stale socket from a previous crash.
755    let _ = std::fs::remove_file(pipe);
756
757    let listener =
758        UnixListener::bind(pipe).with_context(|| format!("failed to bind Unix socket: {pipe}"))?;
759
760    // Set socket file to owner-only (0600).
761    #[cfg(unix)]
762    {
763        use std::os::unix::fs::PermissionsExt;
764        let _ = std::fs::set_permissions(pipe, std::fs::Permissions::from_mode(0o600));
765    }
766
767    // Non-blocking accept with timeout so we can check deadline + PID liveness.
768    listener.set_nonblocking(true)?;
769
770    let _cleanup = SocketCleanup(pipe.to_string());
771
772    loop {
773        if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
774            break;
775        }
776        match listener.accept() {
777            Ok((stream, _)) => {
778                stream.set_nonblocking(false)?;
779                let mut reader = BufReader::new(&stream);
780                let mut writer = &stream;
781                handle_connection(
782                    &mut reader,
783                    &mut writer,
784                    pw,
785                    session,
786                    Some(unix_peer_pid(&stream)?),
787                    &stop,
788                )?;
789            }
790            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
791                std::thread::sleep(Duration::from_millis(200));
792            }
793            Err(e) => return Err(e.into()),
794        }
795    }
796
797    Ok(())
798}
799
800/// RAII guard to remove the socket file on exit (normal or panic).
801#[cfg(not(target_os = "windows"))]
802struct SocketCleanup(String);
803
804#[cfg(not(target_os = "windows"))]
805impl Drop for SocketCleanup {
806    fn drop(&mut self) {
807        let _ = std::fs::remove_file(&self.0);
808    }
809}
810
811#[cfg(unix)]
812struct PeerCredential {
813    pid: u32,
814    uid: u32,
815}
816
817/// Retrieve both the PID and UID of the connecting process in one syscall where possible.
818#[cfg(target_os = "linux")]
819fn unix_peer_credential(
820    stream: &std::os::unix::net::UnixStream,
821) -> std::io::Result<PeerCredential> {
822    use std::mem::size_of;
823    use std::os::fd::AsRawFd;
824    let fd = stream.as_raw_fd();
825    // SAFETY: `libc::ucred` is a plain POD struct (three integral fields) and
826    // zeroing it is a valid initialization for the `getsockopt(SO_PEERCRED)`
827    // out-parameter contract.
828    let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
829    let mut len = size_of::<libc::ucred>() as libc::socklen_t;
830    // SAFETY: `fd` is owned by `stream` (a `&UnixStream`) and remains valid for
831    // the duration of this call — the borrow holds the file descriptor open.
832    // The `optval` pointer addresses the local `cred` stack variable, which
833    // lives until the end of this function and is sized exactly
834    // `size_of::<libc::ucred>()` (matching the value passed via `&mut len`).
835    // `getsockopt` only writes through the pointer; the kernel will not
836    // exceed `len` bytes per the SO_PEERCRED contract.
837    let rc = unsafe {
838        libc::getsockopt(
839            fd,
840            libc::SOL_SOCKET,
841            libc::SO_PEERCRED,
842            &mut cred as *mut _ as *mut libc::c_void,
843            &mut len,
844        )
845    };
846    if rc != 0 {
847        return Err(std::io::Error::last_os_error());
848    }
849    Ok(PeerCredential {
850        pid: cred.pid as u32,
851        uid: cred.uid,
852    })
853}
854
855#[cfg(target_os = "macos")]
856fn unix_peer_credential(
857    stream: &std::os::unix::net::UnixStream,
858) -> std::io::Result<PeerCredential> {
859    use std::mem::size_of;
860    use std::os::fd::AsRawFd;
861    let fd = stream.as_raw_fd();
862    let mut uid: libc::uid_t = 0;
863    let mut gid: libc::gid_t = 0;
864    let mut pid: libc::pid_t = 0;
865    let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
866    // SAFETY: `fd` is owned by `stream` (a `&UnixStream`) and is valid for the
867    // entirety of this call. `&mut uid` and `&mut gid` point to local stack
868    // variables sized exactly as `getpeereid` requires (`uid_t` and `gid_t`
869    // respectively); the kernel writes one value to each and never reads them.
870    let rc_uid = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
871    // SAFETY: same `fd` lifetime invariant as above. The `optval` pointer
872    // addresses the local `pid` (a `libc::pid_t`), and `len` is initialized to
873    // `size_of::<libc::pid_t>()` so the kernel will write at most that many
874    // bytes per the LOCAL_PEERPID contract on Darwin.
875    let rc_pid = unsafe {
876        libc::getsockopt(
877            fd,
878            libc::SOL_LOCAL,
879            libc::LOCAL_PEERPID,
880            &mut pid as *mut _ as *mut libc::c_void,
881            &mut len,
882        )
883    };
884    if rc_uid != 0 {
885        return Err(std::io::Error::last_os_error());
886    }
887    if rc_pid != 0 {
888        return Err(std::io::Error::last_os_error());
889    }
890    Ok(PeerCredential {
891        pid: pid as u32,
892        uid: uid as u32,
893    })
894}
895
896#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
897fn unix_peer_credential(
898    _stream: &std::os::unix::net::UnixStream,
899) -> std::io::Result<PeerCredential> {
900    Err(std::io::Error::new(
901        std::io::ErrorKind::Unsupported,
902        "peer credentials unsupported on this platform",
903    ))
904}
905
906#[cfg(unix)]
907fn unix_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32> {
908    unix_peer_credential(stream)
909        .map(|c| c.pid)
910        .map_err(Into::into)
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    fn run_request(
918        req: AgentRequest,
919        peer_pid: Option<u32>,
920        absolute_deadline: Instant,
921    ) -> (AgentResponse, bool) {
922        let stop = Arc::new(AtomicBool::new(false));
923        let mut input = std::io::Cursor::new(format!("{}\n", serde_json::to_string(&req).unwrap()));
924        let mut output = Vec::new();
925        let password = Password("secret".to_string());
926        // Use idle_secs matching the absolute window so idle doesn't fire first in tests.
927        let idle_secs = if absolute_deadline > Instant::now() {
928            (absolute_deadline - Instant::now()).as_secs().max(1)
929        } else {
930            1
931        };
932        let mut session = AgentSession::new("token-123", idle_secs, absolute_deadline);
933
934        handle_connection(
935            &mut input,
936            &mut output,
937            &password,
938            &mut session,
939            peer_pid,
940            &stop,
941        )
942        .unwrap();
943
944        let response: AgentResponse = serde_json::from_slice(&output).unwrap();
945        (response, stop.load(Ordering::Relaxed))
946    }
947
948    #[test]
949    fn open_vault_allows_matching_peer_pid() {
950        let (response, stop) = run_request(
951            AgentRequest::OpenVault {
952                profile: "default".into(),
953                session_token: "token-123".into(),
954                requesting_pid: 4242,
955            },
956            Some(4242),
957            Instant::now() + Duration::from_secs(60),
958        );
959
960        assert!(!stop);
961        match response {
962            AgentResponse::Password { password } => assert_eq!(password, "secret"),
963            other => panic!("expected password response, got {other:?}"),
964        }
965    }
966
967    #[test]
968    fn open_vault_rejects_pid_mismatch() {
969        let (response, stop) = run_request(
970            AgentRequest::OpenVault {
971                profile: "default".into(),
972                session_token: "token-123".into(),
973                requesting_pid: 4242,
974            },
975            Some(9001),
976            Instant::now() + Duration::from_secs(60),
977        );
978
979        assert!(!stop);
980        match response {
981            AgentResponse::Err { reason } => {
982                assert!(reason.contains("does not match the connecting process"));
983            }
984            other => panic!("expected authorization error, got {other:?}"),
985        }
986    }
987
988    #[test]
989    fn expired_session_rejects_requests_and_stops() {
990        let (response, stop) = run_request(
991            AgentRequest::Ping,
992            Some(4242),
993            Instant::now() - Duration::from_secs(1),
994        );
995
996        assert!(stop);
997        match response {
998            AgentResponse::Err { reason } => assert!(
999                reason.contains("agent session expired"),
1000                "unexpected: {reason}"
1001            ),
1002            other => panic!("expected expiry error, got {other:?}"),
1003        }
1004    }
1005
1006    #[test]
1007    fn lock_request_transitions_session_and_stops() {
1008        let (response, stop) = run_request(
1009            AgentRequest::Lock {
1010                session_token: "token-123".into(),
1011            },
1012            Some(4242),
1013            Instant::now() + Duration::from_secs(60),
1014        );
1015
1016        assert!(stop);
1017        assert!(matches!(response, AgentResponse::Ok));
1018    }
1019}