use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use anyhow::{Result, bail};
use openmoat_core::Decision;
use serde_json::{Map, Value};
use super::digest::is_claude_settings;
use super::keys::{differing, object_digests};
use super::{Drift, Lock, deny_for};
use crate::home::Home;
use crate::install::Recorded;
const RELAXED_KEYS: &[&str] = &[
"$schema",
"alwaysThinkingEnabled",
"effortLevel",
"attribution",
"includeCoAuthoredBy",
"autoMode",
"cleanupPeriodDays",
"language",
"outputStyle",
"prefersReducedMotion",
"showTurnDuration",
"spinnerTipsEnabled",
"spinnerTipsOverride",
"spinnerVerbs",
"terminalProgressBarEnabled",
"respectGitignore",
];
type Shape = fn(&Value) -> bool;
const MODEL_KEYS: &[(&str, Shape)] = &[
("model", Value::is_string),
("switchModelsOnFlag", is_scalar),
];
const PERMISSIONS: &str = "permissions";
const DENY: &str = "deny";
const RELAXED_PERMISSIONS: &[&str] = &[
"allow",
"ask",
DENY,
"defaultMode",
"additionalDirectories",
"blockReadsOutsideWorkingDirectories",
];
#[derive(Debug, Default)]
pub struct Checked {
pub deny: Option<Decision>,
pub relaxed: Vec<Drift>,
}
#[derive(Debug)]
pub struct Verified {
pub lock: Lock,
pub policy: Option<Vec<u8>>,
pub checked: Checked,
}
pub fn check(home: &Home) -> Result<Verified> {
let lock_path = home.lock_path();
if !lock_path.is_file() {
bail!(
"no policy lock at {}; run `openmoat init`",
lock_path.display()
);
}
let lock = Lock::load(&lock_path)?;
let (drift, policy) = lock.verify_keeping(Some(&home.policy_path()));
let checked = split(home, &lock, drift);
Ok(Verified {
lock,
policy,
checked,
})
}
pub fn split(home: &Home, lock: &Lock, drift: Vec<Drift>) -> Checked {
if drift.is_empty() {
return Checked::default();
}
let relaxed = Recorded::load(home)
.is_ok_and(|r| drift.iter().all(|d| is_relaxed(lock, d, &r.claude_rules)));
if relaxed {
Checked {
deny: None,
relaxed: drift,
}
} else {
Checked {
deny: deny_for(&drift),
relaxed: Vec::new(),
}
}
}
pub(super) fn permission_digests(path: &Path) -> Option<BTreeMap<String, String>> {
let root = settings_object(path)?;
object_digests(root.get(PERMISSIONS)?.as_object()?, false)
}
fn is_relaxed(lock: &Lock, drift: &Drift, owned: &[String]) -> bool {
let Drift::KeysChanged(path, _) = drift else {
return false;
};
let stored = path.to_string_lossy();
let Some(pinned) = lock.keys.get(stored.as_ref()) else {
return false;
};
let Some(root) = settings_object(path) else {
return false;
};
let Some(now) = object_digests(&root, true) else {
return false;
};
let pinned_permissions = lock.permission_keys.get(stored.as_ref());
let had_permissions = pinned.contains_key(PERMISSIONS);
differing(pinned, &now).all(|key| {
RELAXED_KEYS.contains(&key.as_str())
|| model_key_relaxed(key, &root)
|| (key == PERMISSIONS
&& permissions_relaxed(pinned_permissions, had_permissions, &root, owned))
})
}
fn model_key_relaxed(key: &str, root: &Map<String, Value>) -> bool {
MODEL_KEYS
.iter()
.any(|(name, shape)| *name == key && root.get(key).is_none_or(shape))
}
fn is_scalar(value: &Value) -> bool {
value.is_string() || value.is_number() || value.is_boolean()
}
fn settings_object(path: &Path) -> Option<Map<String, Value>> {
if !is_claude_settings(path) || !fs::symlink_metadata(path).ok()?.file_type().is_file() {
return None;
}
match serde_json::from_slice(&fs::read(path).ok()?).ok()? {
Value::Object(root) => Some(root),
_ => None,
}
}
fn permissions_relaxed(
pinned: Option<&BTreeMap<String, String>>,
had_permissions: bool,
root: &Map<String, Value>,
owned: &[String],
) -> bool {
let empty = Map::new();
let now = match root.get(PERMISSIONS) {
None => &empty,
Some(Value::Object(permissions)) => permissions,
Some(_) => return false,
};
let none = BTreeMap::new();
let pinned = match pinned {
Some(pinned) => pinned,
None if !had_permissions => &none,
None => return false,
};
let Some(digests) = object_digests(now, false) else {
return false;
};
let mut changed = differing(pinned, &digests);
changed.all(|key| RELAXED_PERMISSIONS.contains(&key.as_str()))
&& (pinned.get(DENY) == digests.get(DENY) || keeps_owned_rules(now, owned))
}
fn keeps_owned_rules(permissions: &Map<String, Value>, owned: &[String]) -> bool {
let Some(list) = permissions.get(DENY).and_then(Value::as_array) else {
return false;
};
!owned.is_empty()
&& owned
.iter()
.all(|rule| list.iter().any(|v| v.as_str() == Some(rule.as_str())))
}
#[cfg(test)]
mod tests;