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 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    }
618}
619
620#[cfg(target_os = "windows")]
621unsafe fn windows_create_named_pipe(pipe_wide: &[u16]) -> Result<*mut std::ffi::c_void> {
622    // PIPE_ACCESS_DUPLEX = 3, PIPE_TYPE_BYTE | PIPE_WAIT = 0, 1 instance, 4096 bufs
623    let h = ffi::CreateNamedPipeW(
624        pipe_wide.as_ptr(),
625        3,    // PIPE_ACCESS_DUPLEX
626        0x00, // PIPE_TYPE_BYTE | PIPE_WAIT
627        1,    // 1 instance — only the approved process can connect
628        4096,
629        4096,
630        0,
631        std::ptr::null_mut(),
632    );
633    if h as isize == -1 || h.is_null() {
634        anyhow::bail!("CreateNamedPipeW failed");
635    }
636    Ok(h)
637}
638
639#[cfg(target_os = "windows")]
640unsafe fn windows_connect_with_timeout(handle: *mut std::ffi::c_void, _ms: u32) -> bool {
641    // ConnectNamedPipe blocks until a client connects or the handle is closed.
642    // A watchdog connection wakes the server when the session expires.
643    ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) != 0
644}
645
646#[cfg(target_os = "windows")]
647unsafe fn windows_close_handle(handle: *mut std::ffi::c_void) {
648    ffi::CloseHandle(handle);
649}
650
651#[cfg(target_os = "windows")]
652unsafe fn windows_get_named_pipe_client_process_id(handle: *mut std::ffi::c_void) -> Result<u32> {
653    let mut pid = 0u32;
654    if ffi::GetNamedPipeClientProcessId(handle, &mut pid) == 0 {
655        anyhow::bail!("GetNamedPipeClientProcessId failed");
656    }
657    Ok(pid)
658}
659
660#[cfg(target_os = "windows")]
661fn windows_connect_pipe_client(pipe: &str) -> Result<std::fs::File> {
662    use std::os::windows::ffi::OsStrExt;
663    use std::os::windows::io::FromRawHandle;
664
665    let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
666        .encode_wide()
667        .chain(std::iter::once(0))
668        .collect();
669
670    let handle = unsafe {
671        ffi::CreateFileW(
672            wide.as_ptr(),
673            0xC000_0000, // GENERIC_READ | GENERIC_WRITE
674            0,
675            std::ptr::null_mut(),
676            3,   // OPEN_EXISTING
677            128, // FILE_ATTRIBUTE_NORMAL
678            std::ptr::null_mut(),
679        )
680    };
681
682    if handle.is_null() || handle as isize == -1 {
683        anyhow::bail!("CreateFileW failed");
684    }
685
686    Ok(unsafe { std::fs::File::from_raw_handle(handle as _) })
687}
688
689// Unix domain socket server — mirrors the Windows named-pipe server above.
690#[cfg(not(target_os = "windows"))]
691fn serve(
692    pipe: &str,
693    pw: &Password,
694    session: &mut AgentSession,
695    _requesting_pid: u32,
696    deadline: Instant,
697    stop: Arc<AtomicBool>,
698) -> Result<()> {
699    use std::os::unix::net::UnixListener;
700
701    // Clean up stale socket from a previous crash.
702    let _ = std::fs::remove_file(pipe);
703
704    let listener =
705        UnixListener::bind(pipe).with_context(|| format!("failed to bind Unix socket: {pipe}"))?;
706
707    // Set socket file to owner-only (0600).
708    #[cfg(unix)]
709    {
710        use std::os::unix::fs::PermissionsExt;
711        let _ = std::fs::set_permissions(pipe, std::fs::Permissions::from_mode(0o600));
712    }
713
714    // Non-blocking accept with timeout so we can check deadline + PID liveness.
715    listener.set_nonblocking(true)?;
716
717    let _cleanup = SocketCleanup(pipe.to_string());
718
719    loop {
720        if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
721            break;
722        }
723        match listener.accept() {
724            Ok((stream, _)) => {
725                stream.set_nonblocking(false)?;
726                let mut reader = BufReader::new(&stream);
727                let mut writer = &stream;
728                handle_connection(
729                    &mut reader,
730                    &mut writer,
731                    pw,
732                    session,
733                    Some(unix_peer_pid(&stream)?),
734                    &stop,
735                )?;
736            }
737            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
738                std::thread::sleep(Duration::from_millis(200));
739            }
740            Err(e) => return Err(e.into()),
741        }
742    }
743
744    Ok(())
745}
746
747/// RAII guard to remove the socket file on exit (normal or panic).
748#[cfg(not(target_os = "windows"))]
749struct SocketCleanup(String);
750
751#[cfg(not(target_os = "windows"))]
752impl Drop for SocketCleanup {
753    fn drop(&mut self) {
754        let _ = std::fs::remove_file(&self.0);
755    }
756}
757
758#[cfg(unix)]
759struct PeerCredential {
760    pid: u32,
761    uid: u32,
762}
763
764/// Retrieve both the PID and UID of the connecting process in one syscall where possible.
765#[cfg(target_os = "linux")]
766fn unix_peer_credential(
767    stream: &std::os::unix::net::UnixStream,
768) -> std::io::Result<PeerCredential> {
769    use std::mem::size_of;
770    use std::os::fd::AsRawFd;
771    let fd = stream.as_raw_fd();
772    // SAFETY: `libc::ucred` is a plain POD struct (three integral fields) and
773    // zeroing it is a valid initialization for the `getsockopt(SO_PEERCRED)`
774    // out-parameter contract.
775    let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
776    let mut len = size_of::<libc::ucred>() as libc::socklen_t;
777    // SAFETY: `fd` is owned by `stream` (a `&UnixStream`) and remains valid for
778    // the duration of this call — the borrow holds the file descriptor open.
779    // The `optval` pointer addresses the local `cred` stack variable, which
780    // lives until the end of this function and is sized exactly
781    // `size_of::<libc::ucred>()` (matching the value passed via `&mut len`).
782    // `getsockopt` only writes through the pointer; the kernel will not
783    // exceed `len` bytes per the SO_PEERCRED contract.
784    let rc = unsafe {
785        libc::getsockopt(
786            fd,
787            libc::SOL_SOCKET,
788            libc::SO_PEERCRED,
789            &mut cred as *mut _ as *mut libc::c_void,
790            &mut len,
791        )
792    };
793    if rc != 0 {
794        return Err(std::io::Error::last_os_error());
795    }
796    Ok(PeerCredential {
797        pid: cred.pid as u32,
798        uid: cred.uid,
799    })
800}
801
802#[cfg(target_os = "macos")]
803fn unix_peer_credential(
804    stream: &std::os::unix::net::UnixStream,
805) -> std::io::Result<PeerCredential> {
806    use std::mem::size_of;
807    use std::os::fd::AsRawFd;
808    let fd = stream.as_raw_fd();
809    let mut uid: libc::uid_t = 0;
810    let mut gid: libc::gid_t = 0;
811    let mut pid: libc::pid_t = 0;
812    let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
813    // SAFETY: `fd` is owned by `stream` (a `&UnixStream`) and is valid for the
814    // entirety of this call. `&mut uid` and `&mut gid` point to local stack
815    // variables sized exactly as `getpeereid` requires (`uid_t` and `gid_t`
816    // respectively); the kernel writes one value to each and never reads them.
817    let rc_uid = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
818    // SAFETY: same `fd` lifetime invariant as above. The `optval` pointer
819    // addresses the local `pid` (a `libc::pid_t`), and `len` is initialized to
820    // `size_of::<libc::pid_t>()` so the kernel will write at most that many
821    // bytes per the LOCAL_PEERPID contract on Darwin.
822    let rc_pid = unsafe {
823        libc::getsockopt(
824            fd,
825            libc::SOL_LOCAL,
826            libc::LOCAL_PEERPID,
827            &mut pid as *mut _ as *mut libc::c_void,
828            &mut len,
829        )
830    };
831    if rc_uid != 0 {
832        return Err(std::io::Error::last_os_error());
833    }
834    if rc_pid != 0 {
835        return Err(std::io::Error::last_os_error());
836    }
837    Ok(PeerCredential {
838        pid: pid as u32,
839        uid: uid as u32,
840    })
841}
842
843#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
844fn unix_peer_credential(
845    _stream: &std::os::unix::net::UnixStream,
846) -> std::io::Result<PeerCredential> {
847    Err(std::io::Error::new(
848        std::io::ErrorKind::Unsupported,
849        "peer credentials unsupported on this platform",
850    ))
851}
852
853#[cfg(unix)]
854fn unix_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32> {
855    unix_peer_credential(stream)
856        .map(|c| c.pid)
857        .map_err(Into::into)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    fn run_request(
865        req: AgentRequest,
866        peer_pid: Option<u32>,
867        absolute_deadline: Instant,
868    ) -> (AgentResponse, bool) {
869        let stop = Arc::new(AtomicBool::new(false));
870        let mut input = std::io::Cursor::new(format!("{}\n", serde_json::to_string(&req).unwrap()));
871        let mut output = Vec::new();
872        let password = Password("secret".to_string());
873        // Use idle_secs matching the absolute window so idle doesn't fire first in tests.
874        let idle_secs = if absolute_deadline > Instant::now() {
875            (absolute_deadline - Instant::now()).as_secs().max(1)
876        } else {
877            1
878        };
879        let mut session = AgentSession::new("token-123", idle_secs, absolute_deadline);
880
881        handle_connection(
882            &mut input,
883            &mut output,
884            &password,
885            &mut session,
886            peer_pid,
887            &stop,
888        )
889        .unwrap();
890
891        let response: AgentResponse = serde_json::from_slice(&output).unwrap();
892        (response, stop.load(Ordering::Relaxed))
893    }
894
895    #[test]
896    fn open_vault_allows_matching_peer_pid() {
897        let (response, stop) = run_request(
898            AgentRequest::OpenVault {
899                profile: "default".into(),
900                session_token: "token-123".into(),
901                requesting_pid: 4242,
902            },
903            Some(4242),
904            Instant::now() + Duration::from_secs(60),
905        );
906
907        assert!(!stop);
908        match response {
909            AgentResponse::Password { password } => assert_eq!(password, "secret"),
910            other => panic!("expected password response, got {other:?}"),
911        }
912    }
913
914    #[test]
915    fn open_vault_rejects_pid_mismatch() {
916        let (response, stop) = run_request(
917            AgentRequest::OpenVault {
918                profile: "default".into(),
919                session_token: "token-123".into(),
920                requesting_pid: 4242,
921            },
922            Some(9001),
923            Instant::now() + Duration::from_secs(60),
924        );
925
926        assert!(!stop);
927        match response {
928            AgentResponse::Err { reason } => {
929                assert!(reason.contains("does not match the connecting process"));
930            }
931            other => panic!("expected authorization error, got {other:?}"),
932        }
933    }
934
935    #[test]
936    fn expired_session_rejects_requests_and_stops() {
937        let (response, stop) = run_request(
938            AgentRequest::Ping,
939            Some(4242),
940            Instant::now() - Duration::from_secs(1),
941        );
942
943        assert!(stop);
944        match response {
945            AgentResponse::Err { reason } => assert!(
946                reason.contains("agent session expired"),
947                "unexpected: {reason}"
948            ),
949            other => panic!("expected expiry error, got {other:?}"),
950        }
951    }
952
953    #[test]
954    fn lock_request_transitions_session_and_stops() {
955        let (response, stop) = run_request(
956            AgentRequest::Lock {
957                session_token: "token-123".into(),
958            },
959            Some(4242),
960            Instant::now() + Duration::from_secs(60),
961        );
962
963        assert!(stop);
964        assert!(matches!(response, AgentResponse::Ok));
965    }
966}