From 5fb35be1b2f7b649114a43ed4e6acac2b0e958bc Mon Sep 17 00:00:00 2001 From: Coffee Date: Sun, 14 Jun 2026 11:12:06 +0800 Subject: [PATCH 1/3] fix(reborn): normalize bare workspace tool paths --- Cargo.lock | 1 + .../Cargo.toml | 1 + .../src/coding/mod.rs | 106 ++++++++++++++++++ .../src/coding/paths.rs | 33 +++++- 4 files changed, 140 insertions(+), 1 deletion(-) 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..4498bce4f23 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, + UserId, VirtualPath, + }; + use serde_json::json; + #[test] fn coding_tools_do_not_select_runtime_backends() { let sources = [ @@ -214,4 +223,101 @@ 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), + 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") + ); + } + + 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..5e769dd5d65 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/paths.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/paths.rs @@ -1,3 +1,5 @@ +use std::path::{Component, Path}; + use ironclaw_filesystem::{FileStat, FilesystemError, FilesystemOperation}; use ironclaw_host_api::{RuntimeDispatchErrorKind, ScopedPath, VirtualPath}; use ironclaw_safety::sensitive_paths::is_sensitive_path_str; @@ -74,9 +76,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 mut components = Path::new(path).components().peekable(); + while matches!(components.peek(), Some(Component::CurDir)) { + components.next(); + } + + match components.next()? { + Component::Normal(segment) if segment == "workspace" => {} + _ => return None, + } + + let mut scoped_path = DEFAULT_SCOPED_ROOT.to_string(); + for component in components { + match component { + Component::Normal(segment) => { + scoped_path.push('/'); + scoped_path.push_str(segment.to_str()?); + } + Component::CurDir => {} + Component::ParentDir => scoped_path.push_str("/.."), + Component::RootDir | Component::Prefix(_) => return None, + } } + Some(scoped_path) } pub(super) fn operation_allowed( From 58306e0677ec75e3d18e5b614d8a548c5e3fda2b Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Sun, 14 Jun 2026 15:31:55 +0000 Subject: [PATCH 2/3] Preserve scoped path URL validation for workspace aliases --- .../src/coding/mod.rs | 29 +++++++++++++++-- .../src/coding/paths.rs | 31 ++++++------------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/ironclaw_first_party_extensions/src/coding/mod.rs b/crates/ironclaw_first_party_extensions/src/coding/mod.rs index 4498bce4f23..7082f1248d1 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/mod.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/mod.rs @@ -191,7 +191,7 @@ mod tests { use ironclaw_filesystem::{LocalFilesystem, RootFilesystem}; use ironclaw_host_api::{ HostPath, InvocationId, MountAlias, MountGrant, MountPermissions, MountView, ResourceScope, - UserId, VirtualPath, + RuntimeDispatchErrorKind, UserId, VirtualPath, }; use serde_json::json; @@ -297,7 +297,7 @@ mod tests { super::CodingCapabilityKind::ReadFile, &scope, Some(&mounts), - filesystem, + Arc::clone(&filesystem), &read_input, ); let read_output = state.dispatch(&read_request).await.expect("read file"); @@ -310,6 +310,31 @@ mod tests { 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), + 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" + ); } fn workspace_mounts() -> MountView { diff --git a/crates/ironclaw_first_party_extensions/src/coding/paths.rs b/crates/ironclaw_first_party_extensions/src/coding/paths.rs index 5e769dd5d65..b936e949def 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/paths.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/paths.rs @@ -1,5 +1,3 @@ -use std::path::{Component, Path}; - use ironclaw_filesystem::{FileStat, FilesystemError, FilesystemOperation}; use ironclaw_host_api::{RuntimeDispatchErrorKind, ScopedPath, VirtualPath}; use ironclaw_safety::sensitive_paths::is_sensitive_path_str; @@ -85,29 +83,20 @@ fn scoped_path_input(path: &str) -> String { } fn workspace_scoped_alias(path: &str) -> Option { - let mut components = Path::new(path).components().peekable(); - while matches!(components.peek(), Some(Component::CurDir)) { - components.next(); + let path = strip_leading_current_dir_segments(path); + if path == "workspace" { + return Some(DEFAULT_SCOPED_ROOT.to_string()); } - match components.next()? { - Component::Normal(segment) if segment == "workspace" => {} - _ => return None, - } + path.strip_prefix("workspace/") + .map(|relative| format!("{DEFAULT_SCOPED_ROOT}/{relative}")) +} - let mut scoped_path = DEFAULT_SCOPED_ROOT.to_string(); - for component in components { - match component { - Component::Normal(segment) => { - scoped_path.push('/'); - scoped_path.push_str(segment.to_str()?); - } - Component::CurDir => {} - Component::ParentDir => scoped_path.push_str("/.."), - Component::RootDir | Component::Prefix(_) => return None, - } +fn strip_leading_current_dir_segments(mut path: &str) -> &str { + while let Some(stripped) = path.strip_prefix("./") { + path = stripped; } - Some(scoped_path) + path } pub(super) fn operation_allowed( From f9b6cca094d0b82c91b10bf99b3bd419716ee828 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Sun, 14 Jun 2026 15:48:50 +0000 Subject: [PATCH 3/3] Normalize empty workspace alias segments --- .../src/coding/mod.rs | 24 ++++++++++++++++++- .../src/coding/paths.rs | 9 ++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ironclaw_first_party_extensions/src/coding/mod.rs b/crates/ironclaw_first_party_extensions/src/coding/mod.rs index 7082f1248d1..2e759acb3ca 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/mod.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/mod.rs @@ -319,7 +319,7 @@ mod tests { super::CodingCapabilityKind::WriteFile, &scope, Some(&mounts), - filesystem, + Arc::clone(&filesystem), &url_like_input, ); let err = state @@ -335,6 +335,28 @@ mod tests { .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 { diff --git a/crates/ironclaw_first_party_extensions/src/coding/paths.rs b/crates/ironclaw_first_party_extensions/src/coding/paths.rs index b936e949def..1ec469a11bb 100644 --- a/crates/ironclaw_first_party_extensions/src/coding/paths.rs +++ b/crates/ironclaw_first_party_extensions/src/coding/paths.rs @@ -89,7 +89,14 @@ fn workspace_scoped_alias(path: &str) -> Option { } path.strip_prefix("workspace/") - .map(|relative| format!("{DEFAULT_SCOPED_ROOT}/{relative}")) + .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 {