- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
416 lines
14 KiB
Rust
416 lines
14 KiB
Rust
//! 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<domain::NetworkPolicy>,
|
|
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<String> {
|
|
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<String>) -> Vec<String> {
|
|
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<String> {
|
|
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<String>, 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<String>, 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<String>, 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::<Vec<_>>()
|
|
.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<PermissionRule>, 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<String> {
|
|
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()
|
|
]
|
|
);
|
|
}
|
|
}
|