use super::*;
use std::collections::BTreeMap;
use serde_json::{Map, Value};
pub(crate) use super::settings_slots::deliver_slots;
pub(crate) const SCOPE_USER: &str = "user";
pub(crate) const SCOPE_PROJECT: &str = "project";
pub(crate) const SCOPE_LOCAL: &str = "local";
pub(crate) const SCOPE_LOCAL_GIT: &str = "local-git-root";
pub(crate) const SCOPE_POLICY_REMOTE: &str = "policy-remote";
pub(crate) const SCOPE_POLICY_MANAGED: &str = "policy-managed";
pub(crate) const SCOPE_POLICY_DROPIN: &str = "policy-managed.d";
pub(crate) const SCOPE_PLUGIN: &str = "plugin";
const SWITCH_DISABLE_ALL: &str = "policy disableAllHooks: no hooks run";
const SWITCH_MANAGED_ONLY: &str = "policy allowManagedHooksOnly: policy hooks only";
const SWITCH_SETTINGS_DISABLE: &str = "settings disableAllHooks: policy hooks only";
const POLICY_FIRST_WINS: &str =
"not composed: the server-managed cache is the highest present policy source";
const UNOBSERVABLE: [&str; 5] = [
"--settings (a file path or inline JSON, materialised per invocation)",
"--managed-settings (a spawning parent's policy tier, in memory only)",
"--setting-sources / --restricted (can empty the user, project and local scopes)",
"the trust-dialog state (before acceptance only user, flag and policy env applies in full)",
"MDM policy (a managed plist or a registry tree), which csift never reads",
];
const PLUGIN_WALK_DEPTH: usize = 6;
const PLUGIN_WALK_DIRS: usize = 512;
#[derive(Debug, Clone)]
pub(crate) struct SourceReport {
pub scope: &'static str,
pub path: PathBuf,
pub read: bool,
pub note: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct HookEntry {
pub matcher: Option<String>,
pub command: String,
pub timeout: Option<u64>,
pub async_: bool,
pub async_rewake: bool,
pub source: String,
}
#[derive(Debug, Clone)]
pub(crate) struct Merged {
pub sources: Vec<SourceReport>,
pub unobservable: Vec<&'static str>,
pub env: BTreeMap<String, String>,
pub env_scope: BTreeMap<String, &'static str>,
pub hooks: BTreeMap<String, Vec<HookEntry>>,
pub policy_switch: Option<&'static str>,
pub strings: BTreeMap<String, Vec<(&'static str, String)>>,
}
pub(crate) fn merged(claude_home: &Path, project_root: Option<&Path>) -> Merged {
merged_in(claude_home, project_root, &managed_settings_dir())
}
pub(crate) fn merged_in(
claude_home: &Path,
project_root: Option<&Path>,
managed_dir: &Path,
) -> Merged {
let mut fold = Fold::new();
read_plugin_hooks(claude_home, &mut fold);
fold.scope(SCOPE_USER, claude_home.join("settings.json"));
if let Some(root) = project_root {
fold.scope(SCOPE_PROJECT, root.join(".claude").join("settings.json"));
if let Some(git) = git_root_above(root) {
fold.scope(
SCOPE_LOCAL_GIT,
git.join(".claude").join("settings.local.json"),
);
}
fold.scope(
SCOPE_LOCAL,
root.join(".claude").join("settings.local.json"),
);
}
read_policy_tier(claude_home, managed_dir, &mut fold);
fold.finish()
}
pub(crate) fn hooks_for_event<'a>(m: &'a Merged, event: &str) -> Vec<&'a HookEntry> {
m.hooks
.get(event)
.map(|v| v.iter().collect())
.unwrap_or_default()
}
pub(crate) fn env_value<'a>(m: &'a Merged, key: &str) -> Option<(&'a str, &'a str)> {
let value = m.env.get(key)?;
let scope = m.env_scope.get(key).copied().unwrap_or("unknown");
Some((value.as_str(), scope))
}
pub(crate) fn string_value_in<'a>(
m: &'a Merged,
key: &str,
scopes: &[&str],
) -> Option<(&'a str, &'a str)> {
m.strings
.get(key)?
.iter()
.rev()
.find(|(scope, _)| scopes.contains(scope))
.map(|(scope, value)| (value.as_str(), *scope))
}
pub(crate) fn managed_settings_dir() -> PathBuf {
#[cfg(target_os = "macos")]
let dir = "/Library/Application Support/ClaudeCode";
#[cfg(target_os = "windows")]
let dir = "C:\\Program Files\\ClaudeCode";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let dir = "/etc/claude-code";
PathBuf::from(dir)
}
fn git_root_above(root: &Path) -> Option<PathBuf> {
let mut cur = root.parent()?;
loop {
if cur.join(".git").exists() {
return Some(cur.to_path_buf());
}
cur = cur.parent()?;
}
}
#[derive(Debug)]
enum FileRead {
Missing,
Bad(String),
Ok(Map<String, Value>),
}
fn read_settings_file(path: &Path) -> FileRead {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return FileRead::Missing,
Err(e) => return FileRead::Bad(format!("unreadable: {e}")),
};
match serde_json::from_str::<Value>(&raw) {
Ok(Value::Object(obj)) => FileRead::Ok(obj),
Ok(_) => FileRead::Bad("not a JSON object".to_string()),
Err(e) => FileRead::Bad(format!("malformed JSON: {e}")),
}
}
fn read_policy_tier(claude_home: &Path, managed_dir: &Path, fold: &mut Fold) {
let remote = claude_home.join("remote-settings.json");
match read_settings_file(&remote) {
FileRead::Ok(obj) => {
fold.report(SCOPE_POLICY_REMOTE, remote, true, None);
fold.fold(SCOPE_POLICY_REMOTE, &obj);
report_uncomposed_managed(managed_dir, fold);
return;
}
FileRead::Missing => fold.report(SCOPE_POLICY_REMOTE, remote, false, None),
FileRead::Bad(note) => fold.report(SCOPE_POLICY_REMOTE, remote, false, Some(note)),
}
fold.scope(
SCOPE_POLICY_MANAGED,
managed_dir.join("managed-settings.json"),
);
for path in dropins(managed_dir) {
fold.scope(SCOPE_POLICY_DROPIN, path);
}
}
fn report_uncomposed_managed(managed_dir: &Path, fold: &mut Fold) {
let mut paths = vec![managed_dir.join("managed-settings.json")];
paths.extend(dropins(managed_dir));
for path in paths {
if path.exists() {
fold.report(
SCOPE_POLICY_MANAGED,
path,
false,
Some(POLICY_FIRST_WINS.to_string()),
);
}
}
}
fn dropins(managed_dir: &Path) -> Vec<PathBuf> {
let Ok(rd) = std::fs::read_dir(managed_dir.join("managed-settings.d")) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|n| n.ends_with(".json") && !n.starts_with('.'))
})
.collect();
out.sort();
out
}
fn read_plugin_hooks(claude_home: &Path, fold: &mut Fold) {
let root = claude_home.join("plugins");
if !root.is_dir() {
return;
}
let mut budget = PLUGIN_WALK_DIRS;
walk_plugins(&root, PLUGIN_WALK_DEPTH, &mut budget, fold);
if budget == 0 {
fold.report(
SCOPE_PLUGIN,
root,
false,
Some(format!(
"stopped after {PLUGIN_WALK_DIRS} directories; deeper manifests were not read"
)),
);
}
}
fn walk_plugins(dir: &Path, depth: usize, budget: &mut usize, fold: &mut Fold) {
if depth == 0 || *budget == 0 {
return;
}
*budget -= 1;
if dir.join("plugin.json").is_file() || dir.join("hooks").join("hooks.json").is_file() {
read_one_plugin(dir, fold);
return; }
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut subs: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& !p
.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|n| n.starts_with('.'))
})
.collect();
subs.sort();
for sub in subs {
walk_plugins(&sub, depth - 1, budget, fold);
}
}
fn read_one_plugin(dir: &Path, fold: &mut Fold) {
let manifest = dir.join("plugin.json");
let name = plugin_name(dir, &manifest);
let source = format!("plugin:{name}");
fold.hooks_only(dir.join("hooks").join("hooks.json"), &source);
fold.hooks_only(manifest, &source);
}
fn plugin_name(dir: &Path, manifest: &Path) -> String {
if let FileRead::Ok(obj) = read_settings_file(manifest) {
if let Some(name) = obj.get("name").and_then(Value::as_str) {
if !name.trim().is_empty() {
return name.to_string();
}
}
}
dir.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("plugin")
.to_string()
}
fn is_policy_scope(scope: &str) -> bool {
scope.starts_with("policy-")
}
fn scalar_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Bool(_) | Value::Number(_) => Some(value.to_string()),
_ => None,
}
}
#[derive(Debug)]
struct Fold {
m: Merged,
disable_all_hooks: bool,
policy_disable_all_hooks: bool,
policy_allow_managed_only: bool,
}
impl Fold {
fn new() -> Self {
Fold {
m: Merged {
sources: Vec::new(),
unobservable: UNOBSERVABLE.to_vec(),
env: BTreeMap::new(),
env_scope: BTreeMap::new(),
hooks: BTreeMap::new(),
policy_switch: None,
strings: BTreeMap::new(),
},
disable_all_hooks: false,
policy_disable_all_hooks: false,
policy_allow_managed_only: false,
}
}
fn report(&mut self, scope: &'static str, path: PathBuf, read: bool, note: Option<String>) {
self.m.sources.push(SourceReport {
scope,
path,
read,
note,
});
}
fn scope(&mut self, scope: &'static str, path: PathBuf) {
match read_settings_file(&path) {
FileRead::Missing => self.report(scope, path, false, None),
FileRead::Bad(note) => self.report(scope, path, false, Some(note)),
FileRead::Ok(obj) => {
self.report(scope, path, true, None);
self.fold(scope, &obj);
}
}
}
fn hooks_only(&mut self, path: PathBuf, source: &str) {
match read_settings_file(&path) {
FileRead::Missing => {}
FileRead::Bad(note) => self.report(SCOPE_PLUGIN, path, false, Some(note)),
FileRead::Ok(obj) => {
self.report(SCOPE_PLUGIN, path, true, None);
if let Some(Value::Object(hooks)) = obj.get("hooks") {
self.fold_hooks(source, hooks);
}
}
}
}
fn fold(&mut self, scope: &'static str, obj: &Map<String, Value>) {
if let Some(Value::Object(env)) = obj.get("env") {
self.fold_env(scope, env);
}
if let Some(Value::Object(hooks)) = obj.get("hooks") {
self.fold_hooks(scope, hooks);
}
self.fold_strings(scope, obj);
self.fold_switches(scope, obj);
}
fn fold_env(&mut self, scope: &'static str, env: &Map<String, Value>) {
for (key, value) in env {
if let Some(text) = scalar_string(value) {
self.m.env.insert(key.clone(), text);
self.m.env_scope.insert(key.clone(), scope);
}
}
}
fn fold_hooks(&mut self, source: &str, hooks: &Map<String, Value>) {
for (event, groups) in hooks {
let Some(list) = groups.as_array() else {
continue;
};
for group in list {
let matcher = group
.get("matcher")
.and_then(Value::as_str)
.map(str::to_string);
match group.get("hooks").and_then(Value::as_array) {
Some(entries) => {
for entry in entries {
self.push_hook(event, matcher.clone(), entry, source);
}
}
None => self.push_hook(event, matcher, group, source),
}
}
}
}
fn push_hook(&mut self, event: &str, matcher: Option<String>, entry: &Value, source: &str) {
let Some(command) = entry.get("command").and_then(Value::as_str) else {
return;
};
let entry = HookEntry {
matcher,
command: command.to_string(),
timeout: entry.get("timeout").and_then(Value::as_u64),
async_: entry.get("async").and_then(Value::as_bool).unwrap_or(false),
async_rewake: entry
.get("asyncRewake")
.and_then(Value::as_bool)
.unwrap_or(false),
source: source.to_string(),
};
let bucket = self.m.hooks.entry(event.to_string()).or_default();
if bucket.iter().any(|h| same_hook(h, &entry)) {
return;
}
bucket.push(entry);
}
fn fold_strings(&mut self, scope: &'static str, obj: &Map<String, Value>) {
for (key, value) in obj {
if key == "env" || key == "hooks" {
continue;
}
if let Value::String(text) = value {
self.m
.strings
.entry(key.clone())
.or_default()
.push((scope, text.clone()));
}
}
}
fn fold_switches(&mut self, scope: &'static str, obj: &Map<String, Value>) {
let disable = obj.get("disableAllHooks").and_then(Value::as_bool);
if let Some(flag) = disable {
self.disable_all_hooks = flag;
}
if !is_policy_scope(scope) {
return;
}
if disable == Some(true) {
self.policy_disable_all_hooks = true;
}
if obj.get("allowManagedHooksOnly").and_then(Value::as_bool) == Some(true) {
self.policy_allow_managed_only = true;
}
}
fn finish(mut self) -> Merged {
if self.policy_disable_all_hooks {
self.m.hooks.clear();
self.m.policy_switch = Some(SWITCH_DISABLE_ALL);
} else if self.policy_allow_managed_only || self.disable_all_hooks {
for entries in self.m.hooks.values_mut() {
entries.retain(|h| is_policy_scope(&h.source));
}
self.m.hooks.retain(|_, entries| !entries.is_empty());
self.m.policy_switch = Some(if self.policy_allow_managed_only {
SWITCH_MANAGED_ONLY
} else {
SWITCH_SETTINGS_DISABLE
});
}
self.m
}
}
fn same_hook(a: &HookEntry, b: &HookEntry) -> bool {
a.matcher == b.matcher
&& a.command == b.command
&& a.timeout == b.timeout
&& a.async_ == b.async_
&& a.async_rewake == b.async_rewake
}