diff --git a/Cargo.lock b/Cargo.lock index 90518518c67..789e257c323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4423,6 +4423,7 @@ dependencies = [ "regex", "serde_json", "similar", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/ironclaw_first_party_extensions/Cargo.toml b/crates/ironclaw_first_party_extensions/Cargo.toml index 1608cbf1e82..4428e7814c0 100644 --- a/crates/ironclaw_first_party_extensions/Cargo.toml +++ b/crates/ironclaw_first_party_extensions/Cargo.toml @@ -29,4 +29,5 @@ tokio = { version = "1", features = ["rt", "sync"] } tracing = "0.1" [dev-dependencies] +tempfile = "3" tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/ironclaw_first_party_extensions/src/coding/mod.rs b/crates/ironclaw_first_party_extensions/src/coding/mod.rs index 25e207e4c3b..2e759acb3ca 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/mod.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/mod.rs @@ -186,6 +186,15 @@ fn bound_safe_summary(summary: String) -> String { #[cfg(test)] mod tests { + use std::sync::Arc; + + use ironclaw_filesystem::{LocalFilesystem, RootFilesystem}; + use ironclaw_host_api::{ + HostPath, InvocationId, MountAlias, MountGrant, MountPermissions, MountView, ResourceScope, + RuntimeDispatchErrorKind, UserId, VirtualPath, + }; + use serde_json::json; + #[test] fn coding_tools_do_not_select_runtime_backends() { let sources = [ @@ -214,4 +223,148 @@ mod tests { assert_eq!(super::bound_safe_summary(input.clone()), input); } + + #[tokio::test] + async fn coding_file_tools_treat_bare_workspace_prefix_as_scoped_alias() { + let temp_root = tempfile::TempDir::new().expect("temp root"); + let mut local_filesystem = LocalFilesystem::new(); + local_filesystem + .mount_local( + VirtualPath::new("/projects").expect("virtual path"), + HostPath::from_path_buf(temp_root.path().to_path_buf()), + ) + .expect("projects mount"); + let filesystem: Arc = Arc::new(local_filesystem); + let mounts = workspace_mounts(); + let scope = ResourceScope::local_default( + UserId::new("workspace-alias-user").expect("user id"), + InvocationId::new(), + ) + .expect("resource scope"); + let state = super::CodingCapabilityState::default(); + + let write_input = json!({ + "path": "workspace/demo/a.txt", + "content": "hello" + }); + let write_request = super::CodingCapabilityRequest::new( + super::CodingCapabilityKind::WriteFile, + &scope, + Some(&mounts), + Arc::clone(&filesystem), + &write_input, + ); + let write_output = state.dispatch(&write_request).await.expect("write file"); + + assert_eq!( + write_output.output["path"].as_str(), + Some("/workspace/demo/a.txt") + ); + let write_preview = write_output + .display_preview + .as_ref() + .expect("write preview"); + assert_eq!( + write_preview.subtitle.as_deref(), + Some("/workspace/demo/a.txt") + ); + assert!( + write_preview + .output_preview + .contains("--- a/workspace/demo/a.txt\n+++ b/workspace/demo/a.txt"), + "preview should use normalized path, got: {}", + write_preview.output_preview + ); + assert_eq!( + filesystem + .read_file( + &VirtualPath::new("/projects/workspace/demo/a.txt").expect("virtual path") + ) + .await + .expect("normalized write path exists"), + b"hello".to_vec() + ); + assert!(temp_root.path().join("workspace/demo/a.txt").exists()); + assert!( + !temp_root + .path() + .join("workspace/workspace/demo/a.txt") + .exists() + ); + + let read_input = json!({ "path": "workspace/demo/a.txt" }); + let read_request = super::CodingCapabilityRequest::new( + super::CodingCapabilityKind::ReadFile, + &scope, + Some(&mounts), + Arc::clone(&filesystem), + &read_input, + ); + let read_output = state.dispatch(&read_request).await.expect("read file"); + + assert_eq!( + read_output.output["path"].as_str(), + Some("/workspace/demo/a.txt") + ); + assert_eq!( + read_output.output["content"].as_str(), + Some(" 1│ hello") + ); + + let url_like_input = json!({ + "path": "workspace/http://example.com/a.txt", + "content": "blocked" + }); + let url_like_request = super::CodingCapabilityRequest::new( + super::CodingCapabilityKind::WriteFile, + &scope, + Some(&mounts), + Arc::clone(&filesystem), + &url_like_input, + ); + let err = state + .dispatch(&url_like_request) + .await + .expect_err("URL-like workspace alias path rejected"); + + assert_eq!(err.kind(), RuntimeDispatchErrorKind::InputEncode); + assert!( + !temp_root + .path() + .join("workspace/http:/example.com/a.txt") + .exists(), + "URL-like path must not be normalized into a writable scoped path" + ); + + let reserved_workspace_file_input = json!({ + "path": "workspace//HEARTBEAT.md", + "content": "blocked" + }); + let reserved_workspace_file_request = super::CodingCapabilityRequest::new( + super::CodingCapabilityKind::WriteFile, + &scope, + Some(&mounts), + filesystem, + &reserved_workspace_file_input, + ); + let err = state + .dispatch(&reserved_workspace_file_request) + .await + .expect_err("empty alias segments preserve reserved workspace file guard"); + + assert_eq!(err.kind(), RuntimeDispatchErrorKind::InputEncode); + assert!( + !temp_root.path().join("workspace/HEARTBEAT.md").exists(), + "reserved workspace memory file must not be written through empty alias segments" + ); + } + + fn workspace_mounts() -> MountView { + MountView::new(vec![MountGrant::new( + MountAlias::new("/workspace").expect("mount alias"), + VirtualPath::new("/projects/workspace").expect("virtual path"), + MountPermissions::read_write(), + )]) + .expect("mount view") + } } diff --git a/crates/ironclaw_first_party_extensions/src/coding/paths.rs b/crates/ironclaw_first_party_extensions/src/coding/paths.rs index 9100131017b..1ec469a11bb 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/paths.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/paths.rs @@ -74,11 +74,38 @@ fn scoped_path_input(path: &str) -> String { DEFAULT_SCOPED_ROOT.to_string() } else if path.starts_with('/') { path.to_string() + } else if let Some(scoped_workspace_path) = workspace_scoped_alias(path) { + scoped_workspace_path } else { - format!("{}/{}", DEFAULT_SCOPED_ROOT, path.trim_start_matches("./")) + let relative = path.trim_start_matches("./"); + format!("{DEFAULT_SCOPED_ROOT}/{relative}") } } +fn workspace_scoped_alias(path: &str) -> Option { + let path = strip_leading_current_dir_segments(path); + if path == "workspace" { + return Some(DEFAULT_SCOPED_ROOT.to_string()); + } + + path.strip_prefix("workspace/") + .map(|relative| relative.trim_start_matches('/')) + .map(|relative| { + if relative.is_empty() { + DEFAULT_SCOPED_ROOT.to_string() + } else { + format!("{DEFAULT_SCOPED_ROOT}/{relative}") + } + }) +} + +fn strip_leading_current_dir_segments(mut path: &str) -> &str { + while let Some(stripped) = path.strip_prefix("./") { + path = stripped; + } + path +} + pub(super) fn operation_allowed( permissions: &ironclaw_host_api::MountPermissions, operation: FilesystemOperation,