//! Claude Code permission projector (lot LP3-2). //! //! Produces the `.claude/settings.local.json` seed written into an agent run dir: //! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with //! the project root granted as an additional working directory, while keeping //! destructive/out-of-project commands denied. The translation is extracted //! verbatim from the former `claude_settings_seed` in `lifecycle.rs`. use domain::permission::{ Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection, PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey, }; use super::json_escape; /// Run-dir-relative path of the owned Claude settings seed. const SETTINGS_REL_PATH: &str = ".claude/settings.local.json"; /// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`. /// /// Pure: `project` only computes the JSON; the launch path materialises it. A /// `Replace`-owned file (clobbered at launch, removed on swap-away). #[derive(Debug, Default, Clone, Copy)] pub struct ClaudePermissionProjector; impl PermissionProjector for ClaudePermissionProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Claude } fn project( &self, eff: Option<&EffectivePermissions>, _network: Option, ctx: &ProjectionContext, ) -> PermissionProjection { // Product invariant: no permissions and no model ⇒ nothing projected // (native prompting). A model is orthogonal and still gets materialised. if eff.is_none() && ctx.model.is_none() { return PermissionProjection::empty(); } let contents = claude_settings_seed(ctx.project_root, eff, ctx.model); PermissionProjection { files: vec![ProjectedFile::Replace { rel_path: SETTINGS_REL_PATH.to_owned(), contents, }], args: Vec::new(), env: Vec::new(), } } fn owned_replace_paths(&self) -> Vec { vec![SETTINGS_REL_PATH.to_owned()] } } /// Builds the Claude Code permission seed. `project_root` is embedded verbatim /// (JSON-escaped) and granted as an additional working directory, since the cwd is /// the run dir and the agent works on the root above it. fn claude_settings_seed( project_root: &str, permissions: Option<&EffectivePermissions>, model: Option<&str>, ) -> String { let model_line = model .map(|model| format!(" \"model\": {},\n", json_literal(model))) .unwrap_or_default(); if permissions.is_none() { return format!( r#"{{ {model_line} "enabledMcpjsonServers": ["idea"] }} "# ); } let root = json_escape(project_root); let default_mode = match permissions.map(EffectivePermissions::fallback) { Some(Posture::Deny) => "plan", Some(Posture::Ask) => "acceptEdits", Some(Posture::Allow) | None => "bypassPermissions", }; let allow = claude_permission_entries(permissions, Effect::Allow); let deny = claude_permission_entries(permissions, Effect::Deny); let default_allow = [ "Read".to_owned(), "Edit".to_owned(), "Write".to_owned(), "Bash".to_owned(), ]; let allow = json_string_array(if allow.is_empty() { &default_allow } else { &allow }); let deny = json_string_array(&merge_default_deny(deny)); format!( r#"{{ {model_line} "permissions": {{ "defaultMode": "{default_mode}", "additionalDirectories": [ "{root}" ], "allow": {allow}, "deny": {deny} }}, "skipDangerousModePermissionPrompt": true, "enabledMcpjsonServers": ["idea"], "sandbox": {{ "enabled": false }} }} "# ) } fn merge_default_deny(mut deny: Vec) -> Vec { for item in [ "Bash(sudo *)", "Bash(rm -rf /)", "Bash(rm -rf /*)", "Bash(rm -rf ~)", "Bash(rm -rf ~/)", "Bash(rm -rf ~/*)", "Bash(rm -rf $HOME*)", "Bash(mkfs*)", "Bash(dd if=*)", "Bash(shutdown*)", "Bash(reboot*)", ] { if !deny.iter().any(|existing| existing == item) { deny.push(item.to_owned()); } } deny } fn claude_permission_entries( permissions: Option<&EffectivePermissions>, effect: Effect, ) -> Vec { let Some(permissions) = permissions else { return Vec::new(); }; let mut out = Vec::new(); for rule in permissions.rules() { if rule.effect() != effect { continue; } match rule.capability() { Capability::Read => push_path_entries(&mut out, "Read", rule), Capability::Write => { push_path_entries(&mut out, "Edit", rule); push_path_entries(&mut out, "Write", rule); } Capability::Delete => push_delete_entries(&mut out, rule), Capability::ExecuteBash => push_bash_entries(&mut out, rule), } } out } fn push_path_entries(out: &mut Vec, capability: &str, rule: &PermissionRule) { if rule.paths().is_empty() { out.push(capability.to_owned()); return; } for glob in rule.paths().globs() { out.push(format!("{capability}({})", glob.pattern())); } } fn push_delete_entries(out: &mut Vec, rule: &PermissionRule) { if rule.paths().is_empty() { out.push("Bash(rm *)".to_owned()); return; } for glob in rule.paths().globs() { out.push(format!("Bash(rm {})", glob.pattern())); } } fn push_bash_entries(out: &mut Vec, rule: &PermissionRule) { if rule.commands().is_empty() { out.push("Bash".to_owned()); return; } for cmd in rule.commands() { if cmd.effect != rule.effect() { continue; } out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher))); } } fn command_matcher_pattern(matcher: &CommandMatcher) -> String { match matcher { CommandMatcher::Exact(value) => value.clone(), CommandMatcher::Prefix(value) => format!("{value}*"), CommandMatcher::Glob(glob) => glob.pattern().to_owned(), } } fn json_string_array(items: &[String]) -> String { if items.is_empty() { return "[]".to_owned(); } let body = items .iter() .map(|item| format!(" \"{}\"", json_escape(item))) .collect::>() .join(",\n"); format!("[\n{body}\n ]") } fn json_literal(value: &str) -> String { format!("\"{}\"", json_escape(value)) } #[cfg(test)] mod tests { use super::*; use domain::permission::{resolve, PathScope, PermissionSet}; use serde_json::Value; fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> { ProjectionContext { project_root: root, run_dir, model: None, } } fn path_scope(patterns: &[&str]) -> PathScope { PathScope::new(patterns.iter().map(ToString::to_string)).unwrap() } /// Builds an [`EffectivePermissions`] from a single (project) set via the /// domain API, exactly like the `permission` unit tests do. fn eff_with(rules: Vec, fallback: Posture) -> EffectivePermissions { resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap() } /// Projects and returns the parsed `settings.local.json` value, asserting the /// projection's structural contract (1 Replace file, no args/env) along the way. fn project_json(eff: &EffectivePermissions, root: &str) -> Value { let proj = ClaudePermissionProjector.project(Some(eff), None, &ctx(root, "/run/agent")); assert!(proj.args.is_empty(), "Claude projection carries no args"); assert!(proj.env.is_empty(), "Claude projection carries no env"); assert_eq!(proj.files.len(), 1, "exactly one file projected"); match &proj.files[0] { ProjectedFile::Replace { rel_path, contents } => { assert_eq!(rel_path, SETTINGS_REL_PATH); serde_json::from_str(contents).expect("the produced settings is valid JSON") } ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"), } } fn str_array(value: &Value) -> Vec { value .as_array() .expect("array") .iter() .map(|v| v.as_str().expect("string").to_owned()) .collect() } // ---- product invariant + ownership ---------------------------------- #[test] fn project_none_is_empty() { let proj = ClaudePermissionProjector.project(None, None, &ctx("/proj", "/run")); assert!(proj.files.is_empty()); assert!(proj.args.is_empty()); assert!(proj.env.is_empty()); } #[test] fn model_projects_even_without_permissions() { let ctx = ProjectionContext { project_root: "/proj", run_dir: "/run", model: Some("claude-sonnet-4-5"), }; let proj = ClaudePermissionProjector.project(None, None, &ctx); assert!(proj.args.is_empty()); assert!(proj.env.is_empty()); assert_eq!(proj.files.len(), 1); match &proj.files[0] { ProjectedFile::Replace { rel_path, contents } => { assert_eq!(rel_path, SETTINGS_REL_PATH); let json: Value = serde_json::from_str(contents).unwrap(); assert_eq!(json["model"], "claude-sonnet-4-5"); assert!(json.get("permissions").is_none()); } ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"), } } #[test] fn owned_replace_paths_is_the_settings_file() { assert_eq!( ClaudePermissionProjector.owned_replace_paths(), vec![SETTINGS_REL_PATH.to_owned()] ); } // ---- (1) posture → defaultMode -------------------------------------- #[test] fn default_mode_maps_each_posture() { for (posture, mode) in [ (Posture::Allow, "bypassPermissions"), (Posture::Ask, "acceptEdits"), (Posture::Deny, "plan"), ] { let json = project_json(&eff_with(vec![], posture), "/proj"); assert_eq!( json["permissions"]["defaultMode"], mode, "posture {posture:?} should map to defaultMode {mode}" ); } } // ---- (2) deny-wins: deny entry surfaces in the deny list ------------- #[test] fn specific_deny_with_broad_allow_appears_in_deny_list() { let rules = vec![ PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"])) .unwrap(), PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(), ]; let json = project_json(&eff_with(rules, Posture::Allow), "/proj"); let deny = str_array(&json["permissions"]["deny"]); // A Write capability fans out to both Edit(..) and Write(..) entries. assert!( deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}" ); assert!( deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}" ); let allow = str_array(&json["permissions"]["allow"]); assert!( allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()), "the broad allow stays in the allow list; allow={allow:?}" ); } // ---- (3) additionalDirectories carries the (escaped) project root ---- #[test] fn additional_directories_contains_project_root_escaped() { // A Windows-ish path with a backslash AND a quote exercises JSON escaping; // parsing it back must yield the original raw path verbatim. let root = r#"C:\Users\a"b\proj"#; let json = project_json(&eff_with(vec![], Posture::Allow), root); let dirs = str_array(&json["permissions"]["additionalDirectories"]); assert_eq!(dirs, vec![root.to_owned()]); } // ---- (4) hard-coded destructive guardrails -------------------------- #[test] fn default_deny_guardrails_are_present() { let json = project_json(&eff_with(vec![], Posture::Allow), "/proj"); let deny = str_array(&json["permissions"]["deny"]); for guard in [ "Bash(sudo *)", "Bash(rm -rf /)", "Bash(rm -rf ~)", "Bash(rm -rf $HOME*)", "Bash(mkfs*)", "Bash(dd if=*)", "Bash(shutdown*)", "Bash(reboot*)", ] { assert!( deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}" ); } } // ---- (5) valid JSON + expected static shape ------------------------- #[test] fn produced_settings_has_expected_static_shape() { // `project_json` already proved the document parses; assert the fixed keys. let json = project_json(&eff_with(vec![], Posture::Ask), "/proj"); assert_eq!(json["enabledMcpjsonServers"][0], "idea"); assert_eq!(json["skipDangerousModePermissionPrompt"], true); assert_eq!(json["sandbox"]["enabled"], false); } #[test] fn empty_rules_fall_back_to_broad_default_allow() { let json = project_json(&eff_with(vec![], Posture::Allow), "/proj"); let allow = str_array(&json["permissions"]["allow"]); assert_eq!( allow, vec![ "Read".to_owned(), "Edit".to_owned(), "Write".to_owned(), "Bash".to_owned() ] ); } }