diff --git a/crates/mcp_detector_daemon/Cargo.toml b/crates/mcp_detector_daemon/Cargo.toml index 73715ab..d393ce0 100644 --- a/crates/mcp_detector_daemon/Cargo.toml +++ b/crates/mcp_detector_daemon/Cargo.toml @@ -27,4 +27,8 @@ anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } notify = "8.2.0" notify-debouncer-full = "0.7.0" + +# libc is only used by the Unix privilege/user code (getpwuid, setuid, chown, +# kill); cfg-gated out on Windows, which has no such model. +[target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/mcp_detector_daemon/src/claude_cli.rs b/crates/mcp_detector_daemon/src/claude_cli.rs index 1e71606..6896685 100644 --- a/crates/mcp_detector_daemon/src/claude_cli.rs +++ b/crates/mcp_detector_daemon/src/claude_cli.rs @@ -7,6 +7,7 @@ use std::process::Command; use anyhow::Context; +#[cfg(unix)] use crate::{paths, platform}; /// `claude mcp add --transport http --scope user [--header …] edison-watch `. @@ -57,9 +58,29 @@ pub fn remove(user: &str) -> anyhow::Result<()> { fn run_as(user: &str, args: &[String]) -> anyhow::Result<()> { let mut cmd = Command::new("claude"); cmd.args(args); - + // The daemon is GUI-subsystem on Windows; suppress the console window a + // console child (`claude`) would otherwise flash. + no_window(&mut cmd); // When running as root, drop to the target user so the CLI writes into their - // home. In the user-mode dev build this branch is skipped. + // home. No-op in the user-mode dev build and on non-Unix (no root/setuid). + drop_to_user(&mut cmd, user); + + let output = cmd.output().context("spawning `claude` CLI")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!( + "`claude {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ) + } +} + +/// Under root, set HOME/USER for the target user and `setuid`/`setgid` to them +/// in the child before exec, so the `claude` CLI writes into their home. +#[cfg(unix)] +fn drop_to_user(cmd: &mut Command, user: &str) { if paths::is_root() && let Some((uid, gid)) = platform::uid_gid_for(user) { @@ -82,15 +103,21 @@ fn run_as(user: &str, args: &[String]) -> anyhow::Result<()> { }); } } +} - let output = cmd.output().context("spawning `claude` CLI")?; - if output.status.success() { - Ok(()) - } else { - anyhow::bail!( - "`claude {}` failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr).trim() - ) - } +/// Non-Unix: the daemon already runs as the single logged-in user, so there is +/// nothing to drop to. +#[cfg(not(unix))] +fn drop_to_user(_cmd: &mut Command, _user: &str) {} + +/// Windows: spawn the child with no console window (CREATE_NO_WINDOW), matching +/// the schtasks/whoami spawns and stdiod's behaviour. +#[cfg(windows)] +fn no_window(cmd: &mut Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); } + +#[cfg(not(windows))] +fn no_window(_cmd: &mut Command) {} diff --git a/crates/mcp_detector_daemon/src/hook_consumer.rs b/crates/mcp_detector_daemon/src/hook_consumer.rs index e51ec8d..bde8e19 100644 --- a/crates/mcp_detector_daemon/src/hook_consumer.rs +++ b/crates/mcp_detector_daemon/src/hook_consumer.rs @@ -164,6 +164,7 @@ fn sweep_orphaned(edison_dir: &Path) { /// `kill(pid, 0)`: 0 → alive; `EPERM` → alive (exists, no permission); `ESRCH` /// → gone. +#[cfg(unix)] fn process_alive(pid: i32) -> bool { // SAFETY: kill with signal 0 performs only an existence/permission check. if unsafe { libc::kill(pid, 0) } == 0 { @@ -171,3 +172,11 @@ fn process_alive(pid: i32) -> bool { } std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } + +/// Non-Unix has no `kill(pid, 0)` probe; a real liveness check lands with the +/// Windows hook-consumer work. Conservatively assume alive so we never delete a +/// live process's pending/error entry. +#[cfg(not(unix))] +fn process_alive(_pid: i32) -> bool { + true +} diff --git a/crates/mcp_detector_daemon/src/ipc.rs b/crates/mcp_detector_daemon/src/ipc.rs index 5ecfaf9..9427bc7 100644 --- a/crates/mcp_detector_daemon/src/ipc.rs +++ b/crates/mcp_detector_daemon/src/ipc.rs @@ -1,31 +1,50 @@ -//! The Unix-socket IPC server. +//! The IPC control server: a Unix-domain socket on Unix, a named pipe on +//! Windows. +//! +//! On Unix each connection's OS user is taken from the socket's **peer +//! credentials** (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not +//! from anything the client sends, so every request is scoped to the +//! kernel-reported uid. On Windows the daemon runs per-user (a logon Scheduled +//! Task), so the peer is always the daemon's own user. //! -//! Each connection's OS user is taken from the socket's **peer credentials** -//! (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not from anything -//! the client sends — so every request is scoped to the kernel-reported uid. //! Requests/replies are newline-delimited JSON; the daemon also pushes -//! [`Event`](crate::protocol::Event)s to a connection when they match its peer user. +//! [`Event`](crate::protocol::Event)s to a connection when they match its user. use std::path::{Path, PathBuf}; use serde::Serialize; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream, unix::OwnedWriteHalf}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::sync::broadcast; use crate::ops; -use crate::platform; use crate::protocol::{Reply, Request}; use crate::runner::EventTx; -/// Default socket path (dev). The root build will use `/var/run/...`. +// ── socket / pipe address ────────────────────────────────────────────────── + +/// Default IPC address. A socket file under base_dir on Unix; a per-user named +/// pipe on Windows (pipes are machine-global, so namespace by user). +#[cfg(not(windows))] pub fn default_socket_path() -> PathBuf { crate::paths::base_dir().join("daemon.sock") } -/// Serve the IPC socket at `path` until the process is stopped. `events` feeds +#[cfg(windows)] +pub fn default_socket_path() -> PathBuf { + PathBuf::from(format!( + r"\\.\pipe\edison-detectord.{}", + crate::paths::current_username() + )) +} + +// ── serve loop (transport-specific) ───────────────────────────────────────── + +/// Serve the IPC endpoint at `path` until the process is stopped. `events` feeds /// per-user pushes to connected clients. +#[cfg(unix)] pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> { + use tokio::net::UnixListener; + crate::paths::ensure_base_dir()?; let _ = std::fs::remove_file(path); // a stale socket file blocks bind let listener = UnixListener::bind(path)?; @@ -33,23 +52,65 @@ pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> { loop { let (stream, _addr) = listener.accept().await?; - let events = events.clone(); - tokio::spawn(async move { - if let Err(e) = handle_conn(stream, events).await { - tracing::debug!(error = %e, "connection ended"); - } - }); + // Identity comes from the kernel peer creds, not the client. + let Some(user) = stream + .peer_cred() + .ok() + .and_then(|c| crate::platform::username_for_uid(c.uid())) + else { + tracing::warn!("could not resolve connection's peer user; dropping it"); + continue; + }; + spawn_conn(stream, user, events.clone()); + } +} + +#[cfg(windows)] +pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> { + use tokio::net::windows::named_pipe::ServerOptions; + + crate::paths::ensure_base_dir()?; + let name = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("pipe name is not valid UTF-8: {}", path.display()))?; + tracing::info!(pipe = %name, "IPC listening"); + + // Always keep one server instance listening: on each connect, hand off the + // connected instance and immediately create the next. `first_pipe_instance` + // on the very first server refuses to bind if another process already owns + // the name (anti-squat). + let mut server = ServerOptions::new() + .first_pipe_instance(true) + .create(name)?; + loop { + server.connect().await?; + let connected = server; + server = ServerOptions::new().create(name)?; + // Per-user daemon: the peer is this daemon's own user. + spawn_conn(connected, crate::paths::current_username(), events.clone()); } } -async fn handle_conn(stream: UnixStream, events: EventTx) -> anyhow::Result<()> { - // Identity comes from the kernel, not the client. - let uid = stream.peer_cred()?.uid(); - let user = - platform::username_for_uid(uid).ok_or_else(|| anyhow::anyhow!("unknown uid {uid}"))?; - tracing::debug!(uid, %user, "connection"); +// ── connection handling (transport-agnostic) ──────────────────────────────── + +fn spawn_conn(stream: S, user: String, events: EventTx) +where + S: AsyncRead + AsyncWrite + Send + 'static, +{ + tokio::spawn(async move { + if let Err(e) = handle_conn(stream, user, events).await { + tracing::debug!(error = %e, "connection ended"); + } + }); +} + +async fn handle_conn(stream: S, user: String, events: EventTx) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Send + 'static, +{ + tracing::debug!(%user, "connection"); - let (rd, mut wr) = stream.into_split(); + let (rd, mut wr) = tokio::io::split(stream); let mut lines = BufReader::new(rd).lines(); let mut rx = events.subscribe(); @@ -81,7 +142,11 @@ async fn handle_conn(stream: UnixStream, events: EventTx) -> anyhow::Result<()> Ok(()) } -async fn write_json(wr: &mut OwnedWriteHalf, val: &T) -> anyhow::Result<()> { +async fn write_json(wr: &mut W, val: &T) -> anyhow::Result<()> +where + W: AsyncWrite + Unpin, + T: Serialize, +{ let mut buf = serde_json::to_vec(val)?; buf.push(b'\n'); wr.write_all(&buf).await?; diff --git a/crates/mcp_detector_daemon/src/main.rs b/crates/mcp_detector_daemon/src/main.rs index 05f84df..0eb3c9b 100644 --- a/crates/mcp_detector_daemon/src/main.rs +++ b/crates/mcp_detector_daemon/src/main.rs @@ -6,6 +6,11 @@ //! Two interfaces: an operator CLI (below) and an IPC socket (`serve`) — both //! go through the shared, per-user [`ops`] layer. +// Release Windows builds are GUI-subsystem so the supervisor (Scheduled Task) +// and one-shot CLI spawns don't flash a console window. Debug keeps the console +// so `run`/`daemon` still print to a terminal during dev. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + use std::collections::HashMap; use std::path::PathBuf; use std::process::ExitCode; @@ -17,7 +22,6 @@ mod claude_cli; mod enrollment; mod hook_consumer; mod ipc; -mod launchd; mod logging; mod ops; mod paths; @@ -26,6 +30,7 @@ mod protocol; mod quarantined; mod recovery; mod runner; +mod service; mod status; mod supervisor; @@ -217,13 +222,13 @@ async fn dispatch(cmd: Cmd) -> anyhow::Result<()> { SecretCmd::Reset { key, confirm } => cmd_reset_secret(key, confirm).await, }, Cmd::Service { action } => match action { - ServiceCmd::Install { enforce } => launchd::install(enforce), - ServiceCmd::Uninstall { purge } => launchd::uninstall(purge), + ServiceCmd::Install { enforce } => service::install(enforce), + ServiceCmd::Uninstall { purge } => service::uninstall(purge), ServiceCmd::Status => { println!( "installed: {}\nrunning: {}\nsocket: {}", - launchd::is_installed(), - launchd::is_running(), + service::is_installed(), + service::is_running(), ipc::default_socket_path().display() ); Ok(()) diff --git a/crates/mcp_detector_daemon/src/paths.rs b/crates/mcp_detector_daemon/src/paths.rs index 220d6e3..01bd78c 100644 --- a/crates/mcp_detector_daemon/src/paths.rs +++ b/crates/mcp_detector_daemon/src/paths.rs @@ -18,10 +18,18 @@ use std::path::PathBuf; const DIR_NAME: &str = "edison-watch-detectord"; -/// True when running as the privileged system daemon (euid 0). +/// True when running as the privileged system daemon (euid 0). Always false on +/// non-Unix targets, which have no root/euid model. pub fn is_root() -> bool { - // SAFETY: geteuid has no preconditions and no failure mode. - unsafe { geteuid() == 0 } + #[cfg(unix)] + { + // SAFETY: geteuid has no preconditions and no failure mode. + unsafe { geteuid() == 0 } + } + #[cfg(not(unix))] + { + false + } } /// Root of all daemon state: system-level under root, the user's config dir @@ -72,6 +80,7 @@ pub fn ensure_user_dir(user: &str) -> io::Result<()> { pub fn current_username() -> String { std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) + .or_else(|_| std::env::var("USERNAME")) // Windows .unwrap_or_else(|_| "unknown".to_string()) } @@ -81,6 +90,7 @@ pub fn edison_watch_dir() -> Option { dirs::home_dir().map(|h| h.join(".edison-watch")) } +#[cfg(unix)] unsafe extern "C" { fn geteuid() -> u32; } diff --git a/crates/mcp_detector_daemon/src/platform.rs b/crates/mcp_detector_daemon/src/platform.rs index fabf466..001a703 100644 --- a/crates/mcp_detector_daemon/src/platform.rs +++ b/crates/mcp_detector_daemon/src/platform.rs @@ -1,74 +1,98 @@ -//! Privilege helpers used by the root build. +//! Privilege + user-identity helpers. //! -//! When the daemon writes files into a user's home (the `disabled_` -//! sidecar and the `.ew-backup`) it runs as root, so those new files are -//! root-owned. We `chown` them back to the owning user so they behave like the -//! user's own files. In-place config edits and dir renames preserve ownership, -//! so only the newly-created files need this. +//! Under the privileged (root) macOS/Unix build the daemon writes files into a +//! user's home and must resolve/chown them to that user; those helpers are +//! implemented with libc. Non-Unix targets have no such privileged multi-user +//! model (the daemon runs per-user via a scheduled task), so the same surface is +//! provided as no-op / `None` stubs. -use std::os::unix::ffi::OsStrExt; -use std::path::Path; +#[cfg(unix)] +mod imp { + use std::os::unix::ffi::OsStrExt; + use std::path::Path; -/// Resolve a uid to its OS user name via `getpwuid`. Used to scope an IPC -/// connection to the peer's user (from the socket's peer credentials). -pub fn username_for_uid(uid: u32) -> Option { - // SAFETY: getpwuid returns a pointer into a static buffer valid until the - // next passwd call; we copy the name out immediately. - unsafe { - let pw = libc::getpwuid(uid); - if pw.is_null() { - None - } else { - Some( - std::ffi::CStr::from_ptr((*pw).pw_name) - .to_string_lossy() - .into_owned(), - ) + /// Resolve a uid to its OS user name via `getpwuid`. Used to scope an IPC + /// connection to the peer's user (from the socket's peer credentials). + pub fn username_for_uid(uid: u32) -> Option { + // SAFETY: getpwuid returns a pointer into a static buffer valid until the + // next passwd call; we copy the name out immediately. + unsafe { + let pw = libc::getpwuid(uid); + if pw.is_null() { + None + } else { + Some( + std::ffi::CStr::from_ptr((*pw).pw_name) + .to_string_lossy() + .into_owned(), + ) + } } } -} -/// Resolve an OS user name to its home dir via `getpwnam` (`pw_dir`). Used to -/// target the correct user's home when the root daemon writes installs/hooks. -pub fn home_dir_for(user: &str) -> Option { - let cname = std::ffi::CString::new(user).ok()?; - // SAFETY: getpwnam returns a pointer into a static buffer valid until the - // next passwd call; we copy pw_dir out immediately. - unsafe { - let pw = libc::getpwnam(cname.as_ptr()); - if pw.is_null() || (*pw).pw_dir.is_null() { - None - } else { - let bytes = std::ffi::CStr::from_ptr((*pw).pw_dir).to_bytes(); - Some(std::path::PathBuf::from(std::ffi::OsStr::from_bytes(bytes))) + /// Resolve an OS user name to its home dir via `getpwnam` (`pw_dir`). + pub fn home_dir_for(user: &str) -> Option { + let cname = std::ffi::CString::new(user).ok()?; + // SAFETY: getpwnam returns a pointer into a static buffer valid until the + // next passwd call; we copy pw_dir out immediately. + unsafe { + let pw = libc::getpwnam(cname.as_ptr()); + if pw.is_null() || (*pw).pw_dir.is_null() { + None + } else { + let bytes = std::ffi::CStr::from_ptr((*pw).pw_dir).to_bytes(); + Some(std::path::PathBuf::from(std::ffi::OsStr::from_bytes(bytes))) + } } } -} -/// Resolve an OS user name to its `(uid, gid)` via `getpwnam`. -pub fn uid_gid_for(user: &str) -> Option<(u32, u32)> { - let cname = std::ffi::CString::new(user).ok()?; - // SAFETY: getpwnam returns a pointer into a static buffer valid until the - // next passwd call; we read its fields immediately and copy them out. - unsafe { - let pw = libc::getpwnam(cname.as_ptr()); - if pw.is_null() { - None + /// Resolve an OS user name to its `(uid, gid)` via `getpwnam`. + pub fn uid_gid_for(user: &str) -> Option<(u32, u32)> { + let cname = std::ffi::CString::new(user).ok()?; + // SAFETY: getpwnam returns a pointer into a static buffer valid until the + // next passwd call; we read its fields immediately and copy them out. + unsafe { + let pw = libc::getpwnam(cname.as_ptr()); + if pw.is_null() { + None + } else { + Some(((*pw).pw_uid, (*pw).pw_gid)) + } + } + } + + /// `chown(path, uid, gid)`. Errors are the caller's to log (best-effort). + pub fn chown(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::other("path contains NUL"))?; + // SAFETY: cpath is a valid NUL-terminated C string for the duration of the call. + let rc = unsafe { libc::chown(cpath.as_ptr(), uid, gid) }; + if rc == 0 { + Ok(()) } else { - Some(((*pw).pw_uid, (*pw).pw_gid)) + Err(std::io::Error::last_os_error()) } } } -/// `chown(path, uid, gid)`. Errors are the caller's to log (best-effort). -pub fn chown(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { - let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()) - .map_err(|_| std::io::Error::other("path contains NUL"))?; - // SAFETY: cpath is a valid NUL-terminated C string for the duration of the call. - let rc = unsafe { libc::chown(cpath.as_ptr(), uid, gid) }; - if rc == 0 { +#[cfg(not(unix))] +mod imp { + use std::path::{Path, PathBuf}; + + // Non-Unix has no uid/gid model. IPC peer identity is resolved differently + // (the named-pipe daemon is per-user), and the root drop-to-user machinery + // doesn't apply, so these are stubs: the daemon runs as the single logged-in + // user. (`username_for_uid` is intentionally omitted: only the Unix peer-cred + // path uses it.) + pub fn home_dir_for(_user: &str) -> Option { + dirs::home_dir() + } + pub fn uid_gid_for(_user: &str) -> Option<(u32, u32)> { + None + } + pub fn chown(_path: &Path, _uid: u32, _gid: u32) -> std::io::Result<()> { Ok(()) - } else { - Err(std::io::Error::last_os_error()) } } + +pub use imp::*; diff --git a/crates/mcp_detector_daemon/src/launchd.rs b/crates/mcp_detector_daemon/src/service/macos.rs similarity index 83% rename from crates/mcp_detector_daemon/src/launchd.rs rename to crates/mcp_detector_daemon/src/service/macos.rs index 9feb3d6..9d1da37 100644 --- a/crates/mcp_detector_daemon/src/launchd.rs +++ b/crates/mcp_detector_daemon/src/service/macos.rs @@ -1,13 +1,13 @@ -//! macOS LaunchAgent install for the detector daemon — a per-user agent (no +//! macOS LaunchAgent install for the detector daemon: a per-user agent (no //! sudo, no root LaunchDaemon), mirroring `edison-stdiod` so the desktop client //! installs and launches us exactly the way it installs stdiod: //! //! - plist at `~/Library/LaunchAgents/watch.edison.detectord.plist` -//! - loaded via `launchctl bootstrap gui/$uid …` (modern flow, not `load`) +//! - loaded via `launchctl bootstrap gui/$uid ...` (modern flow, not `load`) //! - `RunAtLoad` + `KeepAlive` so launchd starts it now/at login and restarts -//! it on crash — the daemon serves its socket for the client to connect to. +//! it on crash. The daemon serves its socket for the client to connect to. //! - a PATH override so child spawns (the `claude` CLI, npx-wrapped servers) -//! resolve — launchd's default PATH omits Homebrew / `/usr/local/bin`. +//! resolve; launchd's default PATH omits Homebrew / `/usr/local/bin`. //! //! Install is idempotent: it always boots-out any existing unit before //! bootstrapping the fresh plist, so re-running picks up a moved binary or a @@ -23,7 +23,7 @@ use crate::{ipc, paths}; const LABEL: &str = "watch.edison.detectord"; const PLIST_FILENAME: &str = "watch.edison.detectord.plist"; /// launchd's per-user default PATH omits Homebrew and /usr/local/bin; without -/// this every child spawn (`claude`, `npx`, …) fails to resolve. +/// this every child spawn (`claude`, `npx`, ...) fails to resolve. const CHILD_PATH: &str = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; fn plist_path() -> Result { @@ -129,11 +129,8 @@ fn bootout_quiet() -> Result<()> { /// Write the plist and `launchctl bootstrap` it. Idempotent. `enforce` decides /// whether the running daemon actually quarantines (still gated by org policy) -/// or runs report-only — default off is safe for first-time install. +/// or runs report-only; default off is safe for first-time install. pub fn install(enforce: bool) -> Result<()> { - if !cfg!(target_os = "macos") { - bail!("`service install` is only supported on macOS"); - } let binary = std::env::current_exe().context("could not resolve current exe path")?; let log = launchd_log()?; let plist = plist_path()?; @@ -163,8 +160,9 @@ pub fn install(enforce: bool) -> Result<()> { Ok(()) } -/// `launchctl bootout` + remove the plist. Idempotent. Leaves state/logs. -pub fn uninstall(purge: bool) -> Result<()> { +/// `launchctl bootout` + remove the plist. Idempotent. Leaves state/logs; the +/// caller (`service::uninstall`) handles the optional data purge. +pub fn uninstall() -> Result<()> { bootout_quiet()?; let plist = plist_path()?; match std::fs::remove_file(&plist) { @@ -172,23 +170,6 @@ pub fn uninstall(purge: bool) -> Result<()> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("removing {}", plist.display())), } - if purge { - // enrollment(s), per-user seen-store + quarantine records, state.json, - // logs/, and the socket all live under base_dir — wipe the whole thing. - let dir = paths::base_dir(); - match std::fs::remove_dir_all(&dir) { - Ok(()) => tracing::info!(path = %dir.display(), "purged daemon data dir"), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e).with_context(|| format!("purging {}", dir.display())), - } - println!( - "Uninstalled LaunchAgent and purged all data (enrollment, seen-store, \ - quarantine records, logs, socket) at {}.", - dir.display() - ); - } else { - println!("Uninstalled LaunchAgent (state + logs left in place)."); - } Ok(()) } diff --git a/crates/mcp_detector_daemon/src/service/mod.rs b/crates/mcp_detector_daemon/src/service/mod.rs new file mode 100644 index 0000000..99047ed --- /dev/null +++ b/crates/mcp_detector_daemon/src/service/mod.rs @@ -0,0 +1,64 @@ +//! Per-OS supervisor integration: launchd on macOS, Task Scheduler on Windows. +//! +//! Each platform module exposes the same surface (`install` / `uninstall` / +//! `is_installed` / `is_running`). Other platforms (Linux, etc.) compile via a +//! stub that reports "not supported" at runtime, so the daemon still builds +//! everywhere and can be run directly with `daemon`. +//! +//! The shared parts (the optional data purge on uninstall) live here; the +//! platform modules only own the supervisor unit itself. + +use anyhow::{Context, Result}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as imp; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +use windows as imp; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod stub; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use stub as imp; + +/// Install + start the supervisor unit for the current binary. `enforce` selects +/// the daemon's enforcing (quarantine + hooks) vs report-only mode. Idempotent. +pub fn install(enforce: bool) -> Result<()> { + imp::install(enforce) +} + +/// Stop + remove the supervisor unit. With `purge`, also delete all daemon data +/// (enrollment, seen-store, quarantine records, logs, socket) under base_dir. +pub fn uninstall(purge: bool) -> Result<()> { + imp::uninstall()?; + if purge { + let dir = crate::paths::base_dir(); + match std::fs::remove_dir_all(&dir) { + Ok(()) => tracing::info!(path = %dir.display(), "purged daemon data dir"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("purging {}", dir.display())), + } + println!( + "Uninstalled the supervisor unit and purged all data (enrollment, \ + seen-store, quarantine records, logs, socket) at {}.", + dir.display() + ); + } else { + println!("Uninstalled the supervisor unit (state + logs left in place)."); + } + Ok(()) +} + +/// Whether the supervisor unit is installed. +pub fn is_installed() -> bool { + imp::is_installed() +} + +/// Whether the daemon is currently running under the supervisor. +pub fn is_running() -> bool { + imp::is_running() +} diff --git a/crates/mcp_detector_daemon/src/service/stub.rs b/crates/mcp_detector_daemon/src/service/stub.rs new file mode 100644 index 0000000..a7803a3 --- /dev/null +++ b/crates/mcp_detector_daemon/src/service/stub.rs @@ -0,0 +1,27 @@ +//! Fallback for platforms without supervisor integration (Linux, etc.). The +//! daemon still builds and can be run directly with `daemon` (e.g. under a +//! systemd user unit); install reports "not supported" rather than failing to +//! compile. `uninstall` is a no-op so `service uninstall --purge` can still wipe +//! data (the shared purge runs in `service::uninstall`). + +use anyhow::{Result, bail}; + +pub fn install(_enforce: bool) -> Result<()> { + bail!( + "`service install` is implemented for macOS (launchd) and Windows (Task \ + Scheduler). On this platform run the daemon directly with `daemon` (e.g. \ + under a systemd user unit)." + ) +} + +pub fn uninstall() -> Result<()> { + Ok(()) +} + +pub fn is_installed() -> bool { + false +} + +pub fn is_running() -> bool { + false +} diff --git a/crates/mcp_detector_daemon/src/service/windows.rs b/crates/mcp_detector_daemon/src/service/windows.rs new file mode 100644 index 0000000..2f18c73 --- /dev/null +++ b/crates/mcp_detector_daemon/src/service/windows.rs @@ -0,0 +1,300 @@ +//! Windows Scheduled Task integration. +//! +//! The Windows analog of the macOS LaunchAgent: a per-user **logon task** that +//! runs the daemon as the logged-in user, in their session, starting at logon +//! and restarting on failure. Deliberately NOT a Windows Service: a service runs +//! in session 0 as SYSTEM, which would break the daemon's per-user model (it +//! reads the user's MCP client configs and spawns the user's tools with their +//! PATH/env). +//! +//! We register via `schtasks /create /xml` with a Task Scheduler definition +//! because the command-line flags can't express what we need (Hidden, +//! restart-on-failure, unlimited run time). `LogonType=InteractiveToken` runs +//! with the user's live token: no stored password, no admin. +//! +//! Idempotent: install deletes any existing task before recreating, mirroring +//! the macOS bootout-before-bootstrap flow. + +use std::os::windows::process::CommandExt; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, anyhow}; +use tracing::{debug, info, warn}; + +/// Base name. The live task name appends the user's SID (see `task_name`). +const TASK_BASENAME: &str = "Edison Watch detectord"; + +/// CREATE_NO_WINDOW: the daemon has no console, so spawning console programs +/// (schtasks/whoami) would otherwise flash a window. +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Per-user task name ` `. Namespacing by SID keeps two accounts on +/// one machine from clobbering each other's task; falls back to the bare base +/// name if the SID can't be resolved. +fn task_name() -> String { + match current_user_sid() { + Some(sid) => format!("{TASK_BASENAME} {sid}"), + None => TASK_BASENAME.to_string(), + } +} + +/// Current user's SID via `whoami /user /fo csv /nh` -> `"DOMAIN\user","S-1-5-..."`. +fn current_user_sid() -> Option { + let out = Command::new("whoami") + .args(["/user", "/fo", "csv", "/nh"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .rsplit(',') + .next() + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| s.starts_with("S-")) +} + +/// `DOMAIN\User` (or bare `User`) for the task principal + logon trigger. +fn current_user() -> String { + let user = std::env::var("USERNAME").unwrap_or_default(); + let domain = std::env::var("USERDOMAIN").unwrap_or_default(); + if domain.is_empty() || user.is_empty() { + user + } else { + format!("{domain}\\{user}") + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Task Scheduler 1.2 XML. Hidden + restart-on-failure + no time limit; runs as +/// the current user with their interactive token at every logon. `enforce` +/// selects the daemon's enforcing vs report-only args (mirrors the macOS plist). +fn render_task_xml(binary: &Path, user: &str, enforce: bool) -> String { + let bin = xml_escape(&binary.display().to_string()); + let u = xml_escape(user); + let args = if enforce { + "daemon --enforce" + } else { + "daemon --no-hooks" + }; + format!( + r#" + + + Edison Watch MCP detector and quarantine daemon + + + + true + {u} + + + + + {u} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + true + false + false + PT0S + 7 + + PT1M + 999 + + + + + {bin} + {args} + + + +"# + ) +} + +/// schtasks `/create /xml` wants the file as UTF-16LE with a BOM; UTF-8 is +/// rejected as malformed on many Windows builds. +fn write_task_xml(path: &Path, body: &str) -> Result<()> { + let mut bytes: Vec = vec![0xFF, 0xFE]; // UTF-16LE BOM + for unit in body.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +fn schtasks(args: &[&str]) -> Result { + debug!(?args, "schtasks"); + Command::new("schtasks") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output() + .context("failed to invoke schtasks") +} + +/// Write the task XML + register it via schtasks, then start it now. Idempotent +/// (deletes any existing task first). +pub fn install(enforce: bool) -> Result<()> { + let binary = std::env::current_exe().context("could not resolve current exe path")?; + let user = current_user(); + if user.is_empty() { + return Err(anyhow!("could not resolve current user (USERNAME unset)")); + } + let task = task_name(); + let xml = render_task_xml(&binary, &user, enforce); + let xml_path = std::env::temp_dir().join("edison-detectord-task.xml"); + write_task_xml(&xml_path, &xml)?; + info!(path = %xml_path.display(), enforce, "wrote scheduled task definition"); + + // Idempotent: drop any existing task (stops it too), plus a legacy task + // under the bare base name (pre-SID-namespacing), before recreating. + let _ = schtasks(&["/delete", "/tn", &task, "/f"]); + if task != TASK_BASENAME { + let _ = schtasks(&["/delete", "/tn", TASK_BASENAME, "/f"]); + } + let out = schtasks(&[ + "/create", + "/tn", + &task, + "/xml", + xml_path.to_string_lossy().as_ref(), + "/f", + ])?; + let _ = std::fs::remove_file(&xml_path); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + return Err(anyhow!( + "schtasks /create failed: {} {}", + stderr.trim(), + stdout.trim() + )); + } + info!(task = %task, "scheduled task created"); + + // Start now (the RunAtLoad equivalent). Non-fatal: the logon trigger still + // starts it at next sign-in. + match schtasks(&["/run", "/tn", &task]) { + Ok(o) if o.status.success() => {} + Ok(o) => warn!( + stderr = %String::from_utf8_lossy(&o.stderr), + "schtasks /run failed; task will start at next logon" + ), + Err(e) => warn!(error = %e, "could not start task now; will start at next logon"), + } + + println!("Installed scheduled task: {task}"); + println!( + "Daemon running{}.", + if enforce { + " (enforcing)" + } else { + " (report-only)" + } + ); + Ok(()) +} + +/// Stop + remove the task. Idempotent. Leaves data; the caller +/// (`service::uninstall`) handles the optional purge. +pub fn uninstall() -> Result<()> { + let task = task_name(); + let _ = schtasks(&["/end", "/tn", &task]); // stop a running instance + if task != TASK_BASENAME { + let _ = schtasks(&["/end", "/tn", TASK_BASENAME]); + let _ = schtasks(&["/delete", "/tn", TASK_BASENAME, "/f"]); + } + let out = schtasks(&["/delete", "/tn", &task, "/f"])?; + if out.status.success() { + info!(task = %task, "removed scheduled task"); + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + // Benign: the task doesn't exist (already uninstalled). + let benign = stderr.contains("does not exist") + || stderr.contains("cannot find") + || stderr.to_lowercase().contains("the system cannot find"); + if benign { + info!("no scheduled task to remove"); + } else { + warn!(stderr = %stderr, "schtasks /delete reported an error; continuing"); + } + } + Ok(()) +} + +/// True iff the scheduled task exists. +pub fn is_installed() -> bool { + schtasks(&["/query", "/tn", &task_name()]) + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// True iff the task is currently executing. Parses `schtasks /query /v`'s +/// "Status:" field (English-locale), mirroring the macOS `launchctl print` parse. +pub fn is_running() -> bool { + let Ok(out) = schtasks(&["/query", "/tn", &task_name(), "/fo", "LIST", "/v"]) else { + return false; + }; + if !out.status.success() { + return false; + } + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Some(rest) = line.trim().strip_prefix("Status:") { + return rest.trim().eq_ignore_ascii_case("Running"); + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_task_xml_enforce_vs_report() { + let e = render_task_xml(Path::new(r"C:\Apps\edison-detectord.exe"), r"WS\dimi", true); + assert!(e.contains(r"C:\Apps\edison-detectord.exe")); + assert!(e.contains("daemon --enforce")); + assert!(e.contains("")); + assert!(e.contains("WS\\dimi")); + assert!(e.contains("InteractiveToken")); + + let r = render_task_xml(Path::new(r"C:\x.exe"), "u", false); + assert!(r.contains("daemon --no-hooks")); + assert!(r.contains("true")); + assert!(r.contains("")); + assert!(r.contains("PT0S")); + } + + #[test] + fn xml_escape_escapes_markup() { + assert_eq!(xml_escape("a&bd"), "a&b<c>d"); + } +}