mirror of
https://github.com/instructkr/claw-code.git
synced 2026-05-18 21:41:26 +08:00
omx(team): auto-checkpoint worker-1 [1]
This commit is contained in:
@@ -74,9 +74,10 @@ pub use conversation::{
|
|||||||
ToolExecutor, TurnSummary,
|
ToolExecutor, TurnSummary,
|
||||||
};
|
};
|
||||||
pub use file_ops::{
|
pub use file_ops::{
|
||||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||||
WriteFileOutput,
|
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||||
|
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||||
};
|
};
|
||||||
pub use git_context::{GitCommitEntry, GitContext};
|
pub use git_context::{GitCommitEntry, GitContext};
|
||||||
pub use hooks::{
|
pub use hooks::{
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ use api::{
|
|||||||
use plugins::PluginTool;
|
use plugins::PluginTool;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
check_freshness, dedupe_superseded_commit_events, edit_file_in_workspace, execute_bash,
|
||||||
grep_search, load_system_prompt,
|
glob_search_in_workspace, grep_search_in_workspace, load_system_prompt,
|
||||||
lsp_client::LspRegistry,
|
lsp_client::LspRegistry,
|
||||||
mcp_tool_bridge::McpToolRegistry,
|
mcp_tool_bridge::McpToolRegistry,
|
||||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||||
read_file,
|
read_file_in_workspace,
|
||||||
summary_compression::compress_summary_text,
|
summary_compression::compress_summary_text,
|
||||||
task_registry::TaskRegistry,
|
task_registry::TaskRegistry,
|
||||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||||
worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt},
|
worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt},
|
||||||
write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput,
|
write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput,
|
||||||
BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage, ConversationRuntime,
|
BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage,
|
||||||
GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName,
|
ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||||
LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode,
|
LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole,
|
||||||
PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, Session, TaskPacket,
|
PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError,
|
||||||
ToolError, ToolExecutor,
|
Session, TaskPacket, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -1885,19 +1885,34 @@ fn classify_bash_permission(command: &str) -> PermissionMode {
|
|||||||
fn has_dangerous_paths(command: &str) -> bool {
|
fn has_dangerous_paths(command: &str) -> bool {
|
||||||
// Look for absolute paths
|
// Look for absolute paths
|
||||||
let tokens: Vec<&str> = command.split_whitespace().collect();
|
let tokens: Vec<&str> = command.split_whitespace().collect();
|
||||||
|
let cwd = std::env::current_dir().ok();
|
||||||
|
|
||||||
for token in tokens {
|
for token in tokens {
|
||||||
|
let token = token.trim_matches(|ch: char| {
|
||||||
|
matches!(
|
||||||
|
ch,
|
||||||
|
'"' | '\'' | '`' | ',' | ';' | ')' | '(' | '[' | ']' | '{' | '}'
|
||||||
|
)
|
||||||
|
});
|
||||||
// Skip flags/options
|
// Skip flags/options
|
||||||
if token.starts_with('-') {
|
if token.starts_with('-') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if token.contains('$') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if looks_like_windows_absolute_path(token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for absolute paths
|
// Check for absolute paths
|
||||||
if token.starts_with('/') || token.starts_with("~/") {
|
if token.starts_with('/') || token.starts_with("~/") {
|
||||||
// Check if it's within CWD
|
// Check if it's within CWD
|
||||||
let path =
|
let path =
|
||||||
PathBuf::from(token.replace('~', &std::env::var("HOME").unwrap_or_default()));
|
PathBuf::from(token.replace('~', &std::env::var("HOME").unwrap_or_default()));
|
||||||
if let Ok(cwd) = std::env::current_dir() {
|
if let Some(cwd) = cwd.as_ref() {
|
||||||
if !path.starts_with(&cwd) {
|
if !path.starts_with(&cwd) {
|
||||||
return true; // Path outside workspace
|
return true; // Path outside workspace
|
||||||
}
|
}
|
||||||
@@ -1908,11 +1923,35 @@ fn has_dangerous_paths(command: &str) -> bool {
|
|||||||
if token.contains("../..") || token.starts_with("../") && !token.starts_with("./") {
|
if token.contains("../..") || token.starts_with("../") && !token.starts_with("./") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(cwd) = cwd.as_ref() {
|
||||||
|
if token.starts_with('.') || token.contains('/') || Path::new(token).exists() {
|
||||||
|
let candidate = if Path::new(token).is_absolute() {
|
||||||
|
PathBuf::from(token)
|
||||||
|
} else {
|
||||||
|
cwd.join(token)
|
||||||
|
};
|
||||||
|
if let Ok(canonical) = candidate.canonicalize() {
|
||||||
|
if !canonical.starts_with(cwd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn looks_like_windows_absolute_path(token: &str) -> bool {
|
||||||
|
let bytes = token.as_bytes();
|
||||||
|
(bytes.len() >= 3
|
||||||
|
&& bytes[0].is_ascii_alphabetic()
|
||||||
|
&& bytes[1] == b':'
|
||||||
|
&& matches!(bytes[2], b'/' | b'\\'))
|
||||||
|
|| token.starts_with(r"\\")
|
||||||
|
}
|
||||||
|
|
||||||
fn run_bash(input: BashCommandInput) -> Result<String, String> {
|
fn run_bash(input: BashCommandInput) -> Result<String, String> {
|
||||||
if let Some(output) = workspace_test_branch_preflight(&input.command) {
|
if let Some(output) = workspace_test_branch_preflight(&input.command) {
|
||||||
return serde_json::to_string_pretty(&output).map_err(|error| error.to_string());
|
return serde_json::to_string_pretty(&output).map_err(|error| error.to_string());
|
||||||
@@ -2069,22 +2108,31 @@ fn branch_divergence_output(
|
|||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||||
to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
|
to_pretty_json(
|
||||||
|
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
|
||||||
|
.map_err(io_to_string)?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_write_file(input: WriteFileInput) -> Result<String, String> {
|
fn run_write_file(input: WriteFileInput) -> Result<String, String> {
|
||||||
to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
|
to_pretty_json(
|
||||||
|
write_file_in_workspace(&input.path, &input.content, &workspace).map_err(io_to_string)?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
||||||
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
to_pretty_json(
|
to_pretty_json(
|
||||||
edit_file(
|
edit_file_in_workspace(
|
||||||
&input.path,
|
&input.path,
|
||||||
&input.old_string,
|
&input.old_string,
|
||||||
&input.new_string,
|
&input.new_string,
|
||||||
input.replace_all.unwrap_or(false),
|
input.replace_all.unwrap_or(false),
|
||||||
|
&workspace,
|
||||||
)
|
)
|
||||||
.map_err(io_to_string)?,
|
.map_err(io_to_string)?,
|
||||||
)
|
)
|
||||||
@@ -2092,12 +2140,17 @@ fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
|||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
|
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
|
||||||
to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
|
to_pretty_json(
|
||||||
|
glob_search_in_workspace(&input.pattern, input.path.as_deref(), &workspace)
|
||||||
|
.map_err(io_to_string)?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
|
fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
|
||||||
to_pretty_json(grep_search(&input).map_err(io_to_string)?)
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
|
to_pretty_json(grep_search_in_workspace(&input, &workspace).map_err(io_to_string)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
@@ -9117,6 +9170,78 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_tools_reject_paths_outside_current_workspace() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let root = temp_path("workspace-scope");
|
||||||
|
let outside = temp_path("workspace-scope-outside");
|
||||||
|
fs::create_dir_all(&root).expect("create root");
|
||||||
|
fs::create_dir_all(&outside).expect("create outside");
|
||||||
|
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&root).expect("set cwd");
|
||||||
|
|
||||||
|
let read_error = execute_tool(
|
||||||
|
"read_file",
|
||||||
|
&json!({ "path": outside.join("secret.txt").display().to_string() }),
|
||||||
|
)
|
||||||
|
.expect_err("read outside workspace should fail");
|
||||||
|
assert!(read_error.contains("escapes workspace"));
|
||||||
|
|
||||||
|
let write_error = execute_tool(
|
||||||
|
"write_file",
|
||||||
|
&json!({ "path": outside.join("created.txt").display().to_string(), "content": "nope" }),
|
||||||
|
)
|
||||||
|
.expect_err("write outside workspace should fail");
|
||||||
|
assert!(write_error.contains("escapes workspace"));
|
||||||
|
assert!(!outside.join("created.txt").exists());
|
||||||
|
|
||||||
|
let glob_error = execute_tool(
|
||||||
|
"glob_search",
|
||||||
|
&json!({ "pattern": outside.join("*.txt").display().to_string() }),
|
||||||
|
)
|
||||||
|
.expect_err("absolute glob outside workspace should fail");
|
||||||
|
assert!(glob_error.contains("escapes workspace"));
|
||||||
|
|
||||||
|
let grep_error = execute_tool(
|
||||||
|
"grep_search",
|
||||||
|
&json!({ "pattern": "secret", "path": outside.display().to_string() }),
|
||||||
|
)
|
||||||
|
.expect_err("grep outside workspace should fail");
|
||||||
|
assert!(grep_error.contains("escapes workspace"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
let _ = fs::remove_dir_all(outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn file_tools_reject_symlink_escape_from_current_workspace() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let root = temp_path("workspace-symlink-scope");
|
||||||
|
let outside = temp_path("workspace-symlink-outside");
|
||||||
|
fs::create_dir_all(&root).expect("create root");
|
||||||
|
fs::create_dir_all(&outside).expect("create outside");
|
||||||
|
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||||
|
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt"))
|
||||||
|
.expect("create symlink");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&root).expect("set cwd");
|
||||||
|
|
||||||
|
let error = execute_tool("read_file", &json!({ "path": "link.txt" }))
|
||||||
|
.expect_err("symlink outside workspace should fail");
|
||||||
|
assert!(error.contains("escapes workspace"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
let _ = fs::remove_dir_all(outside);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sleep_waits_and_reports_duration() {
|
fn sleep_waits_and_reports_duration() {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
@@ -9530,6 +9655,19 @@ printf 'pwsh:%s' "$1"
|
|||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_write_registry() -> super::GlobalToolRegistry {
|
||||||
|
use runtime::permission_enforcer::PermissionEnforcer;
|
||||||
|
use runtime::PermissionPolicy;
|
||||||
|
|
||||||
|
let policy = mvp_tool_specs().into_iter().fold(
|
||||||
|
PermissionPolicy::new(runtime::PermissionMode::WorkspaceWrite),
|
||||||
|
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|
||||||
|
);
|
||||||
|
let mut registry = super::GlobalToolRegistry::builtin();
|
||||||
|
registry.set_enforcer(PermissionEnforcer::new(policy));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_scope_classifies_direct_paths_inside_and_outside_workspace() {
|
fn path_scope_classifies_direct_paths_inside_and_outside_workspace() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
@@ -9660,6 +9798,63 @@ printf 'pwsh:%s' "$1"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
|
||||||
|
let registry = workspace_write_registry();
|
||||||
|
let err = registry
|
||||||
|
.execute("bash", &json!({ "command": "cat $HOME/.ssh/config" }))
|
||||||
|
.expect_err("shell-expanded path should require elevated permission");
|
||||||
|
assert!(
|
||||||
|
err.contains("requires 'danger-full-access'"),
|
||||||
|
"should require elevated mode: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_workspace_write_enforcer_when_bash_uses_windows_absolute_path_then_denied() {
|
||||||
|
let registry = workspace_write_registry();
|
||||||
|
let err = registry
|
||||||
|
.execute(
|
||||||
|
"bash",
|
||||||
|
&json!({ "command": r"cat C:\\Users\\alice\\.ssh\\config" }),
|
||||||
|
)
|
||||||
|
.expect_err("Windows absolute path should require elevated permission");
|
||||||
|
assert!(
|
||||||
|
err.contains("requires 'danger-full-access'"),
|
||||||
|
"should require elevated mode: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn given_workspace_write_enforcer_when_bash_reads_symlink_escape_then_denied() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let root = temp_path("bash-symlink-scope");
|
||||||
|
let outside = temp_path("bash-symlink-outside");
|
||||||
|
fs::create_dir_all(&root).expect("create root");
|
||||||
|
fs::create_dir_all(&outside).expect("create outside");
|
||||||
|
fs::write(outside.join("secret.txt"), "secret\n").expect("outside fixture");
|
||||||
|
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt"))
|
||||||
|
.expect("create symlink");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&root).expect("set cwd");
|
||||||
|
|
||||||
|
let registry = workspace_write_registry();
|
||||||
|
let err = registry
|
||||||
|
.execute("bash", &json!({ "command": "cat link.txt" }))
|
||||||
|
.expect_err("symlink escape should require elevated permission");
|
||||||
|
assert!(
|
||||||
|
err.contains("requires 'danger-full-access'"),
|
||||||
|
"should require elevated mode: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
let _ = fs::remove_dir_all(outside);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user