From e102af6ef354e5103dd1f43fa1ab474cbb894e20 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 2 Apr 2026 10:02:26 +0000 Subject: [PATCH] Honor project permission defaults when CLI has no override Runtime config already parsed merged permissionMode/defaultMode values, but the CLI defaulted straight from RUSTY_CLAUDE_PERMISSION_MODE to danger-full-access. This wires the default permission resolver through the merged runtime config so project/local settings take effect when no env override is present, while keeping env precedence and locking the behavior with regression tests. Constraint: Must preserve explicit env override precedence over project config Rejected: Thread permission source state through every CLI action | unnecessary refactor for a focused parity fix Confidence: high Scope-risk: narrow Directive: Keep config-derived defaults behind explicit CLI/env overrides unless the upstream precedence contract changes Tested: cargo test -p rusty-claude-cli permission_mode -- --nocapture Tested: cargo test -p rusty-claude-cli defaults_to_repl_when_no_args -- --nocapture Not-tested: interactive REPL/manual /permissions flows --- rust/crates/rusty-claude-cli/src/main.rs | 102 ++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f23fa60..5f0cb1a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,4 +1,11 @@ -#![allow(dead_code, unused_imports, unused_variables, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self)] +#![allow( + dead_code, + unused_imports, + unused_variables, + clippy::unneeded_struct_pattern, + clippy::unnecessary_wraps, + clippy::unused_self +)] mod init; mod input; mod render; @@ -36,7 +43,8 @@ use runtime::{ ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + UsageTracker, }; use serde_json::json; use tools::GlobalToolRegistry; @@ -594,12 +602,32 @@ fn permission_mode_from_label(mode: &str) -> PermissionMode { } } +fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { + match mode { + ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, + ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, + ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, + } +} + fn default_permission_mode() -> PermissionMode { env::var("RUSTY_CLAUDE_PERMISSION_MODE") .ok() .as_deref() .and_then(normalize_permission_mode) - .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) + .map(permission_mode_from_label) + .or_else(config_permission_mode_for_current_dir) + .unwrap_or(PermissionMode::DangerFullAccess) +} + +fn config_permission_mode_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .ok()? + .permission_mode() + .map(permission_mode_from_resolved) } fn filter_tool_specs( @@ -4948,6 +4976,74 @@ mod tests { ); } + #[test] + fn default_permission_mode_uses_project_config_when_env_is_unset() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"permissionMode":"acceptEdits"}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + + let resolved = with_current_dir(&cwd, super::default_permission_mode); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_permission_mode { + Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value), + None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, PermissionMode::WorkspaceWrite); + } + + #[test] + fn env_permission_mode_overrides_project_config_default() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"permissionMode":"acceptEdits"}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only"); + + let resolved = with_current_dir(&cwd, super::default_permission_mode); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_permission_mode { + Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value), + None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, PermissionMode::ReadOnly); + } + #[test] fn parses_prompt_subcommand() { let args = vec![