use std::path::Path;
use anyhow::{Result, bail};
use serde_json::{Map, Value, json};
use super::{BLOCK_READS, Generated};
pub fn apply(root: &mut Value, generated: &Generated) -> Result<bool> {
let Value::Object(top) = root else {
bail!("the settings file is not a JSON object");
};
let before = top.clone();
let sandbox = object_at(top, "sandbox")?;
for (key, value) in &generated.sandbox {
match value {
Value::Object(owned) => {
let nested = object_at(sandbox, key)?;
for (sub, v) in owned {
nested.insert(sub.clone(), v.clone());
}
}
other => {
sandbox.insert(key.clone(), other.clone());
}
}
}
if generated.block_reads {
object_at(top, "permissions")?.insert(BLOCK_READS.into(), json!(true));
}
Ok(*top != before)
}
const OWNED: &[(&str, &[&str])] = &[
("enabled", &[]),
("failIfUnavailable", &[]),
("allowUnsandboxedCommands", &[]),
("excludedCommands", &[]),
(
"filesystem",
&["denyRead", "allowRead", "denyWrite", "allowWrite"],
),
(
"network",
&[
"allowedDomains",
"deniedDomains",
"strictAllowlist",
"httpProxyPort",
"socksProxyPort",
],
),
];
pub fn remove(root: &mut Value) -> bool {
let Value::Object(top) = root else {
return false;
};
let mut removed = false;
if let Some(Value::Object(sandbox)) = top.get_mut("sandbox") {
for (key, subs) in OWNED {
if subs.is_empty() {
removed |= sandbox.remove(*key).is_some();
} else if let Some(Value::Object(nested)) = sandbox.get_mut(*key) {
let before = nested.len();
nested.retain(|sub, _| !subs.contains(&sub.as_str()));
if nested.len() != before {
removed = true;
if nested.is_empty() {
sandbox.remove(*key);
}
}
}
}
}
if let Some(Value::Object(permissions)) = top.get_mut("permissions") {
removed |= permissions.remove(BLOCK_READS).is_some();
}
if removed {
for key in ["sandbox", "permissions"] {
if top
.get(key)
.and_then(Value::as_object)
.is_some_and(Map::is_empty)
{
top.remove(key);
}
}
}
removed
}
pub fn protect(generated: &mut Generated, settings: &Path) {
let Some(dir) = settings.parent() else {
return;
};
let deny = generated
.sandbox
.get_mut("filesystem")
.and_then(|f| f.get_mut("denyWrite"))
.and_then(Value::as_array_mut);
if let Some(deny) = deny {
for name in ["settings.json", "settings.local.json"] {
let path = json!(crate::context::path_string(&dir.join(name)));
if !deny.contains(&path) {
deny.push(path);
}
}
}
}
pub fn in_sync(root: &Value, generated: &Generated) -> bool {
let owned = generated.sandbox.iter().all(|(key, value)| match value {
Value::Object(owned) => owned
.iter()
.all(|(sub, v)| root.pointer(&format!("/sandbox/{key}/{sub}")) == Some(v)),
other => root.pointer(&format!("/sandbox/{key}")) == Some(other),
});
owned && (!generated.block_reads || block_reads_on(root))
}
fn block_reads_on(root: &Value) -> bool {
root.pointer(&format!("/permissions/{BLOCK_READS}")) == Some(&json!(true))
}
pub fn weaknesses(root: &Value, expect_block_reads: bool, proxy_port: Option<u16>) -> Vec<String> {
let at = |path: &str| root.pointer(path);
let is = |path: &str, value: bool| at(path) == Some(&json!(value));
let mut out = Vec::new();
for key in ["httpProxyPort", "socksProxyPort"] {
let set = at(&format!("/sandbox/network/{key}")).filter(|v| !v.is_null());
match proxy_port {
Some(port) if set != Some(&json!(port)) => out.push(format!(
"sandbox.network.{key} is not {port}: that traffic does not go through `moat proxy`"
)),
None if set.is_some() => out.push(format!(
"sandbox.network.{key} is set: another proxy decides that traffic, not the allowlist"
)),
_ => {}
}
}
let mut flag = |bad: bool, text: &str| {
if bad {
out.push(text.to_owned());
}
};
flag(
!is("/sandbox/enabled", true),
"sandbox.enabled is not true: Bash runs unsandboxed",
);
flag(
!is("/sandbox/failIfUnavailable", true),
"sandbox.failIfUnavailable is not true: commands run unsandboxed when the sandbox cannot start",
);
flag(
!is("/sandbox/allowUnsandboxedCommands", false),
"sandbox.allowUnsandboxedCommands is not false: the model may run a command outside the sandbox",
);
flag(
at("/sandbox/excludedCommands")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty()),
"sandbox.excludedCommands is not empty: those commands run unsandboxed",
);
flag(
!is("/sandbox/network/strictAllowlist", true),
"sandbox.network.strictAllowlist is not true: unlisted hosts prompt instead of failing",
);
for (path, text) in [
(
"/sandbox/filesystem/disabled",
"sandbox.filesystem.disabled turns file isolation off",
),
(
"/sandbox/network/allowAllUnixSockets",
"sandbox.network.allowAllUnixSockets opens every Unix socket",
),
(
"/sandbox/enableWeakerNetworkIsolation",
"sandbox.enableWeakerNetworkIsolation is on",
),
(
"/sandbox/enableWeakerNestedSandbox",
"sandbox.enableWeakerNestedSandbox is on",
),
] {
flag(is(path, true), text);
}
flag(
at("/sandbox/ignoreViolations").is_some_and(|v| !v.is_null()),
"sandbox.ignoreViolations hides sandbox violations",
);
flag(
expect_block_reads && !block_reads_on(root),
"permissions.blockReadsOutsideWorkingDirectories is not true: sandboxed commands read the whole home directory",
);
out
}
fn object_at<'a>(map: &'a mut Map<String, Value>, key: &str) -> Result<&'a mut Map<String, Value>> {
match map.entry(key).or_insert_with(|| Value::Object(Map::new())) {
Value::Object(nested) => Ok(nested),
_ => bail!("`{key}` in the settings file is not an object"),
}
}
#[cfg(test)]
mod tests {
use openmoat_core::DEFAULT_POLICY;
use serde_json::{Value, json};
use super::super::tests::generated;
use super::*;
#[test]
fn apply_keeps_user_keys_and_is_idempotent() {
let out = generated(DEFAULT_POLICY);
let mut root = json!({
"theme": "dark",
"hooks": { "PreToolUse": [] },
"permissions": { "allow": ["Bash(ls)"] },
"sandbox": { "autoAllowBashIfSandboxed": false, "network": { "allowUnixSockets": ["/x.sock"] } },
});
assert!(!in_sync(&root, &out));
assert!(apply(&mut root, &out).unwrap());
assert!(
!apply(&mut root, &out).unwrap(),
"second apply changes nothing"
);
assert!(in_sync(&root, &out));
assert_eq!(root["theme"], "dark");
assert_eq!(root["permissions"]["allow"], json!(["Bash(ls)"]));
assert_eq!(root["permissions"][BLOCK_READS], json!(true));
assert_eq!(root["sandbox"]["autoAllowBashIfSandboxed"], json!(false));
assert_eq!(
root["sandbox"]["network"]["allowUnixSockets"],
json!(["/x.sock"])
);
assert!(
weaknesses(&root, true, None).is_empty(),
"{:?}",
weaknesses(&root, true, None)
);
assert!(apply(&mut json!([]), &out).is_err());
assert!(apply(&mut json!({"sandbox": 1}), &out).is_err());
}
#[test]
fn remove_undoes_apply_and_keeps_user_keys() {
let original = json!({
"theme": "dark",
"permissions": { "allow": ["Bash(ls)"] },
"sandbox": { "autoAllowBashIfSandboxed": false, "network": { "allowUnixSockets": ["/x.sock"] } },
});
let mut root = original.clone();
apply(&mut root, &generated(DEFAULT_POLICY)).unwrap();
assert!(remove(&mut root));
assert_eq!(root, original);
assert!(!remove(&mut root));
let mut root = json!({});
apply(
&mut root,
&generated("version: 1\nsandbox:\n proxy_port: 18555\n"),
)
.unwrap();
assert!(remove(&mut root));
assert_eq!(root, json!({}), "every key apply writes is owned");
}
#[test]
fn weaknesses_name_every_weakened_setting() {
let out = generated(DEFAULT_POLICY);
let mut root = json!({});
apply(&mut root, &out).unwrap();
root["sandbox"]["enabled"] = json!(false);
root["sandbox"]["failIfUnavailable"] = Value::Null;
root["sandbox"]["allowUnsandboxedCommands"] = json!(true);
root["sandbox"]["excludedCommands"] = json!(["curl"]);
root["sandbox"]["network"]["allowAllUnixSockets"] = json!(true);
root["permissions"][BLOCK_READS] = json!(false);
root["sandbox"]["network"]["httpProxyPort"] = json!(8080);
let found = weaknesses(&root, true, None).join("\n");
for key in [
"httpProxyPort is set",
"sandbox.enabled",
"failIfUnavailable",
"allowUnsandboxedCommands",
"excludedCommands",
"allowAllUnixSockets",
BLOCK_READS,
] {
assert!(found.contains(key), "{key}: {found}");
}
assert!(!in_sync(&root, &out));
}
#[test]
fn weaknesses_name_a_changed_or_missing_moat_proxy_port() {
let out = generated("version: 1\nsandbox:\n proxy_port: 18555\n");
let mut root = json!({});
apply(&mut root, &out).unwrap();
assert!(weaknesses(&root, false, Some(18555)).is_empty());
root["sandbox"]["network"]["httpProxyPort"] = json!(8080);
let network = root["sandbox"]["network"].as_object_mut().unwrap();
network.remove("socksProxyPort");
let found = weaknesses(&root, false, Some(18555)).join("\n");
for key in ["httpProxyPort is not 18555", "socksProxyPort is not 18555"] {
assert!(found.contains(key), "{key}: {found}");
}
assert!(!in_sync(&root, &out));
}
#[test]
fn protect_denies_writes_to_the_settings_moat_pins() {
let mut out = generated(DEFAULT_POLICY);
protect(&mut out, Path::new("/cfg/claude/settings.json"));
protect(&mut out, Path::new("/cfg/claude/settings.json"));
let deny = &out.sandbox["filesystem"]["denyWrite"];
let count = |p: &str| deny.as_array().unwrap().iter().filter(|v| *v == p).count();
assert_eq!(count("/cfg/claude/settings.json"), 1, "once, however often");
assert_eq!(count("/cfg/claude/settings.local.json"), 1);
}
}