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::spec_keys;
use super::{Drift, Kept, Lock, deny_for};
use crate::home::Home;
use crate::install::Recorded;
const RELAXED_KEYS: &[&str] = &[
"$schema",
"alwaysThinkingEnabled",
"effortLevel",
"attribution",
"includeCoAuthoredBy",
"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,
MODE,
"blockReadsOutsideWorkingDirectories",
];
const MODE: &str = "defaultMode";
const PROMPTING_MODES: &[&str] = &["default", "plan", "dontAsk"];
#[derive(Debug, Default)]
pub struct Checked {
pub deny: Option<Decision>,
pub relaxed: Vec<Drift>,
}
#[derive(Debug)]
pub struct Verified {
pub lock: Lock,
pub home: Home,
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, home) = super::verify_state(home, &lock);
let checked = split(&home, &lock, drift);
Ok(Verified {
lock,
home,
checked,
})
}
pub fn split(home: &Home, lock: &Lock, drift: Vec<Drift>) -> Checked {
if drift.is_empty() {
return Checked::default();
}
let kept = home.kept();
let relaxed = Recorded::load(home).is_ok_and(|r| {
drift
.iter()
.all(|d| is_relaxed(lock, d, &r.claude_rules, kept))
});
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], kept: &Kept) -> bool {
let Drift::KeysChanged(path, _) = drift else {
return false;
};
let stored = path.to_string_lossy();
if let Some(keys) = spec_keys::listed(path) {
return lock
.relaxed_rest
.get(stored.as_ref())
.zip(kept.drifted(path))
.is_some_and(|(pinned, bytes)| spec_keys::only_relaxed(path, bytes, keys, pinned));
}
let Some(pinned) = lock.keys.get(stored.as_ref()) else {
return false;
};
let Some(root) = kept
.drifted(path)
.and_then(|bytes| settings_root(path, bytes))
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;
}
settings_root(path, &fs::read(path).ok()?)
}
fn settings_root(path: &Path, bytes: &[u8]) -> Option<Map<String, Value>> {
if !is_claude_settings(path) {
return None;
}
match serde_json::from_slice(bytes).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()) && (key != MODE || mode_relaxed(now))
}) && (pinned.get(DENY) == digests.get(DENY) || keeps_owned_rules(now, owned))
}
fn mode_relaxed(permissions: &Map<String, Value>) -> bool {
permissions
.get(MODE)
.is_none_or(|mode| mode.as_str().is_some_and(|m| PROMPTING_MODES.contains(&m)))
}
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;