diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 71000650..d7b7143a 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -9530,7 +9530,6 @@ printf 'pwsh:%s' "$1" registry } - #[test] fn path_scope_classifies_direct_paths_inside_and_outside_workspace() { let _guard = env_guard(); diff --git a/rust/crates/tools/tests/path_scope_enforcement.rs b/rust/crates/tools/tests/path_scope_enforcement.rs new file mode 100644 index 00000000..19f1878d --- /dev/null +++ b/rust/crates/tools/tests/path_scope_enforcement.rs @@ -0,0 +1,164 @@ +use runtime::{permission_enforcer::PermissionEnforcer, PermissionMode, PermissionPolicy}; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use tools::{mvp_tool_specs, GlobalToolRegistry}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn temp_path(name: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("claw-path-scope-{unique}-{name}")) +} + +fn workspace_write_registry() -> GlobalToolRegistry { + let policy = mvp_tool_specs().into_iter().fold( + PermissionPolicy::new(PermissionMode::WorkspaceWrite), + |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), + ); + GlobalToolRegistry::builtin().with_enforcer(PermissionEnforcer::new(policy)) +} + +fn run_bash(command: &str) -> Result { + workspace_write_registry().execute("bash", &json!({ "command": command })) +} + +fn assert_permission_denied(result: Result, case_name: &str) { + let err = result + .unwrap_err_or_else(|ok| panic!("{case_name} should be denied before execution, got {ok}")); + assert!( + err.contains("requires danger-full-access permission") + || err.contains("current mode is workspace-write"), + "{case_name} should fail in permission enforcement, got: {err}" + ); +} + +trait UnwrapErrOrElse { + fn unwrap_err_or_else E>(self, op: F) -> E; +} + +impl UnwrapErrOrElse for Result { + fn unwrap_err_or_else E>(self, op: F) -> E { + match self { + Ok(value) => op(value), + Err(error) => error, + } + } +} + +fn with_cwd(cwd: &Path, f: impl FnOnce() -> T) -> T { + let previous = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(cwd).expect("set cwd"); + let result = f(); + std::env::set_current_dir(previous).expect("restore cwd"); + result +} + +#[test] +fn direct_paths_allow_workspace_file_and_deny_absolute_outside_file() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("direct"); + fs::create_dir_all(root.join("src")).expect("create workspace"); + fs::write(root.join("src/lib.rs"), "workspace\n").expect("write workspace file"); + let outside = temp_path("direct-outside.txt"); + fs::write(&outside, "secret\n").expect("write outside file"); + + with_cwd(&root, || { + let allowed = run_bash("cat src/lib.rs").expect("workspace-relative read should execute"); + assert!(allowed.contains("workspace")); + assert_permission_denied( + run_bash(&format!("cat {}", outside.display())), + "absolute outside file", + ); + }); + + let _ = fs::remove_dir_all(root); + let _ = fs::remove_file(outside); +} + +#[cfg(unix)] +#[test] +fn symlink_resolving_outside_workspace_is_denied_before_execution() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("symlink"); + fs::create_dir_all(&root).expect("create workspace"); + let outside = temp_path("symlink-secret.txt"); + fs::write(&outside, "secret\n").expect("write outside file"); + std::os::unix::fs::symlink(&outside, root.join("secret-link")).expect("create symlink"); + + with_cwd(&root, || { + assert_permission_denied(run_bash("cat secret-link"), "outside symlink"); + }); + + let _ = fs::remove_dir_all(root); + let _ = fs::remove_file(outside); +} + +#[test] +fn shell_expansion_and_glob_parent_traversal_are_denied_before_execution() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("expansion"); + fs::create_dir_all(&root).expect("create workspace"); + + with_cwd(&root, || { + for (name, command) in [ + ("parent glob", "ls ../*"), + ("PWD parent expansion", "cat $PWD/../secret.txt"), + ("braced PWD parent expansion", "cat ${PWD}/../secret.txt"), + ( + "command substitution parent expansion", + "cat $(pwd)/../secret.txt", + ), + ] { + assert_permission_denied(run_bash(command), name); + } + }); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn nested_worktree_paths_are_allowed_but_parent_escape_is_denied() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("worktree"); + let worktree = root.join("main").join("linked-worktree"); + fs::create_dir_all(worktree.join("src")).expect("create worktree"); + fs::write(worktree.join("src/lib.rs"), "worktree\n").expect("write worktree file"); + + with_cwd(&worktree, || { + let allowed = + run_bash("cat src/lib.rs").expect("nested worktree-relative read should execute"); + assert!(allowed.contains("worktree")); + assert_permission_denied(run_bash("cat ../../outside.txt"), "worktree parent escape"); + }); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn windows_style_absolute_paths_are_denied_before_execution() { + for (name, command) in [ + ( + "windows drive backslash", + r"cat C:\Users\attacker\secret.txt", + ), + ("windows drive slash", r"cat C:/Users/attacker/secret.txt"), + ] { + assert_permission_denied(run_bash(command), name); + } +}