use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use openmoat_hosts::{Host, SandboxFormat};
use toml_edit::DocumentMut;
use super::backend::SandboxBackend;
use super::{Plan, Report, codex, codex_config_path, cursor};
use crate::home::{Home, write_private};
use crate::install::{HostConfig, Recorded, SANDBOX_BACKUP, read_or_empty};
use crate::integrity::Lock;
pub fn hosts() -> impl Iterator<Item = Host> {
Host::ALL.into_iter().filter(|h| format(*h).is_some())
}
pub fn format(host: Host) -> Option<SandboxFormat> {
host.spec().sandbox.as_ref().map(|s| s.format)
}
pub fn backend(host: Host) -> Option<&'static dyn SandboxBackend> {
format(host).map(super::backend::backend)
}
pub fn host_with(format: SandboxFormat) -> Result<Host> {
hosts()
.find(|h| self::format(*h) == Some(format))
.with_context(|| format!("no host keeps its sandbox as {format:?}"))
}
pub fn unavailable(host: Host) -> Option<&'static str> {
unavailable_on(host, cfg!(windows))
}
pub const MASK_FILES_DENIED: bool = cfg!(target_os = "macos");
pub const OLD_SANDBOX_REMOVED: &str = "deny rules written for Claude Code's file tools, without \
sandbox settings (an older OpenMoat's are removed), so Claude Code can start";
fn unavailable_on(host: Host, windows: bool) -> Option<&'static str> {
let sandbox = host.spec().sandbox.as_ref()?;
sandbox.unavailable_on_windows.filter(|_| windows)
}
pub fn settings_path(home: &Home, host: Host) -> Result<PathBuf> {
let hooks = HostConfig::for_host(home, host)?.settings_path;
Ok(match &host.spec().sandbox {
Some(sandbox) => hooks.with_file_name(sandbox.file),
None => hooks,
})
}
pub fn write(home: &Home, host: Host, plan: &Plan, dry_run: bool) -> Result<bool> {
let path = settings_path(home, host)?;
let current = read_text(&path)?;
let Some(rendered) = render(home, host, plan, ¤t)
.with_context(|| format!("updating {}", path.display()))?
else {
return Ok(false);
};
if dry_run {
return Ok(true);
}
if let Some(owned) = rendered.claude_rules {
let mut recorded = Recorded::load(home)?;
if owned != recorded.claude_rules {
recorded.claude_rules = owned;
recorded.save(home)?;
}
}
crate::install::back_up(home, &path, SANDBOX_BACKUP)?;
write_private(&path, rendered.text.as_bytes())?;
Ok(true)
}
pub struct Rendered {
pub text: String,
pub claude_rules: Option<Vec<String>>,
}
pub fn render(home: &Home, host: Host, plan: &Plan, current: &str) -> Result<Option<Rendered>> {
match backend(host) {
Some(backend) => backend.render(home, host, plan, current),
None => Ok(None),
}
}
pub fn read_text(path: &Path) -> Result<String> {
if !path.exists() {
return Ok(String::new());
}
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))
}
pub fn parse_json(text: &str) -> Result<serde_json::Value> {
if text.trim().is_empty() {
return Ok(serde_json::Value::Object(serde_json::Map::new()));
}
serde_json::from_str(text).context("parsing the settings")
}
pub fn report(host: Host, plan: &Plan) -> &Report {
static NONE: Report = Report {
losses: Vec::new(),
allowances: Vec::new(),
};
backend(host).map_or(&NONE, |b| b.report(plan))
}
pub fn masks_unavailable() -> bool {
hosts()
.filter(|h| backend(*h).is_some_and(SandboxBackend::masks_secrets))
.any(|h| unavailable(h).is_some())
}
pub fn codex_profile_files(home: &Home) -> Result<Vec<PathBuf>> {
let path = codex_config_path(home)?;
if !path.is_file() {
return Ok(Vec::new());
}
let doc = read_toml(&path)?;
let ours = doc.get("permissions").and_then(|p| p.get(codex::PROFILE));
Ok(ours.map(|_| path).into_iter().collect())
}
pub fn cursor_sandbox_files(home: &Home) -> Result<Vec<PathBuf>> {
let path = settings_path(home, host_with(SandboxFormat::CursorSandbox)?)?;
if !path.is_file() {
return Ok(Vec::new());
}
let ours = cursor::remove(&mut read_or_empty(&path)?);
Ok(ours.then_some(path).into_iter().collect())
}
pub fn codex_part_digest(path: &Path) -> Result<String> {
Ok(crate::integrity::sha256_hex(
codex::owned_part(&read_toml(path)?).as_bytes(),
))
}
pub fn problems(
home: &Home,
host: Host,
plan: &Plan,
lock: Option<&Lock>,
) -> Result<Option<Vec<String>>> {
let Some(backend) = backend(host) else {
return Ok(None);
};
let path = settings_path(home, host)?;
if !path.parent().is_some_and(Path::is_dir) {
return Ok(None);
}
let mut problems = backend.problems(home, &path, plan, lock)?;
problems.dedup();
Ok(Some(problems))
}
pub fn read_toml(path: &Path) -> Result<DocumentMut> {
if !path.exists() {
return Ok(DocumentMut::new());
}
let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
text.parse()
.with_context(|| format!("parsing {}", path.display()))
}
#[cfg(test)]
mod tests;