use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use openmoat_hosts::{Host, SandboxFormat};
use toml_edit::DocumentMut;
use super::{Plan, Report, claude, 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 unavailable(host: Host) -> Option<&'static str> {
unavailable_on(host, cfg!(windows))
}
pub const MASK_FILES_DENIED: bool = cfg!(target_os = "macos");
pub fn claude_settings(home: &Home) -> Result<serde_json::Value> {
read_or_empty(&settings_path(home, Host::ClaudeCode)?)
}
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>> {
let (changed, text, claude_rules) = match format(host) {
Some(SandboxFormat::CodexProfile) => {
let mut doc: DocumentMut = current.parse().context("parsing the settings")?;
let changed = codex::apply(&mut doc, &plan.codex)?;
(changed, doc.to_string(), None)
}
Some(SandboxFormat::CursorSandbox) => {
if unavailable(host).is_some() {
return Ok(None);
}
let mut root = parse_json(current)?;
let changed = cursor::apply(&mut root, &plan.cursor)?;
(changed, serde_json::to_string_pretty(&root)? + "\n", None)
}
Some(SandboxFormat::ClaudeSettings) | None => {
let mut owned = Recorded::load(home)?.claude_rules;
let mut root = parse_json(current)?;
let changed = if unavailable(host).is_some() {
claude::apply_rules(&mut root, &plan.claude, &mut owned)
} else {
claude::apply(&mut root, &plan.claude, &mut owned)
}?;
(
changed,
serde_json::to_string_pretty(&root)? + "\n",
Some(owned),
)
}
};
Ok(changed.then_some(Rendered { text, claude_rules }))
}
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 {
match format(host) {
Some(SandboxFormat::CodexProfile) => &plan.codex.report,
Some(SandboxFormat::CursorSandbox) => &plan.cursor.report,
Some(SandboxFormat::ClaudeSettings) | None => &plan.claude.report,
}
}
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::Cursor)?;
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 path = settings_path(home, host)?;
if !path.parent().is_some_and(Path::is_dir) {
return Ok(None);
}
let out_of_date = "differs from the policy; run `openmoat sandbox sync`".to_owned();
let mut problems = match format(host) {
Some(SandboxFormat::CodexProfile) => {
let doc = read_toml(&path)?;
let mut found = codex::weaknesses(&doc, plan.proxy_port.is_some());
if !codex::in_sync(&doc, &plan.codex) {
found.push(out_of_date);
}
if lock.is_some_and(|l| !l.pins_codex_profile(&path)) {
found.push("not pinned by the lock; run `openmoat sandbox sync`".to_owned());
}
found
}
Some(SandboxFormat::CursorSandbox) => {
let root = read_or_empty(&path)?;
let mut found = cursor::weaknesses(&root);
if !cursor::in_sync(&root, &plan.cursor) {
found.push(out_of_date);
}
if lock.is_some_and(|l| path.is_file() && !l.pins(&path)) {
found.push("not pinned by the lock; run `openmoat sandbox sync`".to_owned());
}
found
}
Some(SandboxFormat::ClaudeSettings) | None => {
let root = read_or_empty(&path)?;
let mut found = claude::weaknesses(&root, plan.claude.block_reads, plan.proxy_port);
found.extend(claude::mask::weaknesses(&root, &plan.claude));
let owned = Recorded::load(home)?.claude_rules;
if !claude::in_sync(&root, &plan.claude, &owned) {
found.push(out_of_date);
}
found
}
};
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;