#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
mod release_age;
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
mod unsupported;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
use unsupported as platform;
use std::ffi::OsStr;
use std::io::Write as _;
use std::net::{Ipv4Addr, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::{Context as _, Result, bail};
use openmoat_core::{Policy, SecretSource};
use openmoat_proxy::Credential;
use zeroize::Zeroizing;
use super::proxy::{self, Exit};
use super::sandbox::write_report;
use crate::cli::{IsolatedArgs, Netns, RunArgs};
use crate::context::{self, path_string};
use crate::environment::find_in;
use crate::exit::Code;
use crate::guard_relay::{self, Service, random_hex};
use crate::home::Home;
use crate::proxy_ca::Trust;
use crate::sandbox::Grants;
use crate::sandbox::Report;
use crate::sandbox::git_env;
use crate::taint::run::RUN_SESSION_ENV;
const PROXY_VARS: [&str; 6] = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
];
pub fn run(args: &RunArgs) -> Result<Code> {
let home = proxy::installed()?;
let ctx = context::eval_context(&home, None, None)?;
if ctx.project.is_none() {
bail!(
"`openmoat run` confines the agent to a project: start it in the project, not in \
your home directory, one of its ancestors or a filesystem root"
);
}
let (name, rest) = args.command.split_first().context("no agent to run")?;
let program = resolve(name)?;
let git = git_env::add(&|var| std::env::var(var).ok())?;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.context("listening on a loopback port for the proxy")?;
let port = listener.local_addr()?.port();
let mut policy = home.load_policy()?;
crate::install::self_paths::protect_recorded(&home, &mut policy)?;
let terminating = crate::proxy_ca::load(&home, &policy)?;
let trust = terminating
.as_ref()
.map(|t| Trust::write(&t.cert_pem))
.transpose()?;
let ca_notice = terminating.as_ref().and_then(|t| t.notice.clone());
let grants = Grants {
proxy_port: Some(port),
tmpdir: None,
program: Some(path_string(&program)),
writes: args.writes.iter().map(|w| resolved(w)).collect(),
terminals: platform::terminals(),
trust: trust.as_ref().map(|t| path_string(t.dir())),
};
let state = home.root();
let mut agent = if args.isolate {
platform::isolate(&policy, &ctx, grants, &program, state)?
} else {
platform::confine(&policy, &ctx, grants, &program, state)?
};
refuse_port_only(args.netns, agent.warning())?;
agent.command().args(rest);
agent
.command()
.envs(package_release_age(&policy, ctx.project.as_deref()));
for name in env_secrets(&policy) {
agent.command().env_remove(name);
}
let credential = session_credential()?;
let listener_addr = listener.local_addr()?;
let url = credential.proxy_url(listener_addr);
let run_session = credential.session().to_owned();
let (guard, guard_token) = guard_service(&home, &run_session)?;
let exit = Exit::open(&home, policy, ctx, Some(credential))?
.with_guard(guard)
.with_tls(terminating.map(|t| t.tls));
notice(&program, port, exit.session(), &agent, args)?;
let notices = ca_notice
.into_iter()
.chain(trust.iter().filter_map(Trust::notice));
for line in notices {
eprintln!("{line}");
}
std::thread::spawn(move || {
if let Err(error) = exit.serve(&listener) {
eprintln!("moat run: the proxy stopped, so the agent has no network: {error:#}");
}
});
for var in PROXY_VARS {
agent.command().env(var, url.as_str());
}
agent.command().env(RUN_SESSION_ENV, &run_session);
agent
.command()
.env(guard_relay::SOCKET_ENV, listener_addr.to_string())
.env(guard_relay::TOKEN_ENV, guard_token.as_str());
agent
.command()
.env_remove("NO_PROXY")
.env_remove("no_proxy");
agent.command().envs(git);
for (var, path) in trust.iter().flat_map(Trust::env) {
agent.command().env(var, path);
}
signal_hook::flag::register(
signal_hook::consts::SIGINT,
Arc::new(AtomicBool::new(false)),
)
.context("keeping OpenMoat running on Ctrl-C")?;
let status = agent
.status()
.with_context(|| format!("starting {}", program.display()))?;
Ok(if status.success() {
Code::Ok
} else {
Code::Failed
})
}
pub fn inside(args: &IsolatedArgs) -> Result<Code> {
platform::inside(args)
}
fn session_credential() -> Result<Credential> {
let random = random_hex(36).context("making the proxy credential")?;
let session = format!("run-{}-{}", crate::time::now_ms(), &random[..8]);
Ok(Credential::new(&session, random[8..].as_bytes())?)
}
fn guard_service(home: &Home, run_session: &str) -> Result<(Service, Zeroizing<String>)> {
let token = random_hex(32).context("making the hook token")?;
let moat = std::env::current_exe()
.and_then(std::fs::canonicalize)
.context("locating the moat binary")?;
let service = Service::new(home.clone(), run_session, token.as_bytes(), moat);
Ok((service, token))
}
const MAX_PACKAGE_CONFIG_BYTES: u64 = 1 << 20;
fn package_release_age(policy: &Policy, project: Option<&str>) -> Vec<(&'static str, String)> {
let in_project = |file: &str| project.map(|p| Path::new(p).join(file));
release_age::env(
policy.package_min_release_age_days(),
crate::time::now_ms(),
&release_age::Found {
inherited: &|name| std::env::var_os(name).is_some(),
project_has: &|file| in_project(file).is_some_and(|p| p.exists()),
project_file: &|file| {
let path = in_project(file)?;
let small =
std::fs::metadata(&path).is_ok_and(|m| m.len() < MAX_PACKAGE_CONFIG_BYTES);
small.then(|| std::fs::read_to_string(&path).ok()).flatten()
},
},
)
}
fn env_secrets(policy: &Policy) -> Vec<String> {
policy
.secrets
.iter()
.filter_map(|s| match &s.source {
SecretSource::Env(name) => Some(name.clone()),
_ => None,
})
.collect()
}
fn resolve(name: &OsStr) -> Result<PathBuf> {
let path = Path::new(name);
let found = if path.components().count() > 1 {
Some(path.to_path_buf())
} else {
let dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
find_in(&dirs, &[], &name.to_string_lossy())
};
let found = found.with_context(|| format!("{} is not on PATH", path.display()))?;
std::fs::canonicalize(&found).with_context(|| format!("resolving {}", found.display()))
}
fn resolved(path: &Path) -> String {
let resolved = std::fs::canonicalize(path).or_else(|_| context::absolute(path));
path_string(resolved.as_deref().unwrap_or(path))
}
pub fn unavailable() -> Option<String> {
platform::unavailable()
}
pub fn port_only_network() -> Option<String> {
platform::port_only_network()
}
fn refuse_port_only(netns: Netns, port_only: Option<&str>) -> Result<()> {
match (netns, port_only) {
(Netns::Require, Some(why)) => bail!(
"`openmoat run --netns=require`: the agent cannot get a network of its own here, so \
it does not start.\n{why}"
),
_ => Ok(()),
}
}
fn notice(
program: &Path,
port: u16,
session: &str,
agent: &platform::Confined,
args: &RunArgs,
) -> Result<()> {
let report: &Report = agent.report();
let mut err = std::io::stderr().lock();
let sees = if args.isolate {
"; it sees only the project, the read roots and --write paths"
} else {
""
};
let network = if agent.warning().is_some() {
format!("network only to port {port} on any host, OpenMoat's proxy")
} else {
"network only through OpenMoat's proxy".to_owned()
};
writeln!(
err,
"moat run: {} in a sandbox made from the policy{sees}, {network} on 127.0.0.1:{port} \
(audit session {session})",
program.display()
)?;
if let Some(warning) = agent.warning() {
writeln!(err, "{warning}")?;
}
if args.verbose {
writeln!(
err,
" proxy: 127.0.0.1:{port}\n \
turn the agent's own sandbox off (Codex: --sandbox danger-full-access): sandboxes \
do not nest, so the agent's cannot start in here. This one holds the agent and \
every command it starts together, so it is less precise per command than the \
agent's own sandbox."
)?;
write_report(&mut err, report)?;
} else {
writeln!(
err,
"moat run: turn the agent's own sandbox off (Codex: --sandbox danger-full-access); \
sandbox ready ({} stricter, {} wider; --verbose for details)",
report.losses.len(),
report.allowances.len()
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Netns, refuse_port_only};
#[test]
fn require_refuses_a_port_only_network() {
let why = "moat run: the network is restricted by port only";
let refused = refuse_port_only(Netns::Require, Some(why)).unwrap_err();
assert!(format!("{refused:#}").contains("--netns=require"));
assert!(format!("{refused:#}").contains(why));
assert!(refuse_port_only(Netns::Require, None).is_ok());
assert!(
refuse_port_only(Netns::Auto, Some(why)).is_ok(),
"the default"
);
assert!(refuse_port_only(Netns::Auto, None).is_ok());
}
}