Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
21 kB · 517 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518//! The unix socket this daemon listens on, and who is allowed to reach it.//!//! Four of the five guarantees below answer an exposure review that found a//! socket in this project landing world-writable at a predictable path.//! What is behind this one issues credentials, so the directory is `0700`//! and the socket `0600`, both set explicitly on every start and//! re-tightened when found wider.//!//! The fifth is this socket's own. It hands a different answer to each//! caller, so it reads the connecting process's credentials rather than//! believing what the connection says about itself.//!//! # What reaching this socket means, and what it does not//!//! Reaching it **is** the authorization. `plan/cred-delivery.md` publishes//! this path through `CLAUDE_ENV_FILE`, which any shell command the model//! runs can print — so the path is not a secret and nothing here treats it//! as one. This project deleted its last shared operator secret for exactly//! this reason://! the same caller could read it off the same filesystem, and the socket's//! permissions were doing the real work. Anything later carried in that//! environment file selects which context is calling. It does not decide//! whether the caller is allowed to.//!//! The ceiling is therefore the user account, as `docs/trust-model.md`//! already states: anything running as this user can connect, and this//! design defends against a credential leaving the machine rather than//! against one being misused on it.
use std::io;use std::os::unix::fs::{MetadataExt, PermissionsExt};use std::path::{Path, PathBuf};
use tokio::net::{UnixListener, UnixStream};use tracing::warn;
/// The directory this daemon creates and owns, under the runtime directory.const DIR_NAME: &str = "didbot-agent";
/// The socket file inside it.const SOCKET_NAME: &str = "agent.sock";
/// The credentials of a connected process, as the kernel reports them.////// Not as the connection claims them: these come from the socket itself, so/// a caller cannot choose what they say.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct Peer { /// The connecting process's effective user. pub uid: u32, /// Its effective group. pub gid: u32, /// Its process id, where the platform reports one. Useful for finding /// which session a caller belongs to, and useless as a check on its own: /// a process id is reused once the process is gone. pub pid: Option<i32>,}
/// Where the socket lives when a deployment has not said otherwise.////// `XDG_RUNTIME_DIR` when it is set — a per-user directory the system clears/// on logout — and the temporary directory otherwise. Either way the socket/// sits inside a subdirectory this process creates and owns, never directly/// at the base: a predictable path stops being a weakness once the directory/// holding it cannot be pre-empted, and that is what [`Listener::bind`]/// enforces.pub fn default_socket_path() -> PathBuf { std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir) .join(DIR_NAME) .join(SOCKET_NAME)}
/// A listening socket that only yields connections from this user.#[derive(Debug)]pub struct Listener { inner: UnixListener, owner: u32, path: PathBuf,}
impl Listener { /// Binds `path`, hardening the directory and the socket file. /// /// The directory is created at `0700`. One that is already there is /// checked before anything is done to it — a symlink, something that is /// not a directory, a directory shared between users, or one belonging to /// another user is refused and left as it was — and tightened to `0700` /// only once it is this process's own. That refusal is the point. An /// attacker who pre-creates the directory is caught here, and one who /// tries to bind first inside a directory they do not own never gets the /// chance. /// /// The socket file is then set to `0600` explicitly, rather than left to /// whatever `umask` this process happens to be running under. pub fn bind(path: impl Into<PathBuf>) -> io::Result<Self> { let path = path.into(); if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) { secure_dir(dir)?; } let inner = UnixListener::bind(&path)?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; // The socket this process just created is owned by this process, so // its own metadata answers "who am I" without a second way to ask. let owner = std::fs::metadata(&path)?.uid(); Ok(Self { inner, owner, path }) }
/// The user this socket belongs to. Every accepted peer matches it. pub fn owner(&self) -> u32 { self.owner }
/// The path bound. pub fn path(&self) -> &Path { &self.path }
/// Accepts the next connection from this user, dropping any other. /// /// A peer from another user should not be able to reach a `0600` socket /// in a `0700` directory at all. It is checked anyway, because the cost /// is one syscall and the alternative is that a deployment which relaxes /// either permission by accident relaxes credential issuance with it. pub async fn accept(&self) -> io::Result<(UnixStream, Peer)> { loop { let (stream, _) = self.inner.accept().await?; let peer = match peer_of(&stream) { Ok(peer) => peer, Err(error) => { // A connection whose credentials cannot be read is not // one to serve on the assumption that it is probably // fine. warn!(%error, "dropped a connection whose peer credentials could not be read"); continue; } }; if peer.uid != self.owner { warn!( peer_uid = peer.uid, owner_uid = self.owner, "dropped a connection from another user" ); continue; } return Ok((stream, peer)); } }}
/// Reads the connected process's credentials off the socket.pub fn peer_of(stream: &UnixStream) -> io::Result<Peer> { let cred = stream.peer_cred()?; Ok(Peer { uid: cred.uid(), gid: cred.gid(), pid: cred.pid(), })}
/// Removes `path` when, and only when, nothing is listening on it.////// Best effort and silent on any doubt. Skipping a socket that should have/// been removed costs a clear "address in use" from [`Listener::bind`];/// removing one a live process still holds takes the socket away from a/// running daemon, which then serves a path nothing can reach while a second/// process binds a fresh one and quietly takes over credential issuance.pub fn remove_if_stale(path: &Path) { use std::os::unix::fs::FileTypeExt;
// `symlink_metadata`, not `exists`: a dangling symlink here is not a // socket this daemon left behind, and `Path::exists` answers `false` for // any stat error at all, which is the conflation this function exists to // avoid. let Ok(metadata) = std::fs::symlink_metadata(path) else { return; }; if !metadata.file_type().is_socket() { return; } // Only `ECONNREFUSED` means nothing is listening. Every other failure — // `EACCES` for a socket belonging to somebody else, `EAGAIN` or // `ENOBUFS` when a live backlog is full, `EMFILE` when this process is // out of descriptors — means a listener may well be there. match std::os::unix::net::UnixStream::connect(path) { Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => { let _ = std::fs::remove_file(path); } _ => {} }}
/// Creates `dir` at `0700`, or takes up an existing one it is allowed to.////// A directory that was already there is checked before anything is done to/// it, and refused when it is a symlink, when it is not a directory, when its/// sticky bit marks it as shared between users, or when it belongs to another/// user. Ownership is compared rather than inferred from whether a chmod/// succeeded: `chmod(2)` succeeds for a process holding `CAP_FOWNER`, so/// under root an attacker's pre-created directory was being narrowed and used/// instead of refused.////// One that passes all of that and is wider than `0700` is tightened to it,/// with a warning naming the directory: whatever umask made it left the node/// key only as private as that guess, and a run that quietly re-permissions a/// directory somebody named on the command line should at least say so.////// The state directory holding the node key gets the same treatment, from/// the same function.pub(crate) fn secure_dir(dir: &Path) -> io::Result<()> { use std::os::unix::fs::DirBuilderExt;
if let Some(parent) = dir.parent().filter(|parent| !parent.as_os_str().is_empty()) { std::fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(parent)?; } // The last component alone, and not recursive, so `mkdir(2)`'s own // atomicity answers "was this already here": a racing pre-creation // arrives as `AlreadyExists` and goes through the checks below rather // than being silently adopted. match std::fs::DirBuilder::new() .recursive(false) .mode(0o700) .create(dir) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => accept_existing(dir), Err(error) => Err(error), }}
/// Takes up a directory that was already there, or says why it will not.fn accept_existing(dir: &Path) -> io::Result<()> { // `symlink_metadata`, not `metadata`: a symlink planted at this path // redirects everything that follows, and `Path::is_dir` would follow it // and answer about the target. `remove_if_stale` takes the same care for // the socket file. let metadata = std::fs::symlink_metadata(dir)?; if metadata.file_type().is_symlink() { return Err(refused(dir, "is a symlink")); } if !metadata.is_dir() { return Err(refused(dir, "is not a directory")); } let mode = metadata.permissions().mode() & 0o7777; // The sticky bit is what marks a directory shared between users -- // `/tmp` is the case -- and tightening one takes it away from every // other process on the host. A restored data directory carries whatever // mode the snapshot had and no sticky bit, so it is tightened below the // way `docs/operations.md` says it is. if mode & 0o1000 != 0 { return Err(refused( dir, &format!( "is mode {mode:04o}: its sticky bit marks a directory shared between users. \ Name a directory of this deployment's own -- making this one private would \ take it away from everything else on this host." ), )); } // Before the chmod, not inferred from it. refuse_unless_owned(dir, creating_uid(dir)?)?; if mode & 0o077 != 0 { warn!( dir = %dir.display(), found = format!("{mode:04o}"), "tightening a directory this process did not create to 0700" ); std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; } Ok(())}
/// Refuses `dir` unless it belongs to `ours`.////// Split out from [`accept_existing`] because this is the refusal that/// closes the pre-creation race, and an unprivileged test cannot make a/// directory belong to somebody else to reach it any other way.fn refuse_unless_owned(dir: &Path, ours: u32) -> io::Result<()> { let owner = std::fs::symlink_metadata(dir)?.uid(); if owner != ours { return Err(refused( dir, &format!("belongs to uid {owner}, and this process writes as uid {ours}"), )); } Ok(())}
/// The uid this process creates files as, read off one it creates in `dir`.////// The same way [`Listener::bind`] learns it from the socket it just bound:/// a file this process created is owned by this process, so its own metadata/// answers "who am I" without a second way to ask. Doing it inside `dir`/// also settles that this process can write there, which everything holding/// this directory goes on to need.fn creating_uid(dir: &Path) -> io::Result<u32> { use std::os::unix::fs::OpenOptionsExt;
let probe = dir.join(probe_name()); let file = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&probe) .map_err(|error| refused(dir, &format!("cannot be written to: {error}")))?; let uid = file.metadata()?.uid(); drop(file); let _ = std::fs::remove_file(&probe); Ok(uid)}
/// A name for the ownership probe that no other caller is using.////// The process id alone is not enough: several threads open the same/// directory at once, and a name they shared would make the second one’s/// `create_new` collide with the first one’s probe.fn probe_name() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static NEXT: AtomicU64 = AtomicU64::new(0); format!( ".didbot-owner-check-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )}
/// A refusal naming the path and what is wrong with it.fn refused(dir: &Path, why: &str) -> io::Error { io::Error::new( io::ErrorKind::PermissionDenied, format!("{} {why}", dir.display()), )}
#[cfg(test)]mod tests { use super::*;
use crate::scratch::Scratch;
impl Scratch { fn socket(&self) -> PathBuf { self.0.join(SOCKET_NAME) } }
fn mode_of(path: &Path) -> u32 { std::fs::metadata(path).unwrap().permissions().mode() & 0o777 }
#[tokio::test] async fn binding_leaves_the_directory_and_the_socket_shut() { let scratch = Scratch::new("shut"); let listener = Listener::bind(scratch.socket()).unwrap();
assert_eq!(mode_of(&scratch.0), 0o700, "directory"); assert_eq!(mode_of(listener.path()), 0o600, "socket"); }
/// A readable directory this process owns is tightened, as before. #[tokio::test] async fn binding_tightens_a_readable_directory_it_owns() { let scratch = Scratch::new("tighten"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o755)).unwrap();
let _listener = Listener::bind(scratch.socket()).unwrap();
assert_eq!(mode_of(&scratch.0), 0o700); }
/// A directory shared between users is not one to tighten. `/tmp` is the /// case: as an ordinary user the chmod failed and startup was refused /// though nothing was wrong with `/tmp`, and under root it succeeded and /// took the sticky world-writable mode off it for the whole host. #[tokio::test] async fn binding_refuses_a_shared_directory_and_changes_nothing() { let scratch = Scratch::new("shared"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o1777)).unwrap();
let refusal = Listener::bind(scratch.socket()).expect_err("a shared directory is refused"); assert_eq!(refusal.kind(), io::ErrorKind::PermissionDenied); assert!( refusal.to_string().contains("1777"), "the refusal must name the mode it found: {refusal}" ); assert_eq!( std::fs::metadata(&scratch.0).unwrap().permissions().mode() & 0o7777, 0o1777, "left exactly as it was" ); assert!( !scratch.socket().exists(), "nothing was bound inside a directory this process refused" ); }
/// `Path::is_dir` follows a symlink and answers about its target, so a /// link planted at the socket's parent used to redirect both the chmod /// and the bind. `remove_if_stale` already takes this care for the /// socket file; the directory around it got none. #[tokio::test] async fn binding_refuses_a_symlink_at_the_sockets_directory() { let real = Scratch::new("symlink-target"); std::fs::create_dir_all(&real.0).unwrap(); std::fs::set_permissions(&real.0, std::fs::Permissions::from_mode(0o700)).unwrap(); let link = real.0.with_extension("link"); let _ = std::fs::remove_file(&link); std::os::unix::fs::symlink(&real.0, &link).unwrap();
let refusal = Listener::bind(link.join(SOCKET_NAME)).expect_err("a symlink here is refused"); assert!( refusal.to_string().contains("symlink"), "the refusal must say what it found: {refusal}" ); assert!( std::fs::read_dir(&real.0).unwrap().next().is_none(), "nothing was written through the link" ); let _ = std::fs::remove_file(&link); }
/// The ownership refusal, driven directly: an unprivileged test cannot /// make a directory belong to somebody else, but it can ask the check /// what it does when the two uids differ. Under root this is the case /// `set_permissions` never failed for, because `chmod(2)` succeeds for a /// process holding `CAP_FOWNER`. #[tokio::test] async fn a_directory_belonging_to_another_user_is_refused() { let scratch = Scratch::new("foreign"); std::fs::create_dir_all(&scratch.0).unwrap(); std::fs::set_permissions(&scratch.0, std::fs::Permissions::from_mode(0o700)).unwrap();
let ours = std::fs::metadata(&scratch.0).unwrap().uid(); let refusal = refuse_unless_owned(&scratch.0, ours.wrapping_add(1)) .expect_err("a directory belonging to another uid is refused"); assert!( refusal.to_string().contains("belongs to uid"), "the refusal must name both uids: {refusal}" ); assert_eq!(mode_of(&scratch.0), 0o700, "left exactly as it was"); }
#[tokio::test] async fn a_path_that_is_not_a_directory_is_refused() { let scratch = Scratch::new("notadir"); std::fs::create_dir_all(&scratch.0).unwrap(); let file = scratch.0.join("ordinary"); std::fs::write(&file, b"").unwrap();
let refusal = secure_dir(&file).expect_err("a file is not a directory to bind in"); assert!( refusal.to_string().contains("not a directory"), "the refusal must say what it found: {refusal}" ); }
#[tokio::test] async fn an_accepted_peer_is_this_process() { let scratch = Scratch::new("peer"); let listener = Listener::bind(scratch.socket()).unwrap(); let path = listener.path().to_path_buf();
let connect = tokio::spawn(async move { UnixStream::connect(&path).await.unwrap() }); let (_stream, peer) = listener.accept().await.unwrap(); let _client = connect.await.unwrap();
assert_eq!(peer.uid, listener.owner()); assert_eq!( peer.pid, Some(std::process::id() as i32), "the connecting process is this one" ); }
#[tokio::test] async fn a_socket_nothing_listens_on_is_removed() { let scratch = Scratch::new("stale"); let socket = scratch.socket(); drop(Listener::bind(&socket).unwrap());
remove_if_stale(&socket);
assert!(!socket.exists(), "a dead socket is cleared out of the way"); }
#[tokio::test] async fn a_socket_something_listens_on_is_left_alone() { let scratch = Scratch::new("live"); let listener = Listener::bind(scratch.socket()).unwrap();
remove_if_stale(listener.path());
assert!( listener.path().exists(), "a live socket is never taken from under its listener" ); }
#[tokio::test] async fn something_that_is_not_a_socket_is_left_alone() { let scratch = Scratch::new("notasocket"); std::fs::create_dir_all(&scratch.0).unwrap(); let file = scratch.0.join("ordinary"); std::fs::write(&file, b"").unwrap();
remove_if_stale(&file);
assert!(file.exists()); }}