use super::policy::{Action, PatternMap, Policy, RuleValue, Surface};
use crate::core::{
config_cache::ConfigCache, project_trust::is_project_trusted, settings_manager::agent_dir,
};
use serde_json::{Value, json};
use std::{
fs,
path::{Path, PathBuf},
sync::RwLock,
};
pub(super) const CONFIG_FILE: &str = "config.json";
const CONFIG_VERSION: i64 = 1;
static GLOBAL_LOCK: RwLock<()> = RwLock::new(());
static PROJECT_LOCK: RwLock<()> = RwLock::new(());
static GLOBAL_CACHE: ConfigCache = ConfigCache::new();
static PROJECT_CACHE: ConfigCache = ConfigCache::new();
#[derive(Debug, Clone)]
pub(super) struct Settings {
pub policy: Policy,
pub review_log: bool,
pub yolo_mode: bool,
pub sources: Vec<PathBuf>,
pub warnings: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
policy: Policy {
universal: RuleValue::new(Action::Ask),
..Policy::default()
},
review_log: true,
yolo_mode: false,
sources: Vec::new(),
warnings: Vec::new(),
}
}
}
pub(super) fn global_config_path() -> PathBuf {
agent_dir()
.join("extensions")
.join("permissions")
.join(CONFIG_FILE)
}
pub(super) fn project_config_path(cwd: &Path) -> PathBuf {
cwd.join(crate::PROJECT_SCOPE_NAME)
.join("extensions")
.join("permissions")
.join(CONFIG_FILE)
}
pub(super) fn review_log_path() -> PathBuf {
agent_dir()
.join("extensions")
.join("permissions")
.join("logs")
.join("permissions-review.jsonl")
}
fn read_json(cache: &ConfigCache, lock: &RwLock<()>, path: &Path) -> Value {
let _guard = lock.read().unwrap_or_else(|e| e.into_inner());
cache.read(path, || {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.filter(Value::is_object)
.unwrap_or_else(|| json!({}))
})
}
fn merge_json(base: &mut Value, over: &Value) {
match (base, over) {
(Value::Object(b), Value::Object(o)) => {
for (key, value) in o {
match b.get_mut(key) {
Some(slot) => merge_json(slot, value),
None => {
b.insert(key.clone(), value.clone());
}
}
}
}
(slot, value) => *slot = value.clone(),
}
}
pub(super) fn load(cwd: &Path) -> Settings {
let global_path = global_config_path();
let global = read_json(&GLOBAL_CACHE, &GLOBAL_LOCK, &global_path);
let mut merged = global.clone();
let mut sources = Vec::new();
let mut warnings = Vec::new();
if !global.as_object().is_some_and(|o| o.is_empty()) {
sources.push(global_path);
}
let project_path = project_config_path(cwd);
if project_path.is_file() {
if is_project_trusted(cwd, &agent_dir()) {
let project = read_json(&PROJECT_CACHE, &PROJECT_LOCK, &project_path);
merge_json(&mut merged, &project);
sources.push(project_path);
} else {
warnings.push(format!(
"skipped untrusted project config: {}",
project_path.display()
));
}
}
parse_settings(&merged, sources, warnings)
}
fn parse_settings(root: &Value, sources: Vec<PathBuf>, mut warnings: Vec<String>) -> Settings {
let review_log = root
.get("permissionReviewLog")
.and_then(Value::as_bool)
.unwrap_or(true);
let yolo_mode = root
.get("yoloMode")
.and_then(Value::as_bool)
.unwrap_or(false);
let Some(permission) = root.get("permission").and_then(Value::as_object) else {
return Settings {
policy: Policy::default(),
review_log,
yolo_mode,
sources,
warnings,
};
};
let mut policy = Policy::default();
for (key, value) in permission {
match key.as_str() {
"*" => match RuleValue::parse(value) {
Some(rule) => policy.universal = rule,
None => warnings.push("permission[\"*\"]: invalid rule value".to_string()),
},
"path"
| "path_read"
| "path_write"
| "external_directory"
| "external_directory_read"
| "external_directory_write" => {
let map = parse_pattern_map(value, key, &mut warnings);
match key.as_str() {
"path" => policy.path = Some(map),
"path_read" => policy.path_read = Some(map),
"path_write" => policy.path_write = Some(map),
"external_directory" => policy.external_directory = Some(map),
"external_directory_read" => policy.external_directory_read = Some(map),
_ => policy.external_directory_write = Some(map),
}
}
tool => {
let surface = match value {
Value::String(_) | Value::Object(_) if !is_pattern_map(value) => {
match RuleValue::parse(value) {
Some(rule) => Surface::CatchAll(rule),
None => {
warnings.push(format!("permission[{tool}]: invalid rule value"));
continue;
}
}
}
_ => Surface::Patterns(parse_pattern_map(value, tool, &mut warnings)),
};
policy.surfaces.insert(tool.to_string(), surface);
}
}
}
Settings {
policy,
review_log,
yolo_mode,
sources,
warnings,
}
}
fn is_pattern_map(value: &Value) -> bool {
match value {
Value::Object(map) => !map.contains_key("action"),
_ => false,
}
}
fn parse_pattern_map(value: &Value, label: &str, warnings: &mut Vec<String>) -> PatternMap {
if let Some(rule) = RuleValue::parse(value) {
let mut map = PatternMap::default();
map.set("*", rule);
return map;
}
let (map, bad) = PatternMap::parse(value);
for pattern in bad {
warnings.push(format!(
"permission[{label}][{pattern}]: invalid rule value"
));
}
map
}
pub(super) fn default_config() -> Value {
json!({
"configVersion": CONFIG_VERSION,
"permissionReviewLog": true,
"yoloMode": false,
"permission": {
"*": "allow",
"path": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"~/.ssh/*": "deny",
"~/.aws/*": "deny",
"~/.gnupg/*": "deny",
"*.pem": "deny",
"*.key": "deny",
"*id_rsa*": "deny"
},
"bash": {
"*": "allow",
"rm -rf /*": "deny",
"rm -fr /*": "deny",
"rm -rf ~*": "deny",
"mkfs*": "deny",
"dd if=*": "ask",
"shutdown*": "deny",
"reboot*": "deny",
"git push --force*": "ask",
"git push -f*": "ask",
"git reset --hard*": "ask",
"git clean -f*": "ask",
"chmod 777 *": "ask",
"chown *": "ask",
"sudo *": "ask",
"eval *": "ask",
"bash -c *": "ask",
"sh -c *": "ask",
"xargs *": "ask"
}
}
})
}
pub(super) fn write_default(overwrite: bool) -> std::io::Result<bool> {
let path = global_config_path();
if path.is_file() && !overwrite {
return Ok(false);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&default_config()).unwrap_or_default();
fs::write(&path, format!("{text}\n"))?;
let _guard = GLOBAL_LOCK.write().unwrap_or_else(|e| e.into_inner());
GLOBAL_CACHE.invalidate();
Ok(true)
}
pub(super) fn set_switch(key: &str, value: bool) -> std::io::Result<()> {
let path = global_config_path();
let mut root = read_json(&GLOBAL_CACHE, &GLOBAL_LOCK, &path);
if !root.is_object() {
root = default_config();
}
if let Value::Object(map) = &mut root {
map.insert(key.to_string(), json!(value));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&root).unwrap_or_default();
fs::write(&path, format!("{text}\n"))?;
let _guard = GLOBAL_LOCK.write().unwrap_or_else(|e| e.into_inner());
GLOBAL_CACHE.invalidate();
Ok(())
}
pub(super) fn describe_surface(policy: &Policy, surface: &str) -> Option<String> {
let s = policy.surfaces.get(surface)?;
let mut parts = Vec::new();
match s {
Surface::CatchAll(rule) => parts.push(format!("*={}", rule.action)),
Surface::Patterns(map) => {
for pattern in map.patterns() {
if let Some(hit) = map.evaluate(&[pattern.to_string()]) {
parts.push(format!("{pattern}={}", hit.rule.action));
}
}
}
}
Some(parts.join(" "))
}
#[cfg(test)]
pub(super) fn parse_settings_for_tests(root: &Value) -> Policy {
parse_settings(root, Vec::new(), Vec::new()).policy
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_overrides_scalars_and_merges_maps() {
let mut base =
json!({"permission": {"read": "allow", "bash": {"*": "allow", "rm *": "ask"}}});
let over = json!({"permission": {"bash": {"rm *": "deny"}}});
merge_json(&mut base, &over);
assert_eq!(base["permission"]["read"], json!("allow"));
assert_eq!(base["permission"]["bash"]["*"], json!("allow"));
assert_eq!(base["permission"]["bash"]["rm *"], json!("deny"));
}
#[test]
fn merge_replaces_when_types_differ() {
let mut base = json!({"permission": {"bash": {"*": "allow"}}});
let over = json!({"permission": {"bash": "deny"}});
merge_json(&mut base, &over);
assert_eq!(base["permission"]["bash"], json!("deny"));
}
#[test]
fn parses_policy_surfaces() {
let settings = parse_settings(
&json!({"permission": {
"*": "allow",
"read": "deny",
"write": {"*": "deny", "src/*": "allow"},
"path": {"*": "allow", "*.env": "deny"},
"external_directory": "ask"
}}),
Vec::new(),
Vec::new(),
);
assert_eq!(settings.policy.universal.action, Action::Allow);
assert!(settings.policy.surface_all_deny("read"));
assert!(!settings.policy.surface_all_deny("write"));
assert_eq!(
settings
.policy
.path_decision(
&["src/.env".to_string()],
super::super::policy::Direction::Read
)
.expect("hit")
.action,
Action::Deny
);
assert!(settings.policy.has_external());
}
#[test]
fn invalid_rule_values_warn_and_do_not_allow() {
let settings = parse_settings(
&json!({"permission": {"bash": {"*": "bogus"}}}),
Vec::new(),
Vec::new(),
);
assert!(settings.warnings.iter().any(|w| w.contains("bash")));
assert!(
settings
.policy
.bash_decision(&["rm -rf /".to_string()])
.is_none()
);
assert_eq!(settings.policy.universal.action, Action::Ask);
}
#[test]
fn string_shorthand_becomes_catch_all() {
let settings = parse_settings(
&json!({"permission": {"grep": "deny"}}),
Vec::new(),
Vec::new(),
);
let decision = settings
.policy
.tool_decision("grep", &["*".to_string()])
.expect("catch-all hit");
assert_eq!(decision.action, Action::Deny);
}
#[test]
fn object_with_action_is_a_rule_value_not_a_map() {
assert!(!is_pattern_map(&json!({"action": "deny"})));
assert!(is_pattern_map(&json!({"*": "deny"})));
let settings = parse_settings(
&json!({"permission": {"bash": {"action": "deny", "reason": "no shell"}}}),
Vec::new(),
Vec::new(),
);
let decision = settings
.policy
.bash_decision(&["ls".to_string()])
.expect("hit");
assert_eq!(decision.action, Action::Deny);
assert_eq!(decision.reason.as_deref(), Some("no shell"));
}
#[test]
fn write_default_round_trips_and_respects_existing() {
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = std::env::temp_dir();
assert!(write_default(false).expect("write default config"));
assert!(global_config_path().is_file());
let settings = load(&cwd);
assert!(!settings.sources.is_empty());
assert!(settings.warnings.is_empty(), "{:?}", settings.warnings);
assert_eq!(
settings
.policy
.path_decision(&[".env".to_string()], super::super::policy::Direction::Read)
.expect("hit")
.action,
Action::Deny
);
assert!(!write_default(false).expect("keep existing"));
assert!(write_default(true).expect("force overwrite"));
}
#[test]
fn default_config_protects_sensitive_files() {
let settings = parse_settings(&default_config(), Vec::new(), Vec::new());
let dir = super::super::policy::Direction::Read;
assert_eq!(
settings
.policy
.path_decision(&[".env".to_string()], dir)
.expect("hit")
.action,
Action::Deny
);
assert_eq!(
settings
.policy
.path_decision(&[".env.example".to_string()], dir)
.expect("hit")
.action,
Action::Allow
);
assert!(settings.warnings.is_empty());
}
}