Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Yeachan-Heo
f10bf8f595 Keep argument parsing independent from plugin discovery
parse_args only needs the tool registry when --allowedTools is present, but it was eagerly normalizing an empty list through the plugin-backed registry. On a clean CI home this made no-arg parsing fail if bundled plugin sync surfaced a broken install, and it also left parse-related tests exposed to concurrent environment mutation.

Short-circuit empty allowed-tool normalization and serialize the default-permission parse tests that depend on shared process env.

Constraint: Rust CI runs unit tests in parallel inside one process, so std::env mutations are shared across tests
Rejected: Keep eager registry loading for empty allowed-tools lists | unnecessary work and leaks unrelated plugin failures into basic arg parsing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any test that mutates HOME, CLAW_CONFIG_HOME, or RUSTY_CLAUDE_PERMISSION_MODE must hold env_lock while code under test reads process env
Tested: cargo fmt --all --check; cargo test -p rusty-claude-cli
Not-tested: Additional remote workflows beyond rust-ci
2026-04-03 09:37:46 +00:00
Yeachan-Heo
bf59abc9e9 Restore prompt-approved tool execution in CLI parity runs
ConversationRuntime already performs permission-policy checks and interactive approvals before dispatching a tool. The CLI tool executor was routing those same tool calls back through GlobalToolRegistry::execute, which re-ran the enforcer without a prompter and flipped approved bash calls back into denials.

Add a preauthorized execution path for runtime-dispatched tools, keep registry enforcement for direct callers, and format the files that were already tripping rustfmt on main.

Constraint: CI on main was failing both cargo fmt and the mock parity harness after permission enforcement landed
Rejected: Remove registry enforcement globally | would reopen direct-dispatch permission gaps
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Use execute_preauthorized only after ConversationRuntime or an equivalent caller has already completed permission gating
Tested: cargo fmt --all --check; cargo test -p rusty-claude-cli; cargo test -p tools
Not-tested: Full workspace test matrix beyond the Rust CI workflow targets
2026-04-03 09:32:29 +00:00
3 changed files with 126 additions and 23 deletions

View file

@ -191,7 +191,10 @@ impl McpToolRegistry {
let mut manager = manager let mut manager = manager
.lock() .lock()
.map_err(|_| "mcp server manager lock poisoned".to_string())?; .map_err(|_| "mcp server manager lock poisoned".to_string())?;
manager.discover_tools().await.map_err(|error| error.to_string())?; manager
.discover_tools()
.await
.map_err(|error| error.to_string())?;
let response = manager let response = manager
.call_tool(&qualified_tool_name, arguments) .call_tool(&qualified_tool_name, arguments)
.await .await
@ -834,7 +837,9 @@ mod tests {
None, None,
); );
registry registry
.set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(&servers)))) .set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
&servers,
))))
.expect("manager should only be set once"); .expect("manager should only be set once");
let result = registry let result = registry

View file

@ -40,8 +40,7 @@ use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{ use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, parse_oauth_callback_request_target, permission_enforcer::PermissionEnforcer,
permission_enforcer::PermissionEnforcer,
resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent,
CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage,
ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
@ -588,6 +587,9 @@ fn resolve_model_alias(model: &str) -> &str {
} }
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> { fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() {
return Ok(None);
}
current_tool_registry()?.normalize_allowed_tools(values) current_tool_registry()?.normalize_allowed_tools(values)
} }
@ -4976,7 +4978,7 @@ impl ToolExecutor for CliToolExecutor {
} }
let value = serde_json::from_str(input) let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match self.tool_registry.execute(tool_name, &value) { match self.tool_registry.execute_preauthorized(tool_name, &value) {
Ok(output) => { Ok(output) => {
if self.emit_output { if self.emit_output {
let markdown = format_tool_result(tool_name, &output, false); let markdown = format_tool_result(tool_name, &output, false);
@ -5319,6 +5321,7 @@ mod tests {
} }
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
let _guard = env_lock();
assert_eq!( assert_eq!(
parse_args(&[]).expect("args should parse"), parse_args(&[]).expect("args should parse"),
CliAction::Repl { CliAction::Repl {
@ -5329,6 +5332,62 @@ mod tests {
); );
} }
#[test]
fn defaults_to_repl_when_no_args_without_loading_plugin_registry() {
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("workspace");
let config_home = root.join("config-home");
let bundled_root = cwd.join("broken-bundled");
let plugin_root = bundled_root.join("broken-hooks");
std::fs::create_dir_all(plugin_root.join(".claude-plugin"))
.expect("broken bundled manifest dir should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
plugin_root.join(".claude-plugin").join("plugin.json"),
r#"{
"name": "broken-hooks",
"version": "1.0.0",
"description": "broken bundled fixture",
"hooks": {
"PreToolUse": ["./hooks/pre.sh"]
}
}"#,
)
.expect("broken bundled manifest should write");
std::fs::write(
config_home.join("settings.json"),
serde_json::json!({
"plugins": {
"bundledRoot": "./broken-bundled"
}
})
.to_string(),
)
.expect("config should write");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
let action = with_current_dir(&cwd, || parse_args(&[]).expect("args should parse"));
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
std::fs::remove_dir_all(root).expect("temp config root should clean up");
assert_eq!(
action,
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
}
);
}
#[test] #[test]
fn default_permission_mode_uses_project_config_when_env_is_unset() { fn default_permission_mode_uses_project_config_when_env_is_unset() {
let _guard = env_lock(); let _guard = env_lock();
@ -5399,6 +5458,7 @@ mod tests {
#[test] #[test]
fn parses_prompt_subcommand() { fn parses_prompt_subcommand() {
let _guard = env_lock();
let args = vec![ let args = vec![
"prompt".to_string(), "prompt".to_string(),
"hello".to_string(), "hello".to_string(),
@ -5418,6 +5478,7 @@ mod tests {
#[test] #[test]
fn parses_bare_prompt_and_json_output_flag() { fn parses_bare_prompt_and_json_output_flag() {
let _guard = env_lock();
let args = vec![ let args = vec![
"--output-format=json".to_string(), "--output-format=json".to_string(),
"--model".to_string(), "--model".to_string(),
@ -5439,6 +5500,7 @@ mod tests {
#[test] #[test]
fn resolves_model_aliases_in_args() { fn resolves_model_aliases_in_args() {
let _guard = env_lock();
let args = vec![ let args = vec![
"--model".to_string(), "--model".to_string(),
"opus".to_string(), "opus".to_string(),
@ -5492,6 +5554,7 @@ mod tests {
#[test] #[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() { fn parses_allowed_tools_flags_with_aliases_and_lists() {
let _guard = env_lock();
let args = vec![ let args = vec![
"--allowedTools".to_string(), "--allowedTools".to_string(),
"read,glob".to_string(), "read,glob".to_string(),
@ -5574,6 +5637,7 @@ mod tests {
#[test] #[test]
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() { fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
let _guard = env_lock();
assert_eq!( assert_eq!(
parse_args(&["help".to_string()]).expect("help should parse"), parse_args(&["help".to_string()]).expect("help should parse"),
CliAction::Help CliAction::Help
@ -5604,6 +5668,7 @@ mod tests {
#[test] #[test]
fn multi_word_prompt_still_uses_shorthand_prompt_mode() { fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
let _guard = env_lock();
assert_eq!( assert_eq!(
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()]) parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
.expect("prompt shorthand should still work"), .expect("prompt shorthand should still work"),

View file

@ -127,7 +127,10 @@ impl GlobalToolRegistry {
} }
} }
Ok(Self { plugin_tools, enforcer: None }) Ok(Self {
plugin_tools,
enforcer: None,
})
} }
#[must_use] #[must_use]
@ -242,8 +245,23 @@ impl GlobalToolRegistry {
} }
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> { pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
if let Some(enforcer) = &self.enforcer { self.execute_inner(name, input, true)
enforce_permission_check(enforcer, name, input)?; }
pub fn execute_preauthorized(&self, name: &str, input: &Value) -> Result<String, String> {
self.execute_inner(name, input, false)
}
fn execute_inner(
&self,
name: &str,
input: &Value,
enforce_permissions: bool,
) -> Result<String, String> {
if enforce_permissions {
if let Some(enforcer) = &self.enforcer {
enforce_permission_check(enforcer, name, input)?;
}
} }
if mvp_tool_specs().iter().any(|spec| spec.name == name) { if mvp_tool_specs().iter().any(|spec| spec.name == name) {
return execute_tool(name, input); return execute_tool(name, input);
@ -2798,7 +2816,10 @@ struct SubagentToolExecutor {
impl SubagentToolExecutor { impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>) -> Self { fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools, enforcer: None } Self {
allowed_tools,
enforcer: None,
}
} }
fn with_enforcer(mut self, enforcer: PermissionEnforcer) -> Self { fn with_enforcer(mut self, enforcer: PermissionEnforcer) -> Self {
@ -2817,8 +2838,7 @@ impl ToolExecutor for SubagentToolExecutor {
let value = serde_json::from_str(input) let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
if let Some(enforcer) = &self.enforcer { if let Some(enforcer) = &self.enforcer {
enforce_permission_check(enforcer, tool_name, &value) enforce_permission_check(enforcer, tool_name, &value).map_err(ToolError::new)?;
.map_err(ToolError::new)?;
} }
execute_tool(tool_name, &value).map_err(ToolError::new) execute_tool(tool_name, &value).map_err(ToolError::new)
} }
@ -4219,8 +4239,8 @@ mod tests {
use super::{ use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn, agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, permission_mode_from_plugin, execute_tool, final_assistant_text, mvp_tool_specs, permission_mode_from_plugin,
persist_agent_terminal_state, push_output_block, AgentInput, AgentJob, persist_agent_terminal_state, push_output_block, AgentInput, AgentJob, GlobalToolRegistry,
GlobalToolRegistry, SubagentToolExecutor, SubagentToolExecutor,
}; };
use api::OutputContentBlock; use api::OutputContentBlock;
use runtime::{ use runtime::{
@ -4243,10 +4263,11 @@ mod tests {
} }
fn permission_policy_for_mode(mode: PermissionMode) -> PermissionPolicy { fn permission_policy_for_mode(mode: PermissionMode) -> PermissionPolicy {
mvp_tool_specs().into_iter().fold( mvp_tool_specs()
PermissionPolicy::new(mode), .into_iter()
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), .fold(PermissionPolicy::new(mode), |policy, spec| {
) policy.with_tool_requirement(spec.name, spec.required_permission)
})
} }
#[test] #[test]
@ -4321,7 +4342,9 @@ mod tests {
.expect_err("subagent write tool should be denied before dispatch"); .expect_err("subagent write tool should be denied before dispatch");
// then // then
assert!(error.to_string().contains("requires workspace-write permission")); assert!(error
.to_string()
.contains("requires workspace-write permission"));
} }
#[test] #[test]
@ -5813,7 +5836,10 @@ printf 'pwsh:%s' "$1"
fn given_read_only_enforcer_when_write_file_then_denied() { fn given_read_only_enforcer_when_write_file_then_denied() {
let registry = read_only_registry(); let registry = read_only_registry();
let err = registry let err = registry
.execute("write_file", &json!({ "path": "/tmp/x.txt", "content": "x" })) .execute(
"write_file",
&json!({ "path": "/tmp/x.txt", "content": "x" }),
)
.expect_err("write_file should be denied in read-only mode"); .expect_err("write_file should be denied in read-only mode");
assert!( assert!(
err.contains("current mode is read-only"), err.contains("current mode is read-only"),
@ -5847,10 +5873,7 @@ printf 'pwsh:%s' "$1"
fs::write(&file, "content\n").expect("write test file"); fs::write(&file, "content\n").expect("write test file");
let registry = read_only_registry(); let registry = read_only_registry();
let result = registry.execute( let result = registry.execute("read_file", &json!({ "path": file.display().to_string() }));
"read_file",
&json!({ "path": file.display().to_string() }),
);
assert!(result.is_ok(), "read_file should be allowed: {result:?}"); assert!(result.is_ok(), "read_file should be allowed: {result:?}");
let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(root);
@ -5876,6 +5899,16 @@ printf 'pwsh:%s' "$1"
assert_eq!(output["stdout"], "ok"); assert_eq!(output["stdout"], "ok");
} }
#[test]
fn given_enforcer_when_execute_preauthorized_then_skips_redundant_permission_check() {
let registry = read_only_registry();
let result = registry
.execute_preauthorized("bash", &json!({ "command": "printf 'ok'" }))
.expect("preauthorized bash should skip registry enforcement");
let output: serde_json::Value = serde_json::from_str(&result).expect("json");
assert_eq!(output["stdout"], "ok");
}
struct TestServer { struct TestServer {
addr: SocketAddr, addr: SocketAddr,
shutdown: Option<std::sync::mpsc::Sender<()>>, shutdown: Option<std::sync::mpsc::Sender<()>>,