From 9ab569e6267e0d43f438382a8889246ea0b3774e Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 14 May 2026 17:18:50 +0900 Subject: [PATCH] omx(team): auto-checkpoint worker-2 [3] --- rust/crates/runtime/src/file_ops.rs | 64 ++++++++++++++++++- .../tests/output_format_contract.rs | 26 ++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index ef9a7a05..e78a3ac1 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -708,7 +708,7 @@ mod tests { use super::{ component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file, - GrepSearchInput, MAX_WRITE_SIZE, + write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE, }; fn temp_path(name: &str) -> std::path::PathBuf { @@ -808,6 +808,68 @@ mod tests { assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed")); } + #[test] + #[cfg(unix)] + fn workspace_read_rejects_symlink_escape_regression_3007_class() { + let workspace = temp_path("workspace-read-symlink-escape"); + let outside = temp_path("workspace-read-symlink-target"); + std::fs::create_dir_all(&workspace).expect("workspace dir should be created"); + std::fs::create_dir_all(&outside).expect("outside dir should be created"); + let outside_file = outside.join("secret.txt"); + std::fs::write(&outside_file, "outside secret").expect("outside file should write"); + + let link_path = workspace.join("linked-secret.txt"); + std::os::unix::fs::symlink(&outside_file, &link_path).expect("symlink should create"); + + let result = + read_file_in_workspace(link_path.to_string_lossy().as_ref(), None, None, &workspace); + + assert!(result.is_err(), "symlink escape must be rejected"); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!( + error.to_string().contains("escapes workspace"), + "error should explain workspace escape: {error}" + ); + + let _ = std::fs::remove_dir_all(&workspace); + let _ = std::fs::remove_dir_all(&outside); + } + + #[test] + #[cfg(unix)] + fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() { + let workspace = temp_path("workspace-write-symlink-escape"); + let outside = temp_path("workspace-write-symlink-target"); + std::fs::create_dir_all(&workspace).expect("workspace dir should be created"); + std::fs::create_dir_all(&outside).expect("outside dir should be created"); + + let link_dir = workspace.join("linked-outside"); + std::os::unix::fs::symlink(&outside, &link_dir).expect("symlink dir should create"); + let escaped_child = link_dir.join("created.txt"); + + let result = write_file_in_workspace( + escaped_child.to_string_lossy().as_ref(), + "must not escape", + &workspace, + ); + + assert!(result.is_err(), "parent symlink escape must be rejected"); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!( + error.to_string().contains("escapes workspace"), + "error should explain workspace escape: {error}" + ); + assert!( + !outside.join("created.txt").exists(), + "write should not create through an escaping symlink" + ); + + let _ = std::fs::remove_dir_all(&workspace); + let _ = std::fs::remove_dir_all(&outside); + } + #[test] fn globs_and_greps_directory() { let dir = temp_path("search-dir"); diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 108f76ea..56f9751e 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -91,6 +91,32 @@ fn status_and_sandbox_emit_json_when_requested() { assert!(sandbox["filesystem_mode"].as_str().is_some()); } +#[test] +fn status_json_surfaces_permission_mode_override_for_security_audit() { + let root = unique_temp_dir("status-json-permission-mode"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let parsed = assert_json_command( + &root, + &[ + "--permission-mode", + "read-only", + "--output-format", + "json", + "status", + ], + ); + + assert_eq!(parsed["kind"], "status"); + assert_eq!(parsed["permission_mode"], "read-only"); + assert!( + parsed["workspace"]["cwd"].as_str().is_some(), + "status JSON should retain workspace context with permission mode" + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); +} + #[test] fn acp_guidance_emits_json_when_requested() { let root = unique_temp_dir("acp-json");