Compare commits
2 commits
main
...
gaebal/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10bf8f595 | ||
|
|
bf59abc9e9 |
3 changed files with 126 additions and 23 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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<()>>,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue