use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use anyhow::{Result, bail};
use openmoat_core::{EvalContext, Policy};
use crate::cli::IsolatedArgs;
use crate::environment::find_in;
use crate::exit::Code;
use crate::os_version;
use crate::sandbox::{Grants, Report, seatbelt_profile};
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
const CONTAINER: &str = "container";
pub struct Confined {
command: Command,
report: Report,
}
pub fn confine(
policy: &Policy,
ctx: &EvalContext,
grants: Grants,
program: &Path,
_state: &Path,
) -> Result<Confined> {
if let Some(reason) = os_version::run_floor(os_version::macos().as_deref()) {
bail!("{}", refusal(&reason));
}
let generated = seatbelt_profile(policy, ctx, grants)?;
let mut command = Command::new(SANDBOX_EXEC);
command.arg("-p").arg(&generated.profile).arg(program);
Ok(Confined {
command,
report: generated.report,
})
}
pub fn isolate(_: &Policy, _: &EvalContext, _: Grants, _: &Path, _: &Path) -> Result<Confined> {
bail!("{}", stub_message(&search_path(), CONTAINER))
}
pub fn inside(_: &IsolatedArgs) -> Result<Code> {
bail!("only `openmoat run --isolate` on Linux starts this")
}
pub fn unavailable() -> Option<String> {
os_version::run_floor(os_version::macos().as_deref()).map(|reason| refusal(&reason))
}
pub fn port_only_network() -> Option<String> {
None
}
pub fn terminals() -> Vec<String> {
use std::os::fd::{AsFd as _, BorrowedFd};
use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
let stat = |fd: BorrowedFd<'_>| {
let file = std::fs::File::from(fd.try_clone_to_owned().ok()?);
file.metadata().ok()
};
let (stdin, stdout, stderr) = (std::io::stdin(), std::io::stdout(), std::io::stderr());
let streams = [stdin.as_fd(), stdout.as_fd(), stderr.as_fd()];
let devices: Vec<u64> = streams
.into_iter()
.filter_map(stat)
.filter(|m| m.file_type().is_char_device())
.map(|m| m.rdev())
.collect();
if devices.is_empty() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir("/dev") else {
return Vec::new();
};
let mut found: Vec<String> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("ttys"))
.filter_map(|e| {
let meta = std::fs::symlink_metadata(e.path()).ok()?;
let terminal = meta.file_type().is_char_device() && devices.contains(&meta.rdev());
terminal.then(|| crate::context::path_string(&e.path()))
})
.collect();
found.sort();
found
}
impl Confined {
pub fn command(&mut self) -> &mut Command {
&mut self.command
}
pub fn report(&self) -> &Report {
&self.report
}
#[expect(
clippy::unused_self,
reason = "the method every platform's `Confined` has; Linux's reads itself"
)]
pub fn warning(&self) -> Option<&str> {
None
}
pub fn status(mut self) -> Result<ExitStatus> {
Ok(self.command.status()?)
}
}
fn refusal(reason: &str) -> String {
format!(
"`openmoat run` {reason}. The agent was not started. The agent hooks still apply the \
policy to each tool call: start the agent without `openmoat run`"
)
}
fn search_path() -> Vec<PathBuf> {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default()
}
fn stub_message(path: &[PathBuf], program: &str) -> String {
let found = find_in(path, &[], program);
if found.is_some() {
format!(
"`openmoat run --isolate` on macOS: not yet implemented, see ADR-023 (#175). `{program}` \
is installed; the virtualization work ships in follow-up PRs. For now the Isolated \
tier is Linux-only; `openmoat run` without --isolate is the Lightweight tier"
)
} else {
format!(
"`openmoat run --isolate` on macOS needs Apple's `containerization` toolchain (ADR-023, \
#175): `{program}` is not on PATH. Install it with `brew install --cask container` \
or from https://github.com/apple/container/releases, then re-run. Refusing rather \
than starting the agent in a weaker tier"
)
}
}
#[cfg(test)]
mod tests {
use std::fs::{self, File};
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
use super::*;
fn with_executable(dir: &Path, name: &str) -> PathBuf {
let bin = dir.join(name);
File::create(&bin).expect("creating a fake `container` binary");
fs::set_permissions(&bin, fs::Permissions::from_mode(0o755))
.expect("marking it executable");
dir.to_path_buf()
}
#[test]
fn exit_code_contract_is_64_whichever_branch_the_stub_takes() {
assert_eq!(Code::Usage as u8, 64);
}
#[test]
fn an_old_macos_is_refused_with_a_plain_reason() {
let reason = os_version::run_floor(Some("12.7")).unwrap();
let message = refusal(&reason);
assert!(
message.starts_with("`openmoat run` needs macOS 13 or later"),
"{message}"
);
assert!(
message.contains("hooks still apply the policy"),
"{message}"
);
assert!(!message.contains("unbound variable"), "{message}");
}
#[test]
fn the_terminals_are_the_streams_own_ttys() {
for terminal in terminals() {
assert!(terminal.starts_with("/dev/ttys"), "{terminal}");
}
}
#[test]
fn missing_container_points_at_the_install_command() {
let dir = tempfile::tempdir().expect("a scratch dir");
let path = vec![dir.path().to_path_buf()];
let message = stub_message(&path, "container-not-here");
assert!(message.contains("ADR-023"), "{message}");
assert!(message.contains("#175"), "{message}");
assert!(
message.contains("brew install --cask container"),
"{message}"
);
assert!(
!message.contains("not yet implemented"),
"missing is a different branch: {message}"
);
}
#[test]
fn present_container_says_not_yet_implemented() {
let dir = tempfile::tempdir().expect("a scratch dir");
let path = vec![with_executable(dir.path(), "container")];
let message = stub_message(&path, "container");
assert!(message.contains("ADR-023"), "{message}");
assert!(message.contains("#175"), "{message}");
assert!(message.contains("not yet implemented"), "{message}");
assert!(
!message.contains("brew install"),
"present is a different branch: {message}"
);
}
#[test]
fn stub_message_never_promises_a_lightweight_fallback() {
let dir = tempfile::tempdir().expect("a scratch dir");
let missing = stub_message(&[dir.path().to_path_buf()], "nope");
let present = stub_message(&[with_executable(dir.path(), "container")], "container");
for message in [missing, present] {
let lowered = message.to_lowercase();
assert!(!lowered.contains("falls back"), "{message}");
assert!(!lowered.contains("falling back"), "{message}");
}
}
}