use std::io::{BufRead as _, BufReader, ErrorKind, Write as _};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdout, Command, Output, Stdio};
use serde_json::Value;
use tempfile::TempDir;
pub const OK: i32 = 0;
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub const FAILED: i32 = 1;
pub const DENY: i32 = 2;
pub const ASK: i32 = 3;
pub const USAGE: i32 = 64;
pub struct Sandbox {
_dir: TempDir,
pub home: PathBuf,
}
impl Sandbox {
pub fn bare(host_dirs: &[&str]) -> Self {
Self::bare_in(&std::env::temp_dir(), host_dirs)
}
pub fn bare_in(parent: &Path, host_dirs: &[&str]) -> Self {
let dir = tempfile::tempdir_in(parent).unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
for host_dir in host_dirs {
std::fs::create_dir_all(home.join(host_dir)).unwrap();
}
Self { _dir: dir, home }
}
pub fn installed(host_dirs: &[&str]) -> Self {
Self::installed_in(&std::env::temp_dir(), host_dirs)
}
pub fn installed_asking(host_dirs: &[&str]) -> Self {
let sb = Self::installed(host_dirs);
let policy = sb.home.join(".moat/policy.yaml");
let text_now = std::fs::read_to_string(&policy).unwrap();
assert!(text_now.contains("\"*\": defer"), "{text_now}");
std::fs::write(&policy, text_now.replacen("\"*\": defer", "\"*\": ask", 1)).unwrap();
let out = sb.moat_as_person(&["doctor", "--accept"]);
assert!(text(&out).contains("re-pinned"), "{}", text(&out));
sb
}
pub fn installed_in(parent: &Path, host_dirs: &[&str]) -> Self {
let sb = Self::bare_in(parent, host_dirs);
let out = sb.moat(&["init", "--yes"]);
assert_eq!(out.status.code(), Some(0), "moat init: {}", text(&out));
sb
}
pub fn project(&self) -> PathBuf {
let project = self.home.join("proj");
std::fs::create_dir_all(project.join(".git")).unwrap();
project
}
pub fn command(&self) -> Command {
self.command_at(Path::new(env!("CARGO_BIN_EXE_moat")))
}
pub fn command_at(&self, program: &Path) -> Command {
self.configure(Command::new(program))
}
pub fn configure(&self, mut cmd: Command) -> Command {
cmd.current_dir(&self.home)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", &self.home)
.env("USERPROFILE", &self.home);
if let Some(root) = std::env::var_os("SYSTEMROOT") {
cmd.env("SYSTEMROOT", root);
}
cmd
}
pub fn moat(&self, args: &[&str]) -> Output {
output(self.command().args(args), None)
}
pub fn moat_stdin(&self, args: &[&str], stdin: &str) -> Output {
output(self.command().args(args), Some(stdin))
}
pub fn moat_as_person(&self, args: &[&str]) -> Output {
output(self.command().args(args).env("MOAT_ASSUME_TTY", "1"), None)
}
pub fn guard(&self, host: &str, payload: &str) -> Output {
self.moat_stdin(&["guard", "--host", host], payload)
}
pub fn guard_bash(&self, session: &str, cwd: &Path, command: &str) -> (String, String) {
verdict_and_reason(&self.guard("claude-code", &bash_payload(session, cwd, command)))
}
pub fn use_free_proxy_port(&self) -> u16 {
let port = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let path = self.home.join(".moat/policy.yaml");
let mut policy: serde_json::Value =
openmoat_core::yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
policy["sandbox"]["proxy_port"] = port.into();
std::fs::write(&path, openmoat_core::yaml::to_string(&policy).unwrap()).unwrap();
let accepted = self.moat_as_person(&["doctor", "--accept"]);
assert!(text(&accepted).contains("re-pinned"), "{}", text(&accepted));
let synced = self.moat_as_person(&["sandbox", "sync"]);
assert_eq!(synced.status.code(), Some(0), "{}", text(&synced));
port
}
pub fn start_proxy(&self) -> RunningProxy {
let mut child = self
.command()
.arg("proxy")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
let running = RunningProxy {
child,
_stdout: stdout,
};
assert!(line.contains("listening on"), "moat proxy: {line:?}");
running
}
}
pub struct RunningProxy {
child: Child,
_stdout: BufReader<ChildStdout>,
}
impl Drop for RunningProxy {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub fn output(cmd: &mut Command, stdin: Option<&str>) -> Output {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut tries = 0;
let mut child = loop {
match cmd.spawn() {
Err(e) if e.kind() == ErrorKind::ExecutableFileBusy && tries < 50 => {
tries += 1;
std::thread::sleep(std::time::Duration::from_millis(20));
}
spawned => break spawned.expect("spawning moat"),
}
};
let mut pipe = child.stdin.take().expect("stdin is piped");
if let Some(input) = stdin {
if let Err(error) = pipe.write_all(input.as_bytes()) {
assert_eq!(
error.kind(),
ErrorKind::BrokenPipe,
"writing stdin: {error}"
);
}
}
drop(pipe);
child.wait_with_output().unwrap()
}
pub fn fixture(relative: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/hosts")
.join(relative);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
pub fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
pub fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
pub fn text(out: &Output) -> String {
format!("{}{}", stdout(out), stderr(out))
}
pub fn json(out: &Output) -> Value {
serde_json::from_str(stdout(out).trim()).unwrap_or(Value::Null)
}
pub fn hook_output(out: &Output) -> Value {
if out.status.code() == Some(0) && stdout(out).trim().is_empty() {
return serde_json::json!({"permissionDecision": "defer", "permissionDecisionReason": ""});
}
json(out)["hookSpecificOutput"].clone()
}
pub fn verdict(out: &Output) -> String {
hook_output(out)["permissionDecision"]
.as_str()
.unwrap_or_default()
.to_owned()
}
pub fn verdict_and_reason(out: &Output) -> (String, String) {
(
verdict(out),
hook_output(out)["permissionDecisionReason"].to_string(),
)
}
pub fn bash_payload(session: &str, cwd: &Path, command: &str) -> String {
serde_json::json!({
"session_id": session, "cwd": cwd.to_string_lossy(), "hook_event_name": "PreToolUse",
"tool_name": "Bash", "tool_input": {"command": command}, "tool_use_id": "t1"
})
.to_string()
}
#[cfg(unix)] pub const REQUIRE_HOST_BINARIES: &str = "MOAT_REQUIRE_HOST_BINARIES";
#[cfg(unix)]
pub fn skip_without_host_binary(what: &str) {
assert!(
std::env::var_os(REQUIRE_HOST_BINARIES).is_none(),
"{REQUIRE_HOST_BINARIES} is set, so this run must have the host binary for {what}"
);
eprintln!("skipped: {what} (no host binary; the CI standard tier job runs it)");
}