use std::io::Cursor;
use std::os::unix::fs::PermissionsExt as _;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Instant;
use openmoat_audit::Store;
use openmoat_hosts::Host;
use super::client::{connect_retrying, exchange};
use super::service::RULE_TOKEN;
use super::wire::{NONCE_LEN, REPLY_MAGIC, REQUEST_MAGIC, Request};
use super::*;
use crate::home::Home;
const RUN: &str = "run-1-abcdef12";
fn token() -> &'static str {
static TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TOKEN.get_or_init(|| random_hex(32).unwrap().to_string())
}
fn fresh_nonce() -> String {
random_hex(NONCE_LEN / 2).unwrap().to_string()
}
impl Stream for UnixStream {
fn set_timeouts(&self, limit: Duration) -> std::io::Result<()> {
self.set_read_timeout(Some(limit))?;
self.set_write_timeout(Some(limit))
}
}
fn request(token: &str, payload: &str) -> Request {
Request {
token: Zeroizing::new(token.as_bytes().to_vec()),
nonce: fresh_nonce(),
host: Host::ClaudeCode.id().to_owned(),
cwd: "/p".to_owned(),
continue_cli: true,
payload: payload.to_owned(),
}
}
fn reply(nonce: &str, code: u8) -> Reply {
Reply {
nonce: nonce.to_owned(),
code,
stdout: b"{\"hookSpecificOutput\":{}}\n".to_vec(),
stderr: b"moat: trace 1\n".to_vec(),
}
}
fn bytes_of(write: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
let mut out = Vec::new();
write(&mut out);
out
}
#[test]
fn a_request_and_a_reply_read_back_as_written() {
let sent = request(token(), "{\"tool_name\":\"Bash\"}");
let wire = bytes_of(|out| sent.write_to(out).unwrap());
assert!(wire.starts_with(REQUEST_MAGIC));
assert_eq!(Request::read_from(&mut Cursor::new(&wire)).unwrap(), sent);
let answer = reply(&sent.nonce, 2);
let wire = bytes_of(|out| answer.write_to(out).unwrap());
assert!(wire.starts_with(REPLY_MAGIC));
let read = Reply::read_from(&mut Cursor::new(&wire), &sent.nonce).unwrap();
assert_eq!(read, answer);
}
#[test]
fn an_oversize_payload_is_refused_before_it_is_read() {
let limit = usize::try_from(MAX_PAYLOAD_BYTES).unwrap();
let at_limit = request(token(), &"x".repeat(limit));
let wire = bytes_of(|out| at_limit.write_to(out).unwrap());
assert!(Request::read_from(&mut Cursor::new(&wire)).is_ok());
let over = request(token(), &"x".repeat(limit + 1));
let wire = bytes_of(|out| over.write_to(out).unwrap());
let error = Request::read_from(&mut Cursor::new(&wire)).unwrap_err();
assert!(
format!("{error:#}").contains("larger than OpenMoat reads"),
"{error:#}"
);
let mut header = wire[..wire.len() - limit - 1 - 4].to_vec();
header.extend_from_slice(&u32::MAX.to_be_bytes());
assert!(Request::read_from(&mut Cursor::new(&header)).is_err());
}
#[test]
fn a_malformed_or_forged_reply_is_refused() {
let (ours, theirs) = (fresh_nonce(), fresh_nonce());
assert_ne!(ours, theirs);
let nonce = ours.as_str();
let good = bytes_of(|out| reply(nonce, 0).write_to(out).unwrap());
let other_nonce = bytes_of(|out| reply(&theirs, 0).write_to(out).unwrap());
let mut wrong_magic = good.clone();
wrong_magic[0] = b'X';
let truncated = good[..good.len() - 3].to_vec();
let mut cases = vec![
("another request's nonce", other_nonce),
("wrong magic", wrong_magic),
("truncated", truncated),
("empty", Vec::new()),
("an HTTP answer", b"HTTP/1.1 200 OK\r\n\r\n".to_vec()),
];
for code in [1u8, 3, 64, 255] {
cases.push((
"an exit code other than 0 or 2",
bytes_of(|out| reply(nonce, code).write_to(out).unwrap()),
));
}
for (what, wire) in cases {
assert!(
Reply::read_from(&mut Cursor::new(&wire), nonce).is_err(),
"{what} was accepted"
);
}
assert!(Reply::read_from(&mut Cursor::new(&good), nonce).is_ok());
}
#[test]
fn a_request_that_is_not_one_is_refused() {
let sent = request(token(), "{}");
let mut short_nonce = sent.clone();
short_nonce.nonce = "abc".to_owned();
let mut not_hex = sent.clone();
not_hex.nonce = "z".repeat(NONCE_LEN);
for bad in [short_nonce, not_hex] {
let wire = bytes_of(|out| bad.write_to(out).unwrap());
assert!(Request::read_from(&mut Cursor::new(&wire)).is_err());
}
assert!(Request::read_from(&mut Cursor::new(b"GET / HTTP/1.1\r\n\r\n")).is_err());
}
#[test]
fn no_answer_in_time_is_an_error() {
let (ours, _silent) = UnixStream::pair().unwrap();
let started = Instant::now();
let deadline = started + Duration::from_millis(200);
let error = exchange(ours, &request(token(), "{}"), deadline).unwrap_err();
assert!(started.elapsed() < Duration::from_secs(5), "{error:#}");
let (ours, mut theirs) = UnixStream::pair().unwrap();
assert!(exchange(ours, &request(token(), "{}"), Instant::now()).is_err());
let mut sent = Vec::new();
theirs.read_to_end(&mut sent).unwrap();
assert!(sent.is_empty(), "nothing was sent");
}
#[test]
fn only_a_brief_sandbox_refusal_is_connected_again() {
use std::io::{Error, ErrorKind};
let deadline = || Instant::now() + Duration::from_secs(5);
let mut tries = 0;
let opened = connect_retrying(
|_| {
tries += 1;
if tries < 3 {
Err(Error::from(ErrorKind::PermissionDenied))
} else {
Ok(tries)
}
},
deadline(),
);
assert_eq!(opened.unwrap(), 3);
let mut tries = 0;
let refused = connect_retrying(
|_| -> std::io::Result<()> {
tries += 1;
Err(Error::from(ErrorKind::PermissionDenied))
},
deadline(),
);
assert_eq!(refused.unwrap_err().kind(), ErrorKind::PermissionDenied);
assert_eq!(tries, 10, "a lasting refusal is given up on");
let mut tries = 0;
let closed = connect_retrying(
|_| -> std::io::Result<()> {
tries += 1;
Err(Error::from(ErrorKind::ConnectionRefused))
},
deadline(),
);
assert_eq!(closed.unwrap_err().kind(), ErrorKind::ConnectionRefused);
assert_eq!(tries, 1, "nothing listening is not tried again");
let past = connect_retrying(|_| Ok(()), Instant::now());
assert_eq!(past.unwrap_err().kind(), ErrorKind::TimedOut);
}
fn configured_with(pairs: &[(&str, &str)]) -> Option<Result<Endpoint>> {
configured(&|var: &str| {
pairs
.iter()
.find(|(k, _)| *k == var)
.map(|(_, v)| (*v).to_owned())
})
}
#[test]
fn the_environment_names_both_or_neither() {
assert!(configured_with(&[]).is_none());
for only_one in [(SOCKET_ENV, "127.0.0.1:9"), (TOKEN_ENV, "t")] {
assert!(configured_with(&[only_one]).unwrap().is_err());
}
for socket in ["192.0.2.1:9", "/tmp/moat.sock", ""] {
let pairs = [(SOCKET_ENV, socket), (TOKEN_ENV, "t")];
assert!(configured_with(&pairs).unwrap().is_err(), "{socket}");
}
let empty_token = [(SOCKET_ENV, "127.0.0.1:9"), (TOKEN_ENV, "")];
assert!(configured_with(&empty_token).unwrap().is_err());
let good = [(SOCKET_ENV, "127.0.0.1:9"), (TOKEN_ENV, "t")];
assert!(configured_with(&good).unwrap().is_ok());
}
fn service(code: i32) -> (tempfile::TempDir, Home, Service) {
let dir = tempfile::tempdir().unwrap();
let home = Home::at(dir.path().join("state"));
std::fs::create_dir_all(home.root()).unwrap();
Store::open(&home.audit_path()).unwrap();
let moat = dir.path().join("moat");
let script = format!(
"#!/bin/sh\necho \"args=$*\"\necho \"run=$MOAT_RUN_SESSION socket=${{MOAT_GUARD_SOCKET-unset}}\"\n\
cat\necho stderr-line >&2\nexit {code}\n"
);
std::fs::write(&moat, script).unwrap();
std::fs::set_permissions(&moat, std::fs::Permissions::from_mode(0o755)).unwrap();
let service = Service::new(home.clone(), RUN, token().as_bytes(), PathBuf::from(&moat));
(dir, home, service)
}
fn ask(service: &Service, sent: &Request) -> Result<Reply> {
let (ours, theirs) = UnixStream::pair().unwrap();
let server = std::thread::scope(|scope| {
let handle = scope.spawn(|| service.answer(theirs));
let reply = exchange(ours, sent, Instant::now() + Duration::from_secs(10));
(reply, handle.join().unwrap())
});
server.0
}
#[test]
fn a_call_with_the_token_is_decided_outside_with_the_runs_session() {
let (_dir, home, service) = service(0);
let reply = ask(&service, &request(token(), "{\"payload\":1}")).unwrap();
assert_eq!(reply.code, 0);
let out = String::from_utf8(reply.stdout).unwrap();
assert!(
out.contains("args=guard --host claude-code --relayed"),
"{out}"
);
assert!(out.contains(&format!("run={RUN} socket=unset")), "{out}");
assert!(out.contains("{\"payload\":1}"), "{out}");
assert_eq!(reply.stderr, b"stderr-line\n");
assert_eq!(home.open_audit().unwrap().count().unwrap(), 0);
}
#[test]
fn a_failing_guard_is_a_deny() {
let (_dir, _home, service) = service(7);
let reply = ask(&service, &request(token(), "{}")).unwrap();
assert_eq!(reply.code, 2);
}
#[test]
fn a_wrong_token_gets_no_answer_and_is_recorded() {
let (_dir, home, service) = service(0);
let mut near = token()[..token().len() - 1].to_owned();
near.push(if token().ends_with('0') { '1' } else { '0' });
for wrong in [near.as_str(), "", "x"] {
let error = ask(&service, &request(wrong, "{}")).unwrap_err();
assert!(format!("{error:#}").contains("answer"), "{error:#}");
}
let rows = home.open_audit().unwrap().session(RUN).unwrap();
assert_eq!(rows.len(), 3);
for row in rows {
assert_eq!(row.rules, [RULE_TOKEN]);
assert_eq!(row.verdict, openmoat_core::Verdict::Deny);
assert!(row.reasons[0].contains("claude-code"), "{:?}", row.reasons);
assert!(!row.reasons[0].contains(token()));
}
}
#[test]
fn random_hex_is_hex_of_the_length_asked_and_differs() {
let (a, b) = (random_hex(32).unwrap(), random_hex(32).unwrap());
assert_eq!(a.len(), 64);
assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
assert_ne!(*a, *b);
}