mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-18 00:00:11 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09731036ac |
No files matched your search
@@ -466,31 +466,39 @@ impl ConfigLoader {
|
|||||||
let mut merged = BTreeMap::new();
|
let mut merged = BTreeMap::new();
|
||||||
let mut loaded_entries = Vec::new();
|
let mut loaded_entries = Vec::new();
|
||||||
let mut mcp = McpConfigCollection::default();
|
let mut mcp = McpConfigCollection::default();
|
||||||
let mut all_warnings = Vec::new();
|
let mut all_warnings: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for entry in self.discover() {
|
for entry in self.discover() {
|
||||||
crate::config_validate::check_unsupported_format(&entry.path)?;
|
crate::config_validate::check_unsupported_format(&entry.path)?;
|
||||||
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let validation = crate::config_validate::validate_config_file(
|
let ParsedConfigFile { mut object, source } = parsed;
|
||||||
&parsed.object,
|
if let Some(warning) =
|
||||||
&parsed.source,
|
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||||
&entry.path,
|
{
|
||||||
);
|
all_warnings.push(warning);
|
||||||
|
}
|
||||||
|
let validation =
|
||||||
|
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||||
if !validation.is_ok() {
|
if !validation.is_ok() {
|
||||||
let first_error = &validation.errors[0];
|
let first_error = &validation.errors[0];
|
||||||
return Err(ConfigError::Parse(first_error.to_string()));
|
return Err(ConfigError::Parse(first_error.to_string()));
|
||||||
}
|
}
|
||||||
all_warnings.extend(validation.warnings);
|
all_warnings.extend(
|
||||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
validation
|
||||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
.warnings
|
||||||
deep_merge_objects(&mut merged, &parsed.object);
|
.into_iter()
|
||||||
|
.map(|warning| warning.to_string()),
|
||||||
|
);
|
||||||
|
validate_optional_hooks_config(&object, &entry.path)?;
|
||||||
|
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
|
||||||
|
deep_merge_objects(&mut merged, &object);
|
||||||
loaded_entries.push(entry);
|
loaded_entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
for warning in &all_warnings {
|
for warning in &all_warnings {
|
||||||
emit_config_warning_once(&warning.to_string());
|
emit_config_warning_once(warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
build_runtime_config(merged, loaded_entries, mcp)
|
build_runtime_config(merged, loaded_entries, mcp)
|
||||||
@@ -512,19 +520,22 @@ impl ConfigLoader {
|
|||||||
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let validation = crate::config_validate::validate_config_file(
|
let ParsedConfigFile { mut object, source } = parsed;
|
||||||
&parsed.object,
|
if let Some(warning) =
|
||||||
&parsed.source,
|
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||||
&entry.path,
|
{
|
||||||
);
|
all_warnings.push(warning);
|
||||||
|
}
|
||||||
|
let validation =
|
||||||
|
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||||
if !validation.is_ok() {
|
if !validation.is_ok() {
|
||||||
let first_error = &validation.errors[0];
|
let first_error = &validation.errors[0];
|
||||||
return Err(ConfigError::Parse(first_error.to_string()));
|
return Err(ConfigError::Parse(first_error.to_string()));
|
||||||
}
|
}
|
||||||
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
||||||
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
validate_optional_hooks_config(&object, &entry.path)?;
|
||||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
|
||||||
deep_merge_objects(&mut merged, &parsed.object);
|
deep_merge_objects(&mut merged, &object);
|
||||||
loaded_entries.push(entry);
|
loaded_entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,11 +600,14 @@ impl ConfigLoader {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let validation = crate::config_validate::validate_config_file(
|
let ParsedConfigFile { mut object, source } = parsed;
|
||||||
&parsed.object,
|
if let Some(warning) =
|
||||||
&parsed.source,
|
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
|
||||||
&entry.path,
|
{
|
||||||
);
|
warnings.push(warning);
|
||||||
|
}
|
||||||
|
let validation =
|
||||||
|
crate::config_validate::validate_config_file(&object, &source, &entry.path);
|
||||||
if !validation.is_ok() {
|
if !validation.is_ok() {
|
||||||
let detail = validation.errors[0].to_string();
|
let detail = validation.errors[0].to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
@@ -612,7 +626,7 @@ impl ConfigLoader {
|
|||||||
.map(|warning| warning.to_string()),
|
.map(|warning| warning.to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) {
|
if let Err(error) = validate_optional_hooks_config(&object, &entry.path) {
|
||||||
let detail = error.to_string();
|
let detail = error.to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(
|
files.push(ConfigFileReport::load_error(
|
||||||
@@ -624,9 +638,7 @@ impl ConfigLoader {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) =
|
if let Err(error) = merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path) {
|
||||||
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
|
|
||||||
{
|
|
||||||
let detail = error.to_string();
|
let detail = error.to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(
|
files.push(ConfigFileReport::load_error(
|
||||||
@@ -638,8 +650,8 @@ impl ConfigLoader {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key_paths = collect_config_key_paths(&parsed.object);
|
let key_paths = collect_config_key_paths(&object);
|
||||||
deep_merge_objects(&mut merged, &parsed.object);
|
deep_merge_objects(&mut merged, &object);
|
||||||
loaded_entries.push(entry.clone());
|
loaded_entries.push(entry.clone());
|
||||||
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
|
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
|
||||||
}
|
}
|
||||||
@@ -663,6 +675,23 @@ impl ConfigLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strip_untrusted_project_hooks(
|
||||||
|
source: ConfigSource,
|
||||||
|
object: &mut BTreeMap<String, JsonValue>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Option<String> {
|
||||||
|
// Project settings come from the repository and there is no project-hook
|
||||||
|
// trust approval flow yet. User and local settings remain eligible.
|
||||||
|
if source == ConfigSource::Project && object.remove("hooks").is_some() {
|
||||||
|
Some(format!(
|
||||||
|
"Ignoring project hooks in {} because project-hook trust approval is not supported.",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConfigFileReport {
|
impl ConfigFileReport {
|
||||||
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
|
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -2590,6 +2619,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
|
use crate::HookRunner;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -2695,17 +2725,14 @@ mod tests {
|
|||||||
.and_then(JsonValue::as_object)
|
.and_then(JsonValue::as_object)
|
||||||
.expect("hooks object")
|
.expect("hooks object")
|
||||||
.contains_key("PreToolUse"));
|
.contains_key("PreToolUse"));
|
||||||
assert!(loaded
|
assert!(!loaded
|
||||||
.get("hooks")
|
.get("hooks")
|
||||||
.and_then(JsonValue::as_object)
|
.and_then(JsonValue::as_object)
|
||||||
.expect("hooks object")
|
.expect("hooks object")
|
||||||
.contains_key("PostToolUse"));
|
.contains_key("PostToolUse"));
|
||||||
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
||||||
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
|
assert!(loaded.hooks().post_tool_use().is_empty());
|
||||||
assert_eq!(
|
assert!(loaded.hooks().post_tool_use_failure().is_empty());
|
||||||
loaded.hooks().post_tool_use_failure(),
|
|
||||||
&["project-failure".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
|
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.permission_rules().deny(),
|
loaded.permission_rules().deny(),
|
||||||
@@ -2718,6 +2745,73 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_config_hooks_cannot_execute_commands() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
let project_settings = cwd.join(".claw").join("settings.json");
|
||||||
|
let project_legacy_settings = cwd.join(".claw.json");
|
||||||
|
let marker = root.join("project-hook-executed");
|
||||||
|
fs::create_dir_all(project_settings.parent().expect("project config dir"))
|
||||||
|
.expect("create project config dir");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
let command = format!("echo compromised >> {}", marker.display());
|
||||||
|
fs::write(
|
||||||
|
&project_legacy_settings,
|
||||||
|
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||||
|
)
|
||||||
|
.expect("write untrusted legacy project hook");
|
||||||
|
fs::write(
|
||||||
|
&project_settings,
|
||||||
|
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||||
|
)
|
||||||
|
.expect("write untrusted project hook");
|
||||||
|
|
||||||
|
let loader = ConfigLoader::new(&cwd, &home);
|
||||||
|
let loaded = loader.load().expect("config should load");
|
||||||
|
let _ =
|
||||||
|
HookRunner::from_feature_config(loaded.feature_config()).run_pre_tool_use("Read", "{}");
|
||||||
|
|
||||||
|
let hook_executed = marker.exists();
|
||||||
|
if hook_executed {
|
||||||
|
fs::remove_file(&marker).expect("remove hook marker");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!hook_executed,
|
||||||
|
"untrusted project hook executed arbitrary shell command"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (collected, warnings) = loader
|
||||||
|
.load_collecting_warnings()
|
||||||
|
.expect("collecting config should load");
|
||||||
|
assert!(collected.hooks().pre_tool_use().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|warning| warning.contains("Ignoring project hooks"))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
let inspected = loader.inspect_collecting_warnings();
|
||||||
|
assert_eq!(
|
||||||
|
inspected
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|warning| warning.contains("Ignoring project hooks"))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(inspected
|
||||||
|
.runtime_config
|
||||||
|
.expect("inspection should produce runtime config")
|
||||||
|
.hooks()
|
||||||
|
.pre_tool_use()
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_object_style_hook_entries_with_matchers() {
|
fn parses_object_style_hook_entries_with_matchers() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
@@ -3523,7 +3617,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
|
fn ignores_project_hook_entries_and_keeps_user_hooks_441() {
|
||||||
// given
|
// given
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
let cwd = root.join("project");
|
let cwd = root.join("project");
|
||||||
@@ -3547,22 +3641,8 @@ mod tests {
|
|||||||
.load()
|
.load()
|
||||||
.expect("config should load valid hook entries and record invalid siblings");
|
.expect("config should load valid hook entries and record invalid siblings");
|
||||||
|
|
||||||
// #106: arrays now concatenate across config layers, so both "base" and "project" are present
|
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
||||||
assert_eq!(
|
assert!(loaded.hooks().invalid_hooks().is_empty());
|
||||||
loaded.hooks().pre_tool_use(),
|
|
||||||
&["base".to_string(), "project".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(loaded.hooks().invalid_count(), 1);
|
|
||||||
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
|
|
||||||
assert_eq!(
|
|
||||||
loaded.hooks().invalid_hooks()[0].kind,
|
|
||||||
"invalid_hooks_config"
|
|
||||||
);
|
|
||||||
// #106: invalid entry at index 2 after array concatenation
|
|
||||||
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(2));
|
|
||||||
assert!(loaded.hooks().invalid_hooks()[0]
|
|
||||||
.reason
|
|
||||||
.contains("must be a string or hook object"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::compact::{
|
|||||||
use crate::config::RuntimeFeatureConfig;
|
use crate::config::RuntimeFeatureConfig;
|
||||||
use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
|
use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
|
||||||
use crate::permissions::{
|
use crate::permissions::{
|
||||||
PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter,
|
PermissionContext, PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPrompter,
|
||||||
};
|
};
|
||||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||||
use crate::usage::{TokenUsage, UsageTracker};
|
use crate::usage::{TokenUsage, UsageTracker};
|
||||||
@@ -176,6 +176,11 @@ where
|
|||||||
feature_config: &RuntimeFeatureConfig,
|
feature_config: &RuntimeFeatureConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let usage_tracker = UsageTracker::from_session(&session);
|
let usage_tracker = UsageTracker::from_session(&session);
|
||||||
|
let hook_runner = if permission_policy.active_mode() == PermissionMode::ReadOnly {
|
||||||
|
HookRunner::default()
|
||||||
|
} else {
|
||||||
|
HookRunner::from_feature_config(feature_config)
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
session,
|
session,
|
||||||
api_client,
|
api_client,
|
||||||
@@ -184,7 +189,7 @@ where
|
|||||||
system_prompt,
|
system_prompt,
|
||||||
max_iterations: usize::MAX,
|
max_iterations: usize::MAX,
|
||||||
usage_tracker,
|
usage_tracker,
|
||||||
hook_runner: HookRunner::from_feature_config(feature_config),
|
hook_runner,
|
||||||
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
||||||
hook_abort_signal: HookAbortSignal::default(),
|
hook_abort_signal: HookAbortSignal::default(),
|
||||||
hook_progress_reporter: None,
|
hook_progress_reporter: None,
|
||||||
@@ -991,6 +996,82 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_mode_does_not_execute_hooks() {
|
||||||
|
struct SingleCallApiClient {
|
||||||
|
calls: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient for SingleCallApiClient {
|
||||||
|
fn stream(
|
||||||
|
&mut self,
|
||||||
|
_request: ApiRequest,
|
||||||
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
self.calls += 1;
|
||||||
|
if self.calls == 1 {
|
||||||
|
Ok(vec![
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "Read".to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-2".to_string(),
|
||||||
|
name: "ReadError".to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Ok(vec![
|
||||||
|
AssistantEvent::TextDelta("done".to_string()),
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time should be after epoch")
|
||||||
|
.as_nanos();
|
||||||
|
let marker = std::env::temp_dir().join(format!(
|
||||||
|
"claw-read-only-hook-{}-{nanos}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let hook_command = format!("echo compromised >> \"{}\"", marker.display());
|
||||||
|
let features = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||||
|
vec![shell_snippet(&hook_command)],
|
||||||
|
vec![shell_snippet(&hook_command)],
|
||||||
|
vec![shell_snippet(&hook_command)],
|
||||||
|
));
|
||||||
|
let mut runtime = ConversationRuntime::new_with_features(
|
||||||
|
Session::new(),
|
||||||
|
SingleCallApiClient { calls: 0 },
|
||||||
|
StaticToolExecutor::new()
|
||||||
|
.register("Read", |_input| Ok("read result".to_string()))
|
||||||
|
.register("ReadError", |_input| Err(ToolError::new("read failed"))),
|
||||||
|
PermissionPolicy::new(PermissionMode::ReadOnly)
|
||||||
|
.with_tool_requirement("Read", PermissionMode::ReadOnly)
|
||||||
|
.with_tool_requirement("ReadError", PermissionMode::ReadOnly),
|
||||||
|
vec!["system".to_string()],
|
||||||
|
&features,
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.run_turn("read a file", None)
|
||||||
|
.expect("read-only tool call should succeed");
|
||||||
|
|
||||||
|
let hook_executed = marker.exists();
|
||||||
|
if hook_executed {
|
||||||
|
fs::remove_file(&marker).expect("remove hook marker");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!hook_executed,
|
||||||
|
"read-only mode must not execute pre-, post-, or failure hooks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_runtime_session_trace_events() {
|
fn records_runtime_session_trace_events() {
|
||||||
let sink = Arc::new(MemoryTelemetrySink::default());
|
let sink = Arc::new(MemoryTelemetrySink::default());
|
||||||
|
|||||||
Reference in new issue
Block a user