Files
wanderbooks/crates/ledger-host/tests/windows_pipe.rs
T
swaitsandClaude Fable 5.1 bd6448532e feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)
Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge,
the opener, the books server and the command line is gone: they build on
every OS, and the Unix behaviour is unchanged (the existing over-the-wire
suites pass on Linux).

`ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600
sockets as before, peer uid unchanged (the peer functions moved here).
Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>`
made with a DACL of one allow entry for this account's SID, remote
clients refused, the first instance made once (a second server is
refused, as a held lock refuses it on Unix), and 16 idle instances kept
waiting so a client never finds all busy while the server makes the next.
Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's
process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and
compares that process's token user with its own SID; any doubt is "not
us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot
act as the client. A blocking client with a read limit is polled with
PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has
gone reads as the end of the stream, as a closed socket does. Stop
signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has
SIGTERM, SIGINT and SIGHUP.

The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
leaving the opener's job when the job lets it: an agent's job must not
take the host down, as an app's systemd scope must not on Linux) with a
fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A
replaced program is told by size and last write. Git for Windows is found
and a quiet git is stopped with its tree (`taskkill /T`). The test
harness of ledger-cli finds its root by `default_root`, cleans a child's
environment per OS and reaches the server over its pipe; the bridge's
scripted-server tests stay Unix-only.

No new dependency (tokio's named pipes and reqwest's `windows_named_pipe`
were already in the build); `windows-sys` stays in ledger-store.

Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only),
ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`,
Windows variants of the host and start tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:02:12 -06:00

54 lines
1.9 KiB
Rust

//! The books' named pipes on Windows (P11c, D-62, plan 3.9): what keeps a
//! second account out of them, in the two ways the design has (the pipe's
//! DACL, and the SID check of the process at the other end). Windows job
//! only.
#![cfg(windows)]
use ledger_mcp::ipc::{Endpoint, Listener};
use ledger_store::winsec;
use std::path::Path;
/// The process id of `image` (a system program), if `tasklist` finds it.
fn pid_of(image: &str) -> Option<u32> {
let out = std::process::Command::new("tasklist")
.args(["/FI", &format!("IMAGENAME eq {image}"), "/FO", "CSV", "/NH"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
// "services.exe","700","Services","0","12,345 K"
text.lines()
.find(|l| l.to_lowercase().contains(&image.to_lowercase()))?
.split(',')
.nth(1)?
.trim_matches('"')
.parse()
.ok()
}
#[tokio::test]
async fn a_second_user_cannot_open_the_pipe() {
let dir = tempfile::tempdir().unwrap();
let endpoint = Endpoint::new(dir.path(), "lease.sock").unwrap();
let _listener = Listener::bind(&endpoint).unwrap();
// The pipe's own DACL names this account and no one else: Everyone,
// Users and Authenticated Users are not on it, so the system itself
// refuses another account's open.
let pipe = Path::new(endpoint.pipe_name());
assert!(
winsec::is_private(pipe).unwrap(),
"the pipe is open to more than this account"
);
// And a process of another account is never taken for this one: this
// account's own is, and a system service (SYSTEM) is not.
assert!(winsec::pid_is_us(std::process::id()));
if let Some(pid) = pid_of("services.exe") {
assert!(
!winsec::pid_is_us(pid),
"a SYSTEM process ({pid}) was taken for this account"
);
}
assert!(!winsec::pid_is_us(0));
}