From 69e69a53df9fcdb348127c01ab9e23fd8bdc5622 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 29 Jul 2026 12:24:20 +0800 Subject: [PATCH 1/8] fix(sandbox): close metadata permission escape paths --- crates/orca-runtime/src/runtime_bash.rs | 27 +++++- crates/orca-runtime/src/runtime_permission.rs | 35 +++++-- crates/orca-runtime/src/server.rs | 45 ++++++++- .../src/server/command_exec_sandbox.rs | 4 +- .../src/server/surface_adapter.rs | 97 +++++++++++++++++++ crates/orca-tools/src/sandbox/bwrap.rs | 81 +++++++++++++++- crates/orca-tools/src/sandbox/linux.rs | 45 +++++++-- crates/orca-tools/src/sandbox/mod.rs | 14 ++- crates/orca-tools/src/sandbox/seatbelt.rs | 37 ++++++- 9 files changed, 355 insertions(+), 30 deletions(-) diff --git a/crates/orca-runtime/src/runtime_bash.rs b/crates/orca-runtime/src/runtime_bash.rs index 5dea8f99..ec79d68b 100644 --- a/crates/orca-runtime/src/runtime_bash.rs +++ b/crates/orca-runtime/src/runtime_bash.rs @@ -26,6 +26,7 @@ use crate::shell_session::{ ShellTerminalMode, }; use crate::tasks::TaskRegistry; +use crate::thread_store::SessionStore; pub(crate) struct RuntimeBashInvocationContext<'a, 'output> { pub(crate) config: Option<&'a RunConfig>, @@ -87,6 +88,24 @@ pub(crate) fn execute_bash_with_shell_session( Ok(sandbox) => sandbox, Err(error) => return ToolResult::failed(request, error, None), }; + let mut ordinary_additional_roots = Vec::new(); + for root in additional_roots { + // Protected metadata paths only gain write authority through the + // dedicated overlay/session channel below. Their presence in ordinary + // runtime settings must not mint an escalation by path shape alone. + if !orca_tools::sandbox::is_protected_metadata_root(root) { + push_unique_path(&mut ordinary_additional_roots, root.clone()); + } + } + if let Ok(session) = SessionStore::new().load_session(task_registry.session_id()) + && session.meta.session_id == task_registry.session_id() + { + for root in session.meta.metadata_writable_directories { + if orca_tools::sandbox::is_safe_metadata_writable_root(&root) { + push_unique_path(&mut sandbox.metadata_writable_roots, root); + } + } + } for (domain, access) in permission_overlay.network_domain_permissions() { match access { PermissionProfileNetworkAccess::Deny => { @@ -111,7 +130,7 @@ pub(crate) fn execute_bash_with_shell_session( let result = execute_bash_with_sandbox(RuntimeBashSandboxContext { command, cwd, - additional_roots, + additional_roots: &ordinary_additional_roots, sandbox: &sandbox, shell_timeout_secs, task_registry, @@ -148,7 +167,7 @@ pub(crate) fn execute_bash_with_shell_session( return execute_bash_with_sandbox(RuntimeBashSandboxContext { command, cwd, - additional_roots, + additional_roots: &ordinary_additional_roots, sandbox: &retry_sandbox, shell_timeout_secs, task_registry, @@ -191,7 +210,7 @@ pub(crate) fn execute_bash_with_shell_session( return execute_bash_with_sandbox(RuntimeBashSandboxContext { command, cwd, - additional_roots, + additional_roots: &ordinary_additional_roots, sandbox: &retry_sandbox, shell_timeout_secs, task_registry, @@ -223,7 +242,7 @@ pub(crate) fn execute_bash_with_shell_session( command, cwd, additional_readable_directories: Vec::new(), - additional_working_directories: additional_roots.to_vec(), + additional_working_directories: ordinary_additional_roots, metadata_writable_directories: Vec::new(), denied_working_directories: Vec::new(), allowed_unix_socket_roots: Vec::new(), diff --git a/crates/orca-runtime/src/runtime_permission.rs b/crates/orca-runtime/src/runtime_permission.rs index ad4830fb..05a94215 100644 --- a/crates/orca-runtime/src/runtime_permission.rs +++ b/crates/orca-runtime/src/runtime_permission.rs @@ -424,8 +424,10 @@ impl TurnPermissionOverlay { if root.as_os_str().is_empty() { continue; } - if is_exact_metadata_root(root) { - if !self.metadata_writable_directories.contains(root) { + if orca_tools::sandbox::is_protected_metadata_root(root) { + if orca_tools::sandbox::is_safe_metadata_writable_root(root) + && !self.metadata_writable_directories.contains(root) + { self.metadata_writable_directories.push(root.clone()); } } else if !self.additional_working_directories.contains(root) { @@ -437,13 +439,6 @@ impl TurnPermissionOverlay { } } -fn is_exact_metadata_root(path: &std::path::Path) -> bool { - matches!( - path.file_name().and_then(|name| name.to_str()), - Some(".git" | ".agents" | ".codex") - ) -} - #[cfg(test)] mod tests { use std::collections::HashMap; @@ -503,6 +498,28 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn approved_symlink_metadata_root_is_not_made_writable() { + let parent = tempfile::tempdir().unwrap(); + let target = parent.path().join("outside"); + let metadata_link = parent.path().join(".agents"); + std::fs::create_dir(&target).unwrap(); + std::os::unix::fs::symlink(&target, &metadata_link).unwrap(); + let mut overlay = TurnPermissionOverlay::default(); + + overlay.merge_permissions(&RequestPermissionProfile { + file_system: Some(RequestFileSystemPermissions { + write: Some(vec![metadata_link]), + ..Default::default() + }), + ..Default::default() + }); + + assert!(overlay.metadata_writable_directories().is_empty()); + assert!(overlay.additional_working_directories().is_empty()); + } + #[test] fn permission_overlay_delta_carries_only_worker_changes() { let baseline = TurnPermissionOverlay { diff --git a/crates/orca-runtime/src/server.rs b/crates/orca-runtime/src/server.rs index 0e1ace41..11021739 100644 --- a/crates/orca-runtime/src/server.rs +++ b/crates/orca-runtime/src/server.rs @@ -776,7 +776,9 @@ fn materialize_session_permission_grant( for root in roots { for root in materialize_workspace_roots_paths(&thread.cwd, runtime_workspace_roots, root) { if orca_tools::sandbox::is_protected_metadata_root(&root) { - push_unique_path(&mut thread.metadata_writable_directories, root); + if orca_tools::sandbox::is_safe_metadata_writable_root(&root) { + push_unique_path(&mut thread.metadata_writable_directories, root); + } } else if !thread .additional_working_directories .iter() @@ -1305,7 +1307,9 @@ fn run_command_exec( requested, ) { if orca_tools::sandbox::is_protected_metadata_root(&root) { - push_unique_path(&mut metadata_writable_directories, root); + if orca_tools::sandbox::is_safe_metadata_writable_root(&root) { + push_unique_path(&mut metadata_writable_directories, root); + } } else { push_unique_path(&mut additional_working_directories, root); } @@ -3704,6 +3708,43 @@ enabled = true std::fs::read_to_string(session_target).expect("session metadata write"), "persisted" ); + + let agent_target = repo.join(".git").join("agent-next-turn.lock"); + let agent_request = orca_core::tool_types::ToolRequest { + id: "agent-session-metadata".to_string(), + name: orca_core::tool_types::ToolName::Bash, + action: orca_core::approval_types::ActionKind::Write, + target: Some(format!("printf agent > {}", agent_target.display())), + raw_arguments: None, + }; + let task_registry = crate::tasks::TaskRegistry::new(thread_id.clone()); + let mut permission_overlay = + crate::runtime_permission::TurnPermissionOverlay::default(); + let agent_result = crate::runtime_bash::execute_bash_with_shell_session( + crate::runtime_bash::RuntimeBashInvocationContext { + config: Some(&server_config.run_config), + request: &agent_request, + cwd: &repo, + additional_roots: &[], + output_truncation: orca_core::tool_types::ToolOutputTruncation::default(), + shell_timeout_secs: 5, + task_registry: &task_registry, + cancel: None, + permission_handler: None, + permission_overlay: &mut permission_overlay, + output_handler: None, + }, + ); + + assert_eq!( + agent_result.status, + orca_core::tool_types::ToolStatus::Completed, + "a later agent turn should rehydrate the session metadata grant: {agent_result:?}" + ); + assert_eq!( + std::fs::read_to_string(agent_target).expect("agent session metadata write"), + "agent" + ); }); } diff --git a/crates/orca-runtime/src/server/command_exec_sandbox.rs b/crates/orca-runtime/src/server/command_exec_sandbox.rs index 5f6fed15..cdfcfcdb 100644 --- a/crates/orca-runtime/src/server/command_exec_sandbox.rs +++ b/crates/orca-runtime/src/server/command_exec_sandbox.rs @@ -569,7 +569,9 @@ fn push_writable_root( root: PathBuf, ) { if orca_tools::sandbox::is_protected_metadata_root(&root) { - push_unique_path(metadata_writable_roots, root); + if orca_tools::sandbox::is_safe_metadata_writable_root(&root) { + push_unique_path(metadata_writable_roots, root); + } } else { push_unique_path(additional_writable_roots, root); } diff --git a/crates/orca-runtime/src/server/surface_adapter.rs b/crates/orca-runtime/src/server/surface_adapter.rs index 2091382a..40464afa 100644 --- a/crates/orca-runtime/src/server/surface_adapter.rs +++ b/crates/orca-runtime/src/server/surface_adapter.rs @@ -898,6 +898,9 @@ impl JsonlSurfaceAdapter { requested, ) { if orca_tools::sandbox::is_protected_metadata_root(&path) { + if !orca_tools::sandbox::is_safe_metadata_writable_root(&path) { + continue; + } if !metadata_writable_directories.contains(&path) { metadata_writable_directories.push(path); } @@ -3047,6 +3050,100 @@ mod tests { .expect("shutdown stateless permission runtime host"); } + #[test] + fn recorded_session_metadata_grant_stays_in_the_dedicated_authority_channel() { + let _guard = crate::history::lock_test_env(); + let home = tempdir().expect("recorded permission home"); + let previous_home = std::env::var_os(crate::thread_store::ORCA_HOME_ENV); + unsafe { + std::env::set_var(crate::thread_store::ORCA_HOME_ENV, home.path()); + } + let host = RuntimeHost::start().expect("start recorded permission runtime host"); + let surface_host = host.surface_handle().bind_new_connection(); + let mut adapter = JsonlSurfaceAdapter { + host: Some(host), + surface_host, + threads: HashMap::new(), + ephemeral_threads: Arc::new(Mutex::new(HashMap::new())), + transport_turns: Vec::new(), + }; + let cwd = tempdir().expect("recorded permission cwd"); + let metadata = cwd.path().join(".git"); + std::fs::create_dir(&metadata).expect("metadata directory"); + let config = test_run_config(cwd.path().to_path_buf()); + let thread_id = adapter + .start_thread(&config) + .expect("start recorded thread"); + let prepared = adapter + .prepare_turn( + &config, + &thread_id, + "request protected metadata access", + PermissionProfileOverride::default(), + &serde_json::json!("recorded-permission"), + ) + .expect("prepare recorded permission turn"); + let permissions = crate::protocol::RequestPermissionProfile { + file_system: Some(crate::protocol::RequestFileSystemPermissions { + write: Some(vec![metadata.clone()]), + ..Default::default() + }), + ..Default::default() + }; + + adapter + .persist_session_permission_grant( + &thread_id, + &prepared.client, + &[cwd.path().to_path_buf()], + &permissions, + ) + .expect("persist recorded metadata grant"); + + let attachment = match prepared.surface.attach_fresh(FreshAttachRequest { + request_id: SurfaceRequestId::new(), + role: SurfaceAttachmentRole::Jsonl, + requested_capabilities: BTreeSet::from([SurfaceCapability::ReadSnapshot]), + interaction_capabilities: BTreeSet::new(), + }) { + AttachResult::FreshAttached { attachment } => attachment, + _ => panic!("attach recorded metadata settings snapshot"), + }; + let snapshot = attachment.baseline.snapshot; + assert!( + snapshot + .settings + .effective + .additional_working_directories + .iter() + .all(|directory| directory.path.as_path() != metadata) + ); + let stored = crate::thread_store::SessionStore::new() + .load_session(&thread_id) + .expect("load recorded metadata grant"); + assert!( + stored + .meta + .additional_working_directories + .iter() + .all(|directory| directory.path != metadata) + ); + assert_eq!(stored.meta.metadata_writable_directories, vec![metadata]); + + drop(prepared); + adapter + .shutdown() + .expect("shutdown recorded permission runtime host"); + unsafe { + match previous_home { + Some(previous_home) => { + std::env::set_var(crate::thread_store::ORCA_HOME_ENV, previous_home) + } + None => std::env::remove_var(crate::thread_store::ORCA_HOME_ENV), + } + } + } + #[test] fn projection_write_failure_is_returned_after_worker_and_ephemeral_actor_cleanup() { let (entered_tx, entered_rx) = sync_channel(1); diff --git a/crates/orca-tools/src/sandbox/bwrap.rs b/crates/orca-tools/src/sandbox/bwrap.rs index 61b09c09..1b14689c 100644 --- a/crates/orca-tools/src/sandbox/bwrap.rs +++ b/crates/orca-tools/src/sandbox/bwrap.rs @@ -60,6 +60,28 @@ pub struct LinuxSandboxPolicy { pub network_access: bool, } +pub(crate) fn effective_read_only_roots(policy: &LinuxSandboxPolicy) -> Vec { + let mut roots = policy.read_only_roots.clone(); + for writable_root in &policy.writable_roots { + for name in crate::sandbox::PROTECTED_METADATA_DIRS { + let candidate = writable_root.join(name); + if !candidate.exists() { + continue; + } + let canonical = candidate + .canonicalize() + .unwrap_or_else(|_| candidate.clone()); + let explicitly_writable = policy.writable_roots.iter().any(|root| { + root == &candidate && crate::sandbox::is_safe_metadata_writable_root(root) + }); + if !explicitly_writable { + push_unique(&mut roots, canonical); + } + } + } + roots +} + /// Build the full `bwrap` argument vector (excluding the leading `bwrap` /// program name) for the given policy and shell command. /// @@ -118,11 +140,11 @@ pub fn build_bwrap_argv(policy: &LinuxSandboxPolicy, command: &str) -> Vec Command { // No bwrap: try the in-process Landlock + seccomp fallback. // Some nested deny/read-only policies require namespace mounts that - // Landlock cannot express. Only strict restricted-read requests must fail - // closed when no enforcing backend is available; non-strict capability - // modes retain their established compatibility fallback. - let must_fail_closed = request.strict; + // Landlock cannot express. Restricted-read requests and policies that need + // bwrap-specific enforcement must fail closed when no backend is available; + // other non-strict capability modes retain their compatibility fallback. + let must_fail_closed = request.strict || policy_requires_bwrap(&request); match landlock_command(&request) { Ok(command) => command, Err(_) if must_fail_closed => { @@ -86,8 +88,7 @@ fn policy_requires_bwrap(request: &LinuxSandboxRequest) -> bool { if !policy.network_access && !policy.allowed_unix_socket_roots.is_empty() { return true; } - let writable_overlap = policy - .read_only_roots + let writable_overlap = effective_read_only_roots(policy) .iter() .filter(|root| root.exists()) .any(|read_only| { @@ -558,6 +559,36 @@ mod tests { assert!(policy_requires_bwrap(&denied)); } + #[test] + fn nested_read_only_policy_without_backend_fails_closed_when_non_strict() { + if bwrap_path(Path::new(".")).is_some() { + return; + } + let workspace = tempfile::tempdir().unwrap(); + let metadata = workspace.path().join(".git"); + let marker = workspace.path().join("must-not-run"); + std::fs::create_dir(&metadata).unwrap(); + let request = LinuxSandboxRequest { + command: format!("touch {}", marker.display()), + policy: LinuxSandboxPolicy { + cwd: workspace.path().to_path_buf(), + read_scope: LinuxReadScope::Global, + readable_roots: Vec::new(), + allowed_unix_socket_roots: Vec::new(), + writable_roots: vec![workspace.path().to_path_buf()], + read_only_roots: vec![metadata], + denied_roots: Vec::new(), + network_access: true, + }, + strict: false, + }; + + let output = sandbox_command(request).output().unwrap(); + + assert_eq!(output.status.code(), Some(126)); + assert!(!marker.exists()); + } + #[test] fn unix_socket_exceptions_require_bwrap_when_network_is_disabled() { let workspace = tempfile::tempdir().unwrap(); diff --git a/crates/orca-tools/src/sandbox/mod.rs b/crates/orca-tools/src/sandbox/mod.rs index fb154c48..c4605e84 100644 --- a/crates/orca-tools/src/sandbox/mod.rs +++ b/crates/orca-tools/src/sandbox/mod.rs @@ -20,6 +20,16 @@ pub fn is_protected_metadata_root(path: &Path) -> bool { .is_some_and(|name| PROTECTED_METADATA_DIRS.contains(&name)) } +pub fn is_safe_metadata_writable_root(path: &Path) -> bool { + if !is_protected_metadata_root(path) { + return false; + } + match std::fs::symlink_metadata(path) { + Ok(metadata) => !metadata.file_type().is_symlink(), + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + } +} + /// Platform read roots a Linux shell runtime needs when the sandbox root is a /// fresh tmpfs. Exposed here so the pure `bwrap` argv builder (compiled on all /// platforms) can consult the same list the Linux backend uses. @@ -263,7 +273,7 @@ mod platform { &context .metadata_writable_roots .iter() - .filter(|root| is_protected_metadata_root(root)) + .filter(|root| is_safe_metadata_writable_root(root)) .cloned() .collect::>(), ); @@ -411,7 +421,7 @@ mod platform { &context .metadata_writable_roots .iter() - .filter(|root| is_protected_metadata_root(root)) + .filter(|root| is_safe_metadata_writable_root(root)) .cloned() .collect::>(), ); diff --git a/crates/orca-tools/src/sandbox/seatbelt.rs b/crates/orca-tools/src/sandbox/seatbelt.rs index a6a77733..462f9a2d 100644 --- a/crates/orca-tools/src/sandbox/seatbelt.rs +++ b/crates/orca-tools/src/sandbox/seatbelt.rs @@ -129,7 +129,7 @@ pub fn workspace_write_bash_command(context: WorkspaceWriteSandboxCommandContext let canonical_metadata_writable_roots = context .metadata_writable_roots .iter() - .filter(|root| crate::sandbox::is_protected_metadata_root(root)) + .filter(|root| crate::sandbox::is_safe_metadata_writable_root(root)) .map(|root| normalize_path_for_seatbelt(root)) .collect::>(); let canonical_denied_roots = context @@ -176,7 +176,7 @@ pub fn read_only_bash_command(context: ReadOnlySandboxCommandContext<'_>) -> Com let canonical_metadata_writable_roots = context .metadata_writable_roots .iter() - .filter(|root| crate::sandbox::is_protected_metadata_root(root)) + .filter(|root| crate::sandbox::is_safe_metadata_writable_root(root)) .map(|root| normalize_path_for_seatbelt(root)) .collect::>(); let canonical_additional_roots = context @@ -1135,6 +1135,39 @@ mod tests { assert!(!target.exists()); } + #[cfg(unix)] + #[test] + fn workspace_write_sandbox_rejects_explicit_symlinked_metadata_root() { + assert_seatbelt_available(); + + let parent = TempDir::new_in(std::env::current_dir().unwrap()).unwrap(); + let workspace = parent.path().join("workspace"); + let metadata_target = parent.path().join("external-target"); + let metadata_link = workspace.join(".agents"); + std::fs::create_dir(&workspace).unwrap(); + std::fs::create_dir(&metadata_target).unwrap(); + std::os::unix::fs::symlink(&metadata_target, &metadata_link).unwrap(); + let target = metadata_target.join("blocked.txt"); + + let output = workspace_write_bash_command(WorkspaceWriteSandboxCommandContext { + command: &format!("printf blocked > {}", target.display()), + cwd: &workspace, + readable_roots: &[], + additional_roots: &[], + metadata_writable_roots: std::slice::from_ref(&metadata_link), + denied_roots: &[], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + allowed_unix_socket_roots: &[], + }) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(!target.exists()); + } + #[test] fn workspace_write_sandbox_allows_explicit_metadata_write_root() { assert_seatbelt_available(); From 6151bbc0bc14869bbfc5b118419e765bd4746f0c Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 29 Jul 2026 12:56:42 +0800 Subject: [PATCH 2/8] test(tui): stabilize background handoff ordering --- crates/orca-tui/src/app.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/orca-tui/src/app.rs b/crates/orca-tui/src/app.rs index 6c063816..b6558f50 100644 --- a/crates/orca-tui/src/app.rs +++ b/crates/orca-tui/src/app.rs @@ -3386,7 +3386,11 @@ mod tests { }); action_tx - .send(UserAction::Submit("mock_stream_delay_ms 250".to_string())) + // Keep the first provider turn alive long enough for the + // background handoff and the next persisted turn admission on + // slower release-gate hosts. The ordering assertion below is + // still what proves the handoff released foreground ownership. + .send(UserAction::Submit("mock_stream_delay_ms 3000".to_string())) .unwrap(); loop { From 0e339ee7c8f2b3ec64d6fdb3b5bb86d275b158a2 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 29 Jul 2026 13:36:31 +0800 Subject: [PATCH 3/8] test(tools): stabilize timeout exit observation --- crates/orca-tools/src/external.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/orca-tools/src/external.rs b/crates/orca-tools/src/external.rs index c9099e89..a8683011 100644 --- a/crates/orca-tools/src/external.rs +++ b/crates/orca-tools/src/external.rs @@ -350,7 +350,9 @@ mod tests { &request, dir.path(), ToolOutputTruncation::bytes(1024), - Duration::from_millis(200), + // Leave enough startup budget for a loaded serial workspace run to + // install the TERM trap. This test checks exit-code preservation. + Duration::from_secs(3), ); assert_eq!(result.status, ToolStatus::Failed); From b9587e72bc359777857a84387e5aaa445b29b15d Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 29 Jul 2026 13:47:38 +0800 Subject: [PATCH 4/8] test(tui): await permission turn completion --- tests/tui_pty_contract.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/tui_pty_contract.rs b/tests/tui_pty_contract.rs index 1c97f6a4..d1ed35f7 100644 --- a/tests/tui_pty_contract.rs +++ b/tests/tui_pty_contract.rs @@ -87,6 +87,13 @@ fn tui_permission_round_trips_through_the_runtime_surface() { Duration::from_secs(10), "TUI did not resume after the typed permission response", ); + receive_until( + &process, + &mut output, + "Mock completed after tool execution.", + Duration::from_secs(10), + "TUI did not complete after the approved tool execution", + ); arm_idle_exit(&mut process, &mut output); let status = process.wait_for_exit(Duration::from_secs(5)); From efee9c8ee1a8a9704a38f79860838fce376e0bb0 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 29 Jul 2026 13:53:26 +0800 Subject: [PATCH 5/8] test(server): bound hook cancellation timing --- tests/session_server_contract.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/session_server_contract.rs b/tests/session_server_contract.rs index 307a85b8..ad471fa7 100644 --- a/tests/session_server_contract.rs +++ b/tests/session_server_contract.rs @@ -1979,9 +1979,10 @@ fn server_mode_interrupt_cancels_active_pre_model_hook_wait() { let interrupt = child.expect_event("interrupt-hook", "turn_controlled"); assert_eq!(interrupt["status"], "interrupted"); let completed = child.expect_event("turn-hook", "turn_completed"); + let interrupt_elapsed = interrupt_sent_at.elapsed(); assert!( - interrupt_sent_at.elapsed() < Duration::from_millis(1200), - "turn completion waited for the full pre_model hook sleep" + interrupt_elapsed < Duration::from_secs(3), + "turn completion waited too long for pre_model hook cancellation: {interrupt_elapsed:?}" ); assert_eq!(completed["status"], "cancelled"); From fc502d081d3a862b1f2504e418e233123e81491a Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Thu, 30 Jul 2026 16:18:03 +0800 Subject: [PATCH 6/8] fix(sandbox): commit session filesystem grants atomically and isolate metadata authority Continue closing the metadata permission escape paths surfaced by the Seatbelt review. Session-scoped filesystem grants are now resolved and committed by the runtime in the same durable batch as the permission decision, so a failed append can neither mutate settings nor wake the waiting tool, and a later agent turn restores the exact dedicated metadata grant without reloading session history from the Bash execution path. Sandbox hardening: - Keep dedicated metadata write authority (`session-metadata` source) provenance-separate from ordinary writable roots so an ordinary grant can never be mistaken for metadata authority. - Linux/bubblewrap discovers existing nested protected metadata (`.git`, `.agents`, `.codex`) before launch and re-binds it read-only, refuses to let symlinked metadata roots widen an explicit grant, and fails closed when an overlapping policy requires bubblewrap but it is unavailable. - macOS/Seatbelt keeps workspace metadata read-only under writable roots by default. Also refresh the v0.3.0 release notes, changelog, and sitemap to document the new session-grant durability and metadata isolation behavior. Co-authored-by: TRAE CLI --- crates/orca-provider/src/lib.rs | 42 +- crates/orca-runtime/src/runtime_bash.rs | 21 +- crates/orca-runtime/src/runtime_host.rs | 402 ++++++++++++++---- crates/orca-runtime/src/runtime_permission.rs | 2 + .../src/runtime_surface/commands.rs | 1 + .../src/runtime_surface/commit.rs | 134 ++++++ .../src/runtime_surface/operation.rs | 5 + .../src/runtime_surface/reducer.rs | 1 + crates/orca-runtime/src/server.rs | 14 +- .../src/server/processors/permission.rs | 17 - .../src/server/surface_adapter.rs | 328 +------------- crates/orca-runtime/src/tool_router.rs | 4 + .../tests/runtime_surface_attach.rs | 1 + .../tests/runtime_surface_commit.rs | 1 + .../tests/runtime_surface_interaction.rs | 67 +-- .../tests/runtime_surface_reducer.rs | 1 + crates/orca-tools/src/sandbox/bwrap.rs | 127 +++++- crates/orca-tools/src/sandbox/linux.rs | 30 +- crates/orca-tools/src/sandbox/mod.rs | 44 +- docs/releases/v0.3.0.md | 16 +- site/public/sitemap.xml | 12 +- site/src/changelog/Changelog.tsx | 4 +- site/src/shared.ts | 2 +- tests/server_runtime_contract.rs | 34 +- tests/session_server_contract.rs | 107 +++++ 25 files changed, 909 insertions(+), 508 deletions(-) diff --git a/crates/orca-provider/src/lib.rs b/crates/orca-provider/src/lib.rs index 41fbd57b..892be6bd 100644 --- a/crates/orca-provider/src/lib.rs +++ b/crates/orca-provider/src/lib.rs @@ -734,9 +734,21 @@ fn mock_call(conversation: &Conversation) -> ProviderResponse { }; } - if let Some(command) = prompt.trim().strip_prefix("force_bash ") { + if !has_tool_results && let Some(command) = prompt.trim().strip_prefix("force_bash ") { + let tool_id = (1_u64..) + .map(|index| format!("mock-tool-{index}")) + .find(|candidate| { + !conversation.messages.iter().any(|message| match message { + Message::Assistant { tool_calls, .. } => tool_calls + .iter() + .any(|tool_call| tool_call.id == *candidate), + Message::Tool { tool_call_id, .. } => tool_call_id == candidate, + Message::System { .. } | Message::User { .. } => false, + }) + }) + .expect("mock tool id space is not exhausted"); let bash = ToolRequest { - id: "mock-tool-1".to_string(), + id: tool_id, name: ToolName::Bash, action: ActionKind::Shell, target: Some(command.to_string()), @@ -1684,6 +1696,32 @@ mod tests { )); } + #[test] + fn mock_force_bash_uses_fresh_tool_ids_across_turns_and_stops_after_result() { + let mut conversation = Conversation::new(); + conversation.add_user("force_bash printf first".to_string()); + let first = mock_call(&conversation); + let first_call = first.tool_calls.first().unwrap().clone(); + assert_eq!(first_call.id, "mock-tool-1"); + conversation.add_assistant(None, None, vec![first_call.clone()]); + conversation.add_tool_result(first_call.id, "first".to_string()); + conversation.add_assistant(Some("first done".to_string()), None, Vec::new()); + conversation.add_user("force_bash printf second".to_string()); + + let second = mock_call(&conversation); + let second_call = second.tool_calls.first().unwrap().clone(); + assert_eq!(second_call.id, "mock-tool-2"); + conversation.add_assistant(None, None, vec![second_call.clone()]); + conversation.add_tool_result(second_call.id, "second".to_string()); + + let completed = mock_call(&conversation); + assert!(completed.tool_calls.is_empty()); + assert_eq!( + completed.assistant_content.as_deref(), + Some("Mock completed after tool execution.") + ); + } + #[test] fn mock_provider_can_request_network_permissions() { let mut conversation = Conversation::new(); diff --git a/crates/orca-runtime/src/runtime_bash.rs b/crates/orca-runtime/src/runtime_bash.rs index ec79d68b..fcdb3130 100644 --- a/crates/orca-runtime/src/runtime_bash.rs +++ b/crates/orca-runtime/src/runtime_bash.rs @@ -26,7 +26,6 @@ use crate::shell_session::{ ShellTerminalMode, }; use crate::tasks::TaskRegistry; -use crate::thread_store::SessionStore; pub(crate) struct RuntimeBashInvocationContext<'a, 'output> { pub(crate) config: Option<&'a RunConfig>, @@ -89,6 +88,17 @@ pub(crate) fn execute_bash_with_shell_session( Err(error) => return ToolResult::failed(request, error, None), }; let mut ordinary_additional_roots = Vec::new(); + for directory in &config.additional_working_directories { + if directory.source == crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE { + if orca_tools::sandbox::is_safe_metadata_writable_root(&directory.path) { + push_unique_path(&mut sandbox.metadata_writable_roots, directory.path.clone()); + } + continue; + } + if !orca_tools::sandbox::is_protected_metadata_root(&directory.path) { + push_unique_path(&mut ordinary_additional_roots, directory.path.clone()); + } + } for root in additional_roots { // Protected metadata paths only gain write authority through the // dedicated overlay/session channel below. Their presence in ordinary @@ -97,15 +107,6 @@ pub(crate) fn execute_bash_with_shell_session( push_unique_path(&mut ordinary_additional_roots, root.clone()); } } - if let Ok(session) = SessionStore::new().load_session(task_registry.session_id()) - && session.meta.session_id == task_registry.session_id() - { - for root in session.meta.metadata_writable_directories { - if orca_tools::sandbox::is_safe_metadata_writable_root(&root) { - push_unique_path(&mut sandbox.metadata_writable_roots, root); - } - } - } for (domain, access) in permission_overlay.network_domain_permissions() { match access { PermissionProfileNetworkAccess::Deny => { diff --git a/crates/orca-runtime/src/runtime_host.rs b/crates/orca-runtime/src/runtime_host.rs index 4eccdfe0..68b09c21 100644 --- a/crates/orca-runtime/src/runtime_host.rs +++ b/crates/orca-runtime/src/runtime_host.rs @@ -2045,7 +2045,10 @@ pub struct RuntimeThreadStartRequest { } impl RuntimeThreadStartRequest { - pub fn new(config: RunConfig, title: impl Into) -> Self { + pub fn new(mut config: RunConfig, title: impl Into) -> Self { + config.additional_working_directories.retain(|directory| { + directory.source != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }); Self { config, title: title.into(), @@ -7741,6 +7744,9 @@ fn initial_surface_snapshot( let additional_working_directories = config .additional_working_directories .iter() + .filter(|directory| { + directory.source != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }) .map(|directory| { Ok(surface::SurfaceAdditionalWorkingDirectory { path: surface::CanonicalPath::try_new(directory.path.clone()).map_err(|error| { @@ -7758,6 +7764,20 @@ fn initial_surface_snapshot( }) }) .collect::, RuntimeHostError>>()?; + let metadata_writable_directories = config + .additional_working_directories + .iter() + .filter(|directory| { + directory.source == crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }) + .map(|directory| { + surface::CanonicalPath::try_new(directory.path.clone()).map_err(|error| { + RuntimeHostError::ThreadStartFailed { + message: format!("invalid metadata writable directory: {error:?}"), + } + }) + }) + .collect::, RuntimeHostError>>()?; let now = surface::UnixMillis::new( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -7847,6 +7867,7 @@ fn initial_surface_snapshot( digest: permission_rules_digest, }, additional_working_directories, + metadata_writable_directories, network_permissions: surface::SurfaceNetworkPermissions { enabled: None, domains: Vec::new(), @@ -8041,15 +8062,34 @@ fn apply_runtime_settings_patch( }; } surface::RuntimeSettingsPatch::ReplaceAdditionalWorkingDirectories { directories } => { + config.additional_working_directories.retain(|directory| { + directory.source == crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }); config.additional_working_directories = directories .iter() .map(|directory| orca_core::config::AdditionalWorkingDirectory { path: directory.path.as_path().to_path_buf(), source: directory.source.as_str().to_string(), }) + .chain(config.additional_working_directories.clone()) .collect(); settings.additional_working_directories = directories.clone(); } + surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { directories } => { + config.additional_working_directories.retain(|directory| { + directory.source != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }); + config + .additional_working_directories + .extend(directories.iter().map(|directory| { + orca_core::config::AdditionalWorkingDirectory { + path: directory.as_path().to_path_buf(), + source: crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + .to_string(), + } + })); + settings.metadata_writable_directories = directories.clone(); + } surface::RuntimeSettingsPatch::ReplaceNetworkPermissions { permissions } => { settings.network_permissions = permissions.clone(); } @@ -8069,6 +8109,7 @@ fn runtime_settings_patch_affects_policy(patch: &surface::RuntimeSettingsPatch) | surface::RuntimeSettingsPatch::SetActivePermissionProfile { .. } | surface::RuntimeSettingsPatch::ReplacePermissionRules { .. } | surface::RuntimeSettingsPatch::ReplaceAdditionalWorkingDirectories { .. } + | surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { .. } | surface::RuntimeSettingsPatch::ReplaceNetworkPermissions { .. } | surface::RuntimeSettingsPatch::ApplyPermissionUpdate { .. } ) @@ -8135,6 +8176,13 @@ fn hydrate_run_config_from_surface_settings( directories: settings.additional_working_directories.clone(), }, )?; + apply_runtime_settings_patch( + config, + &mut restored, + &surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { + directories: settings.metadata_writable_directories.clone(), + }, + )?; Ok(()) } @@ -8221,7 +8269,13 @@ fn persist_surface_settings_metadata( }) .collect(), ), - metadata_writable_directories: None, + metadata_writable_directories: Some( + settings + .metadata_writable_directories + .iter() + .map(|directory| directory.as_path().to_path_buf()) + .collect(), + ), network_domain_permissions: Some(network_domain_permissions), }, ) @@ -10210,56 +10264,121 @@ fn surface_permission_profile_is_subset( file_system_subset && network_subset && shell_subset } -fn surface_session_permission_grant_is_applied( - settings: &surface::SurfaceRuntimeSettings, +fn prepare_surface_session_permission_grant( + config: &RunConfig, + current: &surface::SurfaceSettingsSnapshot, permissions: &surface::SurfacePermissionProfile, -) -> bool { - let paths_applied = permissions.file_system.as_ref().is_none_or(|file_system| { - file_system - .read - .iter() - .flatten() - .chain(file_system.write.iter().flatten()) - .all(|path| { - surface::CanonicalPath::try_new(std::path::PathBuf::from(path.0.as_str())) - .ok() - .is_some_and(|path| { - settings - .additional_working_directories - .iter() - .any(|directory| directory.path == path) - }) - }) - }); - let network_applied = permissions.network.as_ref().is_none_or(|requested| { - let enabled = requested - .enabled - .is_none_or(|enabled| settings.network_permissions.enabled == Some(enabled)); - enabled - && requested.domains.iter().all(|(domain, access)| { - settings - .network_permissions - .domains - .iter() - .any(|permission| { - permission.domain.as_str() == domain.0.as_str() - && permission.access - == match access { - surface::SurfaceAllowDeny::Allow => { - surface::SurfaceNetworkDomainAccess::Allow - } - surface::SurfaceAllowDeny::Deny => { - surface::SurfaceNetworkDomainAccess::Deny - } - } - }) - }) - }); - let shell_applied = permissions +) -> Result<(surface::SurfaceSettingsSnapshot, RunConfig), &'static str> { + if permissions .shell .as_ref() - .is_none_or(|shell| !shell.unsandboxed); - paths_applied && network_applied && shell_applied + .is_some_and(|shell| shell.unsandboxed) + { + return Err("unsandboxed shell grants cannot persist for a session"); + } + if permissions + .file_system + .as_ref() + .and_then(|file_system| file_system.read.as_ref()) + .is_some_and(|paths| !paths.is_empty()) + { + return Err("session read grants are not supported"); + } + + let mut directories = current.effective.additional_working_directories.clone(); + let mut metadata_directories = current.effective.metadata_writable_directories.clone(); + if let Some(file_system) = permissions.file_system.as_ref() { + for requested in file_system.write.iter().flatten() { + let path = std::path::PathBuf::from(requested.0.as_str()); + if path.as_os_str().is_empty() { + continue; + } + let canonical = surface::CanonicalPath::try_new(path.clone()) + .map_err(|_| "session permission path is not canonical")?; + if orca_tools::sandbox::is_protected_metadata_root(&path) { + if !orca_tools::sandbox::is_safe_metadata_writable_root(&path) { + return Err( + "session metadata grant must name a non-symlink metadata directory", + ); + } + if !metadata_directories.contains(&canonical) { + metadata_directories.push(canonical); + } + } else if !directories + .iter() + .any(|directory| directory.path == canonical) + { + directories.push(surface::SurfaceAdditionalWorkingDirectory { + path: canonical, + source: surface::NonEmptyText::try_new("session") + .expect("session permission source is non-empty"), + }); + } + } + } + + let mut network = current.effective.network_permissions.clone(); + if let Some(requested) = permissions.network.as_ref() { + if requested.enabled.is_some() { + network.enabled = requested.enabled; + } + for (domain, access) in &requested.domains { + let domain = surface::CanonicalDomainName::try_new(domain.0.as_str().to_string()) + .map_err(|_| "session network domain is invalid")?; + let access = match access { + surface::SurfaceAllowDeny::Allow => surface::SurfaceNetworkDomainAccess::Allow, + surface::SurfaceAllowDeny::Deny => surface::SurfaceNetworkDomainAccess::Deny, + }; + if let Some(existing) = network + .domains + .iter_mut() + .find(|permission| permission.domain == domain) + { + existing.access = access; + } else { + network + .domains + .push(surface::SurfaceNetworkDomainPermission { domain, access }); + } + } + network + .domains + .sort_by(|left, right| left.domain.as_str().cmp(right.domain.as_str())); + } + + let mut next = current.clone(); + let mut next_config = config.clone(); + for patch in [ + surface::RuntimeSettingsPatch::ReplaceAdditionalWorkingDirectories { directories }, + surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { + directories: metadata_directories, + }, + surface::RuntimeSettingsPatch::ReplaceNetworkPermissions { + permissions: network, + }, + ] { + apply_runtime_settings_patch(&mut next_config, &mut next.effective, &patch) + .map_err(|_| "session permission settings could not be applied")?; + } + next.thread_revision = surface::SettingsRevision::try_new( + current + .thread_revision + .get() + .checked_add(1) + .ok_or("session settings revision exhausted")?, + ) + .map_err(|_| "session settings revision is invalid")?; + next.effective.policy_epoch = surface::PolicyEpoch::try_new( + current + .effective + .policy_epoch + .get() + .checked_add(1) + .ok_or("session policy epoch exhausted")?, + ) + .map_err(|_| "session policy epoch is invalid")?; + next.pending = None; + Ok((next, next_config)) } fn surface_session_permission_settings_delta_authorized( @@ -10284,6 +10403,13 @@ fn surface_session_permission_settings_delta_authorized( { return false; } + if current + .metadata_writable_directories + .iter() + .any(|directory| !next.metadata_writable_directories.contains(directory)) + { + return false; + } let requested_paths = requested .file_system .iter() @@ -10308,6 +10434,14 @@ fn surface_session_permission_settings_delta_authorized( { return false; } + if next + .metadata_writable_directories + .iter() + .filter(|directory| !current.metadata_writable_directories.contains(directory)) + .any(|directory| !requested_paths.contains(directory)) + { + return false; + } let requested_network = requested.network.as_ref(); if current.network_permissions.enabled != next.network_permissions.enabled && requested_network.and_then(|network| network.enabled) != next.network_permissions.enabled @@ -14565,7 +14699,11 @@ impl ThreadActor { batch: &surface::SurfaceCommitBatch, value: T, ) -> surface::MutationReply { - let event = &batch.events.as_slice()[0]; + let event = batch + .events + .as_slice() + .last() + .expect("interaction commit has an event"); surface::MutationReply::Committed { mutation: surface::CommittedMutation { request_id, @@ -18583,7 +18721,7 @@ impl ThreadActor { "interaction response authority does not match the persisted request", )); } - if let ( + let attempted_session_permission_grant = if let ( surface::SurfaceInteractionRequest::PermissionRequest { permissions: requested, .. @@ -18594,27 +18732,9 @@ impl ThreadActor { scope, permissions, .. }, }, - ) = (&interaction.record.request, response.answer()) + ) = + (&interaction.record.request, response.answer()) { - if *scope == surface::PermissionGrantScope::Session - && !surface_session_permission_grant_is_applied( - &self - .resident_surface - .coordinator - .state() - .snapshot() - .settings - .effective, - permissions, - ) - { - return Ok(Self::uncommitted_interaction_response( - request_id, - interaction, - surface::SurfaceMutationErrorCode::InvalidInput, - "session permission grants require runtime settings ownership", - )); - } if !surface_permission_profile_is_subset(permissions, requested) { return Ok(Self::uncommitted_interaction_response( request_id, @@ -18623,7 +18743,29 @@ impl ThreadActor { "permission response exceeds the persisted requested profile", )); } - } + if *scope == surface::PermissionGrantScope::Session { + let snapshot = self.resident_surface.coordinator.state().snapshot(); + match prepare_surface_session_permission_grant( + &self.config, + &snapshot.settings, + permissions, + ) { + Ok(grant) => Some(grant), + Err(message) => { + return Ok(Self::uncommitted_interaction_response( + request_id, + interaction, + surface::SurfaceMutationErrorCode::InvalidInput, + message, + )); + } + } + } else { + None + } + } else { + None + }; if !self .resident_surface .hub @@ -18700,6 +18842,7 @@ impl ThreadActor { &interaction.record.response_token, response.answer(), ); + let interaction_request = interaction.record.request.clone(); let (receipt, winner_answer, attempted_private_winner) = match interaction.private_response.as_ref() { Some(winner) => ( @@ -18745,6 +18888,32 @@ impl ThreadActor { (receipt, response.answer().clone(), true) } }; + let session_permission_grant = if attempted_private_winner { + attempted_session_permission_grant + } else if let ( + surface::SurfaceInteractionRequest::PermissionRequest { .. }, + surface::SurfaceClientInteractionAnswer::PermissionRequest { + decision: + surface::SurfacePermissionClientDecision::Allow { + scope: surface::PermissionGrantScope::Session, + permissions, + .. + }, + }, + ) = (&interaction_request, &winner_answer) + { + let snapshot = self.resident_surface.coordinator.state().snapshot(); + Some( + prepare_surface_session_permission_grant( + &self.config, + &snapshot.settings, + permissions, + ) + .map_err(|_| surface::SurfaceClientCommandError::RuntimeUnavailable)?, + ) + } else { + None + }; let batch = if let Some(batch) = self .resident_surface .interactions @@ -18754,26 +18923,40 @@ impl ThreadActor { { batch } else { - let scope = background_fence - .as_ref() - .map(|fence| surface::SurfaceScope::Background { + let interaction_scope = background_fence.as_ref().map_or_else( + || surface::SurfaceScope::Generation { fence: fence.clone(), - }) - .unwrap_or_else(|| surface::SurfaceScope::Generation { + }, + |fence| surface::SurfaceScope::Background { fence: fence.clone(), - }); - let batch = self.surface_event_batch_with_commit_id( - vec![( - scope, - surface::SurfaceEvent::Interaction(surface::InteractionPatch::Resolved { - interaction_id: interaction_id.clone(), - expected_revision, - next_revision, - receipt: receipt.clone(), - }), - )], - None, + }, ); + let mut events = Vec::new(); + if let Some((next_settings, _)) = session_permission_grant.as_ref() { + events.push(( + surface::SurfaceScope::Thread, + surface::SurfaceEvent::Settings(surface::SettingsPatch::Committed { + previous_revision: self + .resident_surface + .coordinator + .state() + .snapshot() + .settings + .thread_revision, + snapshot: next_settings.clone(), + }), + )); + } + events.push(( + interaction_scope, + surface::SurfaceEvent::Interaction(surface::InteractionPatch::Resolved { + interaction_id: interaction_id.clone(), + expected_revision, + next_revision, + receipt: receipt.clone(), + }), + )); + let batch = self.surface_event_batch_with_commit_id(events, None); self.resident_surface .interactions .get_mut(&interaction_id) @@ -18793,6 +18976,10 @@ impl ThreadActor { &batch, ) } + None if session_permission_grant.is_some() => self + .resident_surface + .coordinator + .commit_actor_generation_permission_resolution_batch(fence, &batch), None => self .resident_surface .coordinator @@ -18809,6 +18996,14 @@ impl ThreadActor { Some(tokio::time::Instant::now() + SURFACE_CAPABILITY_LOSS_RETRY_INTERVAL); return Err(surface::SurfaceClientCommandError::RuntimeUnavailable); } + if let Some((next_settings, next_config)) = session_permission_grant { + self.config = next_config; + if let Err(error) = + self.persist_surface_settings_metadata_if_recorded(&next_settings.effective) + { + eprintln!("orca: committed session permission metadata projection failed: {error}"); + } + } self.apply_surface_interaction_resolution(&interaction_id, &winner_answer); let output = surface::RespondInteractionOutput { interaction_id: interaction_id.clone(), @@ -18848,7 +19043,11 @@ impl ThreadActor { let batch = private .pending_batch .expect("committed interaction retains its exact public batch"); - let envelope = &batch.events.as_slice()[0]; + let envelope = batch + .events + .as_slice() + .last() + .expect("interaction resolution batch has an event"); interaction.revision = surface::InteractionRevision::try_new(interaction.revision.get().saturating_add(1)) .expect("interaction revision did not exhaust"); @@ -23435,6 +23634,14 @@ impl ThreadActor { if !self.admits_surface_client(client, surface::SurfaceCapability::ManageThreadSettings) { return Err(surface::SurfaceClientCommandError::Unauthorized); } + if patches.as_slice().iter().any(|patch| { + matches!( + patch, + surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { .. } + ) + }) { + return Err(surface::SurfaceClientCommandError::Unauthorized); + } if self.pending_manual_compaction_completion.is_some() { return Err(surface::SurfaceClientCommandError::RuntimeUnavailable); } @@ -41516,6 +41723,21 @@ mod tests { let surface = thread.surface(); let attachment = fresh_surface_attachment(&surface); let previous_revision = attachment.baseline.snapshot.settings.thread_revision; + let metadata = cwd.path().join(".git"); + std::fs::create_dir(&metadata).expect("create protected metadata directory"); + assert!(matches!( + attachment.client.update_settings( + surface_request_id(), + previous_revision, + surface::NonEmptyVec::try_new(vec![ + surface::RuntimeSettingsPatch::ReplaceMetadataWritableDirectories { + directories: vec![surface::CanonicalPath::try_new(metadata).unwrap()], + }, + ]) + .unwrap(), + ), + Err(surface::SurfaceClientCommandError::Unauthorized) + )); let updated = committed_surface_value( attachment .client diff --git a/crates/orca-runtime/src/runtime_permission.rs b/crates/orca-runtime/src/runtime_permission.rs index 05a94215..c908a795 100644 --- a/crates/orca-runtime/src/runtime_permission.rs +++ b/crates/orca-runtime/src/runtime_permission.rs @@ -10,6 +10,8 @@ use crate::protocol::{ PermissionGrantScope, PermissionResponseDecision, RequestFileSystemPermissions, RequestNetworkPermissions, RequestPermissionProfile, RequestShellPermissions, }; + +pub(crate) const SESSION_METADATA_DIRECTORY_SOURCE: &str = "session-metadata"; use crate::sandbox_denial::{ SandboxDenialDiagnostic, should_request_filesystem_permission_with_denied_roots, }; diff --git a/crates/orca-runtime/src/runtime_surface/commands.rs b/crates/orca-runtime/src/runtime_surface/commands.rs index 6fce8a51..2ee2f8c3 100644 --- a/crates/orca-runtime/src/runtime_surface/commands.rs +++ b/crates/orca-runtime/src/runtime_surface/commands.rs @@ -4328,6 +4328,7 @@ mod closed_command_domain_tests { digest: digest(72), }, additional_working_directories: Vec::new(), + metadata_writable_directories: Vec::new(), network_permissions: SurfaceNetworkPermissions { enabled: Some(true), domains: Vec::new(), diff --git a/crates/orca-runtime/src/runtime_surface/commit.rs b/crates/orca-runtime/src/runtime_surface/commit.rs index ef0abba3..b98d5376 100644 --- a/crates/orca-runtime/src/runtime_surface/commit.rs +++ b/crates/orca-runtime/src/runtime_surface/commit.rs @@ -635,6 +635,10 @@ enum BatchCommitAuthority<'permit> { actor: &'permit SurfacePublisherPermit, generation: &'permit SurfacePublisherPermit, }, + ActorGenerationPermissionResolution { + actor: &'permit SurfacePublisherPermit, + generation: &'permit SurfacePublisherPermit, + }, ActorFinalizerTaskTerminal { actor: &'permit SurfacePublisherPermit, finalizer: &'permit SurfacePublisherPermit, @@ -709,6 +713,10 @@ enum RecoveredBatchAuthority { actor: SurfacePublisherPermit, generation: SurfacePublisherPermit, }, + ActorGenerationPermissionResolution { + actor: SurfacePublisherPermit, + generation: SurfacePublisherPermit, + }, ActorFinalizerTaskTerminal { actor: SurfacePublisherPermit, finalizer: SurfacePublisherPermit, @@ -1031,6 +1039,19 @@ impl<'owner> RuntimeCommitCoordinator<'owner, JsonlSurfaceCommitLedger> { None, )?; } + RecoveredBatchAuthority::ActorGenerationPermissionResolution { + actor, + generation, + } => { + coordinator.commit_batch_with_authority( + BatchCommitAuthority::ActorGenerationPermissionResolution { + actor: &actor, + generation: &generation, + }, + &batch, + None, + )?; + } RecoveredBatchAuthority::ActorFinalizerTaskTerminal { actor, finalizer } => { coordinator.commit_batch_with_authority( BatchCommitAuthority::ActorFinalizerTaskTerminal { @@ -1236,6 +1257,16 @@ impl<'owner, L: SurfaceCommitLedger> RuntimeCommitCoordinator<'owner, L> { &batch, None, )?, + RecoveredBatchAuthority::ActorGenerationPermissionResolution { actor, generation } => { + self.commit_batch_with_authority( + BatchCommitAuthority::ActorGenerationPermissionResolution { + actor: &actor, + generation: &generation, + }, + &batch, + None, + )? + } RecoveredBatchAuthority::ActorFinalizerTaskTerminal { actor, finalizer } => self .commit_batch_with_authority( BatchCommitAuthority::ActorFinalizerTaskTerminal { @@ -1776,6 +1807,26 @@ impl<'owner, L: SurfaceCommitLedger> RuntimeCommitCoordinator<'owner, L> { ) } + pub(crate) fn commit_actor_generation_permission_resolution_batch( + &mut self, + fence: super::SurfaceOperationFence, + batch: &SurfaceCommitBatch, + ) -> Result { + let actor = self.actor_control_permit.clone(); + let generation = self.register_permit(SurfacePublisherPermit::Generation { + permit_id: next_permit_id(), + fence, + }); + self.commit_batch_with_authority( + BatchCommitAuthority::ActorGenerationPermissionResolution { + actor: &actor, + generation: &generation, + }, + batch, + None, + ) + } + pub fn commit_finalizer_batch( &mut self, operation_id: super::SurfaceOperationId, @@ -3501,6 +3552,32 @@ impl<'owner, L: SurfaceCommitLedger> RuntimeCommitCoordinator<'owner, L> { }); } } + if let Some(SurfaceScope::Generation { + fence: historical_fence, + }) = events.last().map(|event| &event.scope) + { + let generation = SurfacePublisherPermit::Generation { + permit_id: next_permit_id(), + fence: historical_fence.clone(), + }; + let mut issued = self.issued_permits.clone(); + issued.push(generation.clone()); + if actor_generation_permission_resolution_authorized( + &issued, + &actor, + &generation, + batch, + self.owner_epoch, + ) { + let generation = self.register_permit(generation); + return Ok( + RecoveredBatchAuthority::ActorGenerationPermissionResolution { + actor, + generation, + }, + ); + } + } if let Some((historical_fence, operation_id)) = events .first() @@ -4373,6 +4450,15 @@ impl<'owner, L: SurfaceCommitLedger> RuntimeCommitCoordinator<'owner, L> { self.owner_epoch, ) } + BatchCommitAuthority::ActorGenerationPermissionResolution { actor, generation } => { + actor_generation_permission_resolution_authorized( + &self.issued_permits, + actor, + generation, + batch, + self.owner_epoch, + ) + } BatchCommitAuthority::ActorFinalizerTaskTerminal { actor, finalizer } => { actor_finalizer_task_terminal_authorized( &self.state, @@ -5101,6 +5187,54 @@ fn actor_goal_edit_run_authorized( ) } +fn actor_generation_permission_resolution_authorized( + issued_permits: &[SurfacePublisherPermit], + actor_permit: &SurfacePublisherPermit, + generation_permit: &SurfacePublisherPermit, + batch: &SurfaceCommitBatch, + owner_epoch: ThreadOwnerEpoch, +) -> bool { + if !issued_permits.contains(actor_permit) || !issued_permits.contains(generation_permit) { + return false; + } + let ( + SurfacePublisherPermit::ActorControl { + thread_id, + owner_epoch: actor_owner_epoch, + .. + }, + SurfacePublisherPermit::Generation { fence, .. }, + ) = (actor_permit, generation_permit) + else { + return false; + }; + if *actor_owner_epoch != owner_epoch + || thread_id != &fence.thread_id + || thread_id != &batch.cursor_before.thread_id + || thread_id != &batch.cursor_after.thread_id + { + return false; + } + let [settings, interaction] = batch.events.as_slice() else { + return false; + }; + let settings_valid = matches!( + (&settings.scope, &settings.event), + ( + SurfaceScope::Thread, + super::SurfaceEvent::Settings(super::SettingsPatch::Committed { .. }) + ) + ); + let interaction_valid = matches!( + (&interaction.scope, &interaction.event), + ( + SurfaceScope::Generation { fence: scope }, + super::SurfaceEvent::Interaction(super::InteractionPatch::Resolved { .. }) + ) if scope == fence + ); + settings_valid && interaction_valid +} + fn actor_generation_terminalization_authorized( issued_permits: &[SurfacePublisherPermit], actor_permit: &SurfacePublisherPermit, diff --git a/crates/orca-runtime/src/runtime_surface/operation.rs b/crates/orca-runtime/src/runtime_surface/operation.rs index 03e12767..61e0af2e 100644 --- a/crates/orca-runtime/src/runtime_surface/operation.rs +++ b/crates/orca-runtime/src/runtime_surface/operation.rs @@ -998,6 +998,8 @@ pub struct SurfaceRuntimeSettings { pub active_permission_profile: Option, pub permission_rules: SurfacePermissionRuleSet, pub additional_working_directories: Vec, + #[serde(default)] + pub metadata_writable_directories: Vec, pub network_permissions: SurfaceNetworkPermissions, pub policy_epoch: PolicyEpoch, } @@ -1073,6 +1075,9 @@ pub enum RuntimeSettingsPatch { ReplaceAdditionalWorkingDirectories { directories: Vec, }, + ReplaceMetadataWritableDirectories { + directories: Vec, + }, ReplaceNetworkPermissions { permissions: SurfaceNetworkPermissions, }, diff --git a/crates/orca-runtime/src/runtime_surface/reducer.rs b/crates/orca-runtime/src/runtime_surface/reducer.rs index e549f4ec..9f6f6882 100644 --- a/crates/orca-runtime/src/runtime_surface/reducer.rs +++ b/crates/orca-runtime/src/runtime_surface/reducer.rs @@ -8188,6 +8188,7 @@ pub(crate) mod tests { digest: digest(1), }, additional_working_directories: Vec::new(), + metadata_writable_directories: Vec::new(), network_permissions: SurfaceNetworkPermissions { enabled: Some(true), domains: Vec::new(), diff --git a/crates/orca-runtime/src/server.rs b/crates/orca-runtime/src/server.rs index 11021739..0b9e3a38 100644 --- a/crates/orca-runtime/src/server.rs +++ b/crates/orca-runtime/src/server.rs @@ -261,12 +261,21 @@ fn apply_permission_updates(config: &mut RunConfig, updates: Vec { for directory in directories { + if directory.source + == crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + { + continue; + } if let Some(existing) = config .additional_working_directories .iter_mut() .find(|existing| existing.path == directory.path) { - existing.source = directory.source; + if existing.source + != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + { + existing.source = directory.source; + } } else { config.additional_working_directories.push(directory); } @@ -276,7 +285,8 @@ fn apply_permission_updates(config: &mut RunConfig, updates: Vec config.additional_working_directories.retain(|directory| { - directory.source != destination + directory.source == crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + || directory.source != destination || !directories.iter().any(|remove| remove == &directory.path) }), } diff --git a/crates/orca-runtime/src/server/processors/permission.rs b/crates/orca-runtime/src/server/processors/permission.rs index dd9321c8..f3ba8620 100644 --- a/crates/orca-runtime/src/server/processors/permission.rs +++ b/crates/orca-runtime/src/server/processors/permission.rs @@ -123,23 +123,6 @@ fn run_permission_respond( permissions, )?; let allow = decision == protocol::PermissionResponseDecision::Allow; - if allow - && scope == protocol::PermissionGrantScope::Session - && let Err(error) = state.threads.persist_session_permission_grant( - &thread_id, - &client, - &runtime_workspace_roots, - &permissions, - ) - { - return protocol::write_server_event( - writer, - &id, - ServerEvent::error(format!( - "session permission settings did not commit: {error}" - )), - ); - } let answer = match &target { crate::unstable_surface::SurfaceInteractionKind::ToolApproval => { crate::unstable_surface::SurfaceClientInteractionAnswer::ToolApproval { diff --git a/crates/orca-runtime/src/server/surface_adapter.rs b/crates/orca-runtime/src/server/surface_adapter.rs index 40464afa..644631c0 100644 --- a/crates/orca-runtime/src/server/surface_adapter.rs +++ b/crates/orca-runtime/src/server/surface_adapter.rs @@ -846,183 +846,6 @@ impl JsonlSurfaceAdapter { ); Ok(()) } - - pub(crate) fn persist_session_permission_grant( - &self, - thread_id: &str, - client: &RuntimeSurfaceClientHandle, - runtime_workspace_roots: &[std::path::PathBuf], - permissions: &crate::protocol::RequestPermissionProfile, - ) -> io::Result<()> { - let surface = self - .jsonl_surface(thread_id) - .ok_or_else(|| io::Error::other("JSONL runtime surface unavailable"))?; - let attachment = match surface.attach_fresh(FreshAttachRequest { - request_id: SurfaceRequestId::new(), - role: SurfaceAttachmentRole::Jsonl, - requested_capabilities: BTreeSet::from([SurfaceCapability::ReadSnapshot]), - interaction_capabilities: BTreeSet::new(), - }) { - AttachResult::FreshAttached { attachment } => attachment, - _ => { - return Err(io::Error::other( - "JSONL permission settings snapshot unavailable", - )); - } - }; - let settings = &attachment.baseline.snapshot.settings; - let cwd = settings - .effective - .cwd - .as_path() - .to_str() - .ok_or_else(|| io::Error::other("JSONL thread cwd is not valid UTF-8"))?; - let is_ephemeral = self.ephemeral_thread(thread_id).is_some(); - let mut directories = settings.effective.additional_working_directories.clone(); - let mut metadata_writable_directories = if is_ephemeral { - Vec::new() - } else { - self.read_session(thread_id, false, false)? - .metadata_writable_directories - }; - if let Some(file_system) = permissions.file_system.as_ref() { - for requested in file_system - .write - .iter() - .flatten() - .filter(|path| !path.as_os_str().is_empty()) - { - for path in super::materialize_workspace_roots_paths( - cwd, - runtime_workspace_roots, - requested, - ) { - if orca_tools::sandbox::is_protected_metadata_root(&path) { - if !orca_tools::sandbox::is_safe_metadata_writable_root(&path) { - continue; - } - if !metadata_writable_directories.contains(&path) { - metadata_writable_directories.push(path); - } - continue; - } - let path = crate::unstable_surface::CanonicalPath::try_new(path) - .map_err(|error| io::Error::other(error.to_string()))?; - if !directories.iter().any(|directory| directory.path == path) { - directories.push( - crate::unstable_surface::SurfaceAdditionalWorkingDirectory { - path, - source: crate::unstable_surface::NonEmptyText::try_new("session") - .expect("session permission source is non-empty"), - }, - ); - } - } - } - } - let mut network = settings.effective.network_permissions.clone(); - if let Some(requested) = permissions.network.as_ref() { - if requested.enabled.is_some() { - network.enabled = requested.enabled; - } - for (domain, access) in &requested.domains { - let domain = crate::unstable_surface::CanonicalDomainName::try_new(domain.clone()) - .map_err(|error| io::Error::other(error.to_string()))?; - let access = match access { - orca_core::config::PermissionProfileNetworkAccess::Allow => { - crate::unstable_surface::SurfaceNetworkDomainAccess::Allow - } - orca_core::config::PermissionProfileNetworkAccess::Deny => { - crate::unstable_surface::SurfaceNetworkDomainAccess::Deny - } - }; - if let Some(existing) = network - .domains - .iter_mut() - .find(|permission| permission.domain == domain) - { - existing.access = access; - } else { - network - .domains - .push(crate::unstable_surface::SurfaceNetworkDomainPermission { - domain, - access, - }); - } - } - network - .domains - .sort_by(|left, right| left.domain.as_str().cmp(right.domain.as_str())); - } - let persisted_additional_working_directories = directories - .iter() - .map(|directory| orca_core::config::AdditionalWorkingDirectory { - path: directory.path.as_path().to_path_buf(), - source: directory.source.as_str().to_string(), - }) - .collect(); - let persisted_network_domain_permissions = network - .domains - .iter() - .map(|permission| { - ( - permission.domain.as_str().to_string(), - match permission.access { - crate::unstable_surface::SurfaceNetworkDomainAccess::Allow => { - orca_core::config::PermissionProfileNetworkAccess::Allow - } - crate::unstable_surface::SurfaceNetworkDomainAccess::Deny => { - orca_core::config::PermissionProfileNetworkAccess::Deny - } - }, - ) - }) - .collect(); - let mut patches = Vec::new(); - if directories != settings.effective.additional_working_directories { - patches.push(RuntimeSettingsPatch::ReplaceAdditionalWorkingDirectories { directories }); - } - if network != settings.effective.network_permissions { - patches.push(RuntimeSettingsPatch::ReplaceNetworkPermissions { - permissions: network, - }); - } - let update_result = if let Ok(patches) = NonEmptyVec::try_new(patches) { - committed( - client.update_settings(SurfaceRequestId::new(), settings.thread_revision, patches), - "JSONL session permission settings update", - ) - .map(|_| ()) - } else { - Ok(()) - }; - let update_result = update_result.and_then(|_| { - if is_ephemeral { - return Ok(()); - } - self.surface_host - .jsonl_update_session_metadata( - thread_id, - ThreadMetadataPatch { - additional_working_directories: Some( - persisted_additional_working_directories, - ), - metadata_writable_directories: Some(metadata_writable_directories), - network_domain_permissions: Some(persisted_network_domain_permissions), - ..ThreadMetadataPatch::default() - }, - ) - .map(|_| ()) - }); - let _ = surface.detach( - &attachment.client, - DetachRequest { - request_id: SurfaceRequestId::new(), - }, - ); - update_result - } } fn apply_surface_settings_to_run_config( @@ -1092,6 +915,15 @@ fn apply_surface_settings_to_run_config( source: directory.source.as_str().to_string(), }) .collect(); + config.additional_working_directories.extend( + settings + .metadata_writable_directories + .iter() + .map(|directory| orca_core::config::AdditionalWorkingDirectory { + path: directory.as_path().to_path_buf(), + source: crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE.to_string(), + }), + ); config.reasoning_effort = match settings.reasoning_effort { crate::unstable_surface::SurfaceReasoningEffort::High => { orca_core::config::ReasoningEffort::High @@ -2864,6 +2696,9 @@ fn settings_patches( let directories = config .additional_working_directories .iter() + .filter(|directory| { + directory.source != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }) .map(|directory| { Ok(crate::unstable_surface::SurfaceAdditionalWorkingDirectory { path: crate::unstable_surface::CanonicalPath::try_new(directory.path.clone())?, @@ -3005,145 +2840,6 @@ mod tests { impl JsonlSurfaceOutput for BrokenProjectionWriter {} - #[test] - fn stateless_session_metadata_grant_does_not_require_a_persisted_thread() { - let host = RuntimeHost::start().expect("start stateless permission runtime host"); - let surface_host = host.surface_handle().bind_new_connection(); - let mut adapter = JsonlSurfaceAdapter { - host: Some(host), - surface_host, - threads: HashMap::new(), - ephemeral_threads: Arc::new(Mutex::new(HashMap::new())), - transport_turns: Vec::new(), - }; - let cwd = tempdir().expect("stateless permission cwd"); - let config = test_run_config(cwd.path().to_path_buf()); - let prepared = adapter - .prepare_stateless_turn_with_interactions( - &config, - "request protected metadata access", - PermissionProfileOverride::default(), - &serde_json::json!("stateless-permission"), - test_interactions(), - ) - .expect("prepare stateless permission turn"); - let permissions = crate::protocol::RequestPermissionProfile { - file_system: Some(crate::protocol::RequestFileSystemPermissions { - write: Some(vec![cwd.path().join(".git")]), - ..Default::default() - }), - ..Default::default() - }; - - adapter - .persist_session_permission_grant( - prepared.thread_id(), - &prepared.client, - &[cwd.path().to_path_buf()], - &permissions, - ) - .expect("ephemeral session grant must stay runtime-only"); - - drop(prepared); - adapter - .shutdown() - .expect("shutdown stateless permission runtime host"); - } - - #[test] - fn recorded_session_metadata_grant_stays_in_the_dedicated_authority_channel() { - let _guard = crate::history::lock_test_env(); - let home = tempdir().expect("recorded permission home"); - let previous_home = std::env::var_os(crate::thread_store::ORCA_HOME_ENV); - unsafe { - std::env::set_var(crate::thread_store::ORCA_HOME_ENV, home.path()); - } - let host = RuntimeHost::start().expect("start recorded permission runtime host"); - let surface_host = host.surface_handle().bind_new_connection(); - let mut adapter = JsonlSurfaceAdapter { - host: Some(host), - surface_host, - threads: HashMap::new(), - ephemeral_threads: Arc::new(Mutex::new(HashMap::new())), - transport_turns: Vec::new(), - }; - let cwd = tempdir().expect("recorded permission cwd"); - let metadata = cwd.path().join(".git"); - std::fs::create_dir(&metadata).expect("metadata directory"); - let config = test_run_config(cwd.path().to_path_buf()); - let thread_id = adapter - .start_thread(&config) - .expect("start recorded thread"); - let prepared = adapter - .prepare_turn( - &config, - &thread_id, - "request protected metadata access", - PermissionProfileOverride::default(), - &serde_json::json!("recorded-permission"), - ) - .expect("prepare recorded permission turn"); - let permissions = crate::protocol::RequestPermissionProfile { - file_system: Some(crate::protocol::RequestFileSystemPermissions { - write: Some(vec![metadata.clone()]), - ..Default::default() - }), - ..Default::default() - }; - - adapter - .persist_session_permission_grant( - &thread_id, - &prepared.client, - &[cwd.path().to_path_buf()], - &permissions, - ) - .expect("persist recorded metadata grant"); - - let attachment = match prepared.surface.attach_fresh(FreshAttachRequest { - request_id: SurfaceRequestId::new(), - role: SurfaceAttachmentRole::Jsonl, - requested_capabilities: BTreeSet::from([SurfaceCapability::ReadSnapshot]), - interaction_capabilities: BTreeSet::new(), - }) { - AttachResult::FreshAttached { attachment } => attachment, - _ => panic!("attach recorded metadata settings snapshot"), - }; - let snapshot = attachment.baseline.snapshot; - assert!( - snapshot - .settings - .effective - .additional_working_directories - .iter() - .all(|directory| directory.path.as_path() != metadata) - ); - let stored = crate::thread_store::SessionStore::new() - .load_session(&thread_id) - .expect("load recorded metadata grant"); - assert!( - stored - .meta - .additional_working_directories - .iter() - .all(|directory| directory.path != metadata) - ); - assert_eq!(stored.meta.metadata_writable_directories, vec![metadata]); - - drop(prepared); - adapter - .shutdown() - .expect("shutdown recorded permission runtime host"); - unsafe { - match previous_home { - Some(previous_home) => { - std::env::set_var(crate::thread_store::ORCA_HOME_ENV, previous_home) - } - None => std::env::remove_var(crate::thread_store::ORCA_HOME_ENV), - } - } - } - #[test] fn projection_write_failure_is_returned_after_worker_and_ephemeral_actor_cleanup() { let (entered_tx, entered_rx) = sync_channel(1); diff --git a/crates/orca-runtime/src/tool_router.rs b/crates/orca-runtime/src/tool_router.rs index 5fe1e902..352c7fc3 100644 --- a/crates/orca-runtime/src/tool_router.rs +++ b/crates/orca-runtime/src/tool_router.rs @@ -286,6 +286,10 @@ impl<'a> RuntimeToolRouter<'a> { let additional_roots = config .additional_working_directories .iter() + .filter(|directory| { + directory.source + != crate::runtime_permission::SESSION_METADATA_DIRECTORY_SOURCE + }) .map(|directory| directory.path.clone()) .chain( permission_overlay diff --git a/crates/orca-runtime/tests/runtime_surface_attach.rs b/crates/orca-runtime/tests/runtime_surface_attach.rs index 012cde63..4e0d490b 100644 --- a/crates/orca-runtime/tests/runtime_surface_attach.rs +++ b/crates/orca-runtime/tests/runtime_surface_attach.rs @@ -38,6 +38,7 @@ fn snapshot(next_seq: u64) -> SurfaceSnapshot { digest: Sha256Digest::new([1; 32]), }, additional_working_directories: Vec::new(), + metadata_writable_directories: Vec::new(), network_permissions: SurfaceNetworkPermissions { enabled: Some(true), domains: Vec::new(), diff --git a/crates/orca-runtime/tests/runtime_surface_commit.rs b/crates/orca-runtime/tests/runtime_surface_commit.rs index 09ae056e..41c8c122 100644 --- a/crates/orca-runtime/tests/runtime_surface_commit.rs +++ b/crates/orca-runtime/tests/runtime_surface_commit.rs @@ -69,6 +69,7 @@ fn snapshot() -> SurfaceSnapshot { digest: digest(1), }, additional_working_directories: Vec::new(), + metadata_writable_directories: Vec::new(), network_permissions: SurfaceNetworkPermissions { enabled: Some(true), domains: Vec::new(), diff --git a/crates/orca-runtime/tests/runtime_surface_interaction.rs b/crates/orca-runtime/tests/runtime_surface_interaction.rs index 670fed85..db39abb7 100644 --- a/crates/orca-runtime/tests/runtime_surface_interaction.rs +++ b/crates/orca-runtime/tests/runtime_surface_interaction.rs @@ -107,6 +107,7 @@ struct BlockingAssistantStreamExecutor; struct PermissionExecutor { response_tx: mpsc::SyncSender, tool: ToolRequest, + write_path: PathBuf, } struct BlockingResolvedToolApprovalExecutor { @@ -543,7 +544,7 @@ impl ThreadOperationExecutor for PermissionExecutor { permissions: RequestPermissionProfile { file_system: Some(RequestFileSystemPermissions { read: None, - write: Some(vec![PathBuf::from("/workspace/output")]), + write: Some(vec![self.write_path.clone()]), entries: None, }), network: None, @@ -1553,6 +1554,7 @@ fn native_permission_allow_cannot_widen_requested_profile() { let host = RuntimeHost::start_with_executor(Arc::new(PermissionExecutor { response_tx, tool: permission_tool_request(), + write_path: PathBuf::from("/workspace/output"), })) .expect("start runtime host"); let thread = host @@ -1627,28 +1629,6 @@ fn native_permission_allow_cannot_widen_requested_profile() { )); assert!(response_rx.try_recv().is_err()); - let session_rejected = attachment - .client - .respond_interaction_by_id( - request_id(), - interaction.interaction_id.clone(), - SurfaceClientInteractionAnswer::PermissionRequest { - decision: SurfacePermissionClientDecision::Allow { - scope: PermissionGrantScope::Session, - permissions: requested.clone(), - strict_auto_review: false, - }, - }, - ) - .unwrap(); - assert!(matches!( - session_rejected, - MutationReply::Uncommitted { - mutation: UncommittedMutation::Invalid { ref error, .. }, - } if error.error().code == SurfaceMutationErrorCode::InvalidInput - )); - assert!(response_rx.try_recv().is_err()); - let _ = committed_value( attachment .client @@ -1694,6 +1674,7 @@ fn sandbox_permission_is_bound_to_the_exact_effect_tool() { let host = RuntimeHost::start_with_executor(Arc::new(PermissionExecutor { response_tx, tool: effect_tool_request(), + write_path: PathBuf::from("/workspace/output"), })) .expect("start runtime host"); let thread = host @@ -2402,6 +2383,7 @@ fn run_effect_permission_restart_child() -> ! { let host = RuntimeHost::start_with_executor(Arc::new(PermissionExecutor { response_tx, tool: permission_tool_request(), + write_path: PathBuf::from("/workspace/output"), })) .unwrap(); let thread = host @@ -3875,13 +3857,17 @@ fn tool_approval_allow_wakes_only_after_exact_resolution_batch_commits() { } #[test] -fn permission_allow_wakes_only_after_exact_resolution_batch_commits() { +fn session_permission_allow_commits_settings_before_waking_and_survives_conflicting_retry() { with_orca_home(|home| { let cwd = tempfile::tempdir().unwrap(); + let metadata_directory = cwd.path().join(".git"); + fs::create_dir(&metadata_directory).unwrap(); + let metadata_directory = metadata_directory.canonicalize().unwrap(); let (response_tx, response_rx) = mpsc::sync_channel(1); let host = RuntimeHost::start_with_executor(Arc::new(PermissionExecutor { response_tx, tool: permission_tool_request(), + write_path: metadata_directory.clone(), })) .unwrap(); let thread = host @@ -3929,7 +3915,7 @@ fn permission_allow_wakes_only_after_exact_resolution_batch_commits() { interaction.interaction_id.clone(), SurfaceClientInteractionAnswer::PermissionRequest { decision: SurfacePermissionClientDecision::Allow { - scope: PermissionGrantScope::Turn, + scope: PermissionGrantScope::Session, permissions: requested.clone(), strict_auto_review: false, }, @@ -3940,6 +3926,13 @@ fn permission_allow_wakes_only_after_exact_resolution_batch_commits() { Err(SurfaceClientCommandError::RuntimeUnavailable) )); assert!(response_rx.try_recv().is_err()); + assert!( + fresh_snapshot(&surface) + .settings + .effective + .metadata_writable_directories + .is_empty() + ); fs::remove_dir(&ledger).unwrap(); fs::rename(&backup, &ledger).unwrap(); @@ -3964,8 +3957,28 @@ fn permission_allow_wakes_only_after_exact_resolution_batch_commits() { RespondInteractionDisposition::AlreadyResolved { .. } )); assert_eq!( - response_rx.recv_timeout(TEST_TIMEOUT).unwrap().decision, - PermissionResponseDecision::Allow + response_rx.recv_timeout(TEST_TIMEOUT).unwrap(), + RuntimePermissionResponse { + decision: PermissionResponseDecision::Allow, + scope: RuntimePermissionGrantScope::Session, + strict_auto_review: false, + permissions: RequestPermissionProfile { + file_system: Some(RequestFileSystemPermissions { + read: None, + write: Some(vec![metadata_directory.clone()]), + entries: None, + }), + network: None, + shell: None, + }, + } + ); + assert_eq!( + fresh_snapshot(&surface) + .settings + .effective + .metadata_writable_directories, + vec![CanonicalPath::try_new(metadata_directory).unwrap()] ); assert!(response_rx.try_recv().is_err()); let replay = committed_value( diff --git a/crates/orca-runtime/tests/runtime_surface_reducer.rs b/crates/orca-runtime/tests/runtime_surface_reducer.rs index d7c08ff4..eced8908 100644 --- a/crates/orca-runtime/tests/runtime_surface_reducer.rs +++ b/crates/orca-runtime/tests/runtime_surface_reducer.rs @@ -58,6 +58,7 @@ fn settings() -> SurfaceSettingsSnapshot { digest: digest(1), }, additional_working_directories: Vec::new(), + metadata_writable_directories: Vec::new(), network_permissions: SurfaceNetworkPermissions { enabled: Some(true), domains: Vec::new(), diff --git a/crates/orca-tools/src/sandbox/bwrap.rs b/crates/orca-tools/src/sandbox/bwrap.rs index 1b14689c..0dd766dc 100644 --- a/crates/orca-tools/src/sandbox/bwrap.rs +++ b/crates/orca-tools/src/sandbox/bwrap.rs @@ -29,6 +29,8 @@ use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + /// How the sandboxed filesystem view is constructed. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LinuxReadScope { @@ -49,6 +51,14 @@ pub struct LinuxSandboxPolicy { pub allowed_unix_socket_roots: Vec, /// Roots granted read-write access. pub writable_roots: Vec, + /// Ordinary writable roots whose existing protected metadata descendants + /// must be discovered and re-bound read-only. This excludes implicit temp + /// roots, which would otherwise require an unbounded scan on every launch. + pub metadata_protection_roots: Vec, + /// Protected metadata roots granted read-write access through the dedicated + /// escalation channel. Keeping provenance separate prevents an ordinary + /// writable-root grant from being mistaken for metadata authority. + pub metadata_writable_roots: Vec, /// Roots that must stay read-only even when they fall under a writable /// root (re-bound with `--ro-bind` after the writable binds). Used to /// protect workspace metadata such as `.git` while preserving reads. @@ -62,26 +72,65 @@ pub struct LinuxSandboxPolicy { pub(crate) fn effective_read_only_roots(policy: &LinuxSandboxPolicy) -> Vec { let mut roots = policy.read_only_roots.clone(); - for writable_root in &policy.writable_roots { - for name in crate::sandbox::PROTECTED_METADATA_DIRS { - let candidate = writable_root.join(name); - if !candidate.exists() { - continue; - } - let canonical = candidate + for writable_root in &policy.metadata_protection_roots { + if let Some(metadata) = writable_root + .ancestors() + .find(|path| crate::sandbox::is_protected_metadata_root(path)) + { + let canonical = metadata .canonicalize() - .unwrap_or_else(|_| candidate.clone()); - let explicitly_writable = policy.writable_roots.iter().any(|root| { - root == &candidate && crate::sandbox::is_safe_metadata_writable_root(root) - }); - if !explicitly_writable { + .unwrap_or_else(|_| metadata.to_path_buf()); + if !is_explicit_metadata_writable_root(policy, metadata, &canonical) { push_unique(&mut roots, canonical); } } + + let mut entries = WalkDir::new(writable_root).follow_links(false).into_iter(); + while let Some(entry) = entries.next() { + match entry { + Ok(entry) if crate::sandbox::is_protected_metadata_root(entry.path()) => { + let canonical = entry + .path() + .canonicalize() + .unwrap_or_else(|_| entry.path().to_path_buf()); + if !is_explicit_metadata_writable_root(policy, entry.path(), &canonical) { + push_unique(&mut roots, canonical); + } + if entry.file_type().is_dir() { + entries.skip_current_dir(); + } + } + Ok(_) => {} + Err(error) => { + // A subtree that cannot be inspected must not silently + // inherit a broader writable grant. + let unreadable = error + .path() + .map(Path::to_path_buf) + .unwrap_or_else(|| writable_root.clone()); + let canonical = unreadable + .canonicalize() + .unwrap_or_else(|_| unreadable.clone()); + push_unique(&mut roots, canonical); + } + } + } } roots } +fn is_explicit_metadata_writable_root( + policy: &LinuxSandboxPolicy, + metadata: &Path, + canonical_metadata: &Path, +) -> bool { + crate::sandbox::is_safe_metadata_writable_root(metadata) + && policy.metadata_writable_roots.iter().any(|root| { + let canonical = root.canonicalize().unwrap_or_else(|_| root.clone()); + canonical == canonical_metadata + }) +} + /// Build the full `bwrap` argument vector (excluding the leading `bwrap` /// program name) for the given policy and shell command. /// @@ -137,6 +186,13 @@ pub fn build_bwrap_argv(policy: &LinuxSandboxPolicy, command: &str) -> Vec Vec { for root in &policy.writable_roots { push_unique(&mut roots, root.clone()); } + for root in &policy.metadata_writable_roots { + push_unique(&mut roots, root.clone()); + } for root in crate::sandbox::linux_platform_default_read_roots() { push_unique(&mut roots, root); } @@ -221,6 +280,8 @@ mod tests { readable_roots: Vec::new(), allowed_unix_socket_roots: Vec::new(), writable_roots: Vec::new(), + metadata_protection_roots: Vec::new(), + metadata_writable_roots: Vec::new(), read_only_roots: Vec::new(), denied_roots: Vec::new(), network_access: true, @@ -310,6 +371,43 @@ mod tests { std::fs::create_dir(&metadata).unwrap(); let mut policy = base_policy(); policy.writable_roots = vec![external.path().to_path_buf()]; + policy.metadata_protection_roots = policy.writable_roots.clone(); + + let argv = build_bwrap_argv(&policy, "true"); + let metadata = path_arg(&metadata.canonicalize().unwrap()); + let read_only_position = argv + .windows(3) + .position(|args| args == ["--ro-bind", metadata.as_str(), metadata.as_str()]); + + assert!(read_only_position.is_some()); + } + + #[test] + fn ordinary_exact_metadata_writable_root_is_rebound_read_only() { + let external = tempfile::tempdir().unwrap(); + let metadata = external.path().join(".git"); + std::fs::create_dir(&metadata).unwrap(); + let mut policy = base_policy(); + policy.writable_roots = vec![metadata.clone()]; + policy.metadata_protection_roots = policy.writable_roots.clone(); + + let argv = build_bwrap_argv(&policy, "true"); + let metadata = path_arg(&metadata.canonicalize().unwrap()); + let read_only_position = argv + .windows(3) + .position(|args| args == ["--ro-bind", metadata.as_str(), metadata.as_str()]); + + assert!(read_only_position.is_some()); + } + + #[test] + fn ordinary_writable_root_rebinds_nested_metadata_read_only() { + let external = tempfile::tempdir().unwrap(); + let metadata = external.path().join("project").join(".git"); + std::fs::create_dir_all(&metadata).unwrap(); + let mut policy = base_policy(); + policy.writable_roots = vec![external.path().to_path_buf()]; + policy.metadata_protection_roots = policy.writable_roots.clone(); let argv = build_bwrap_argv(&policy, "true"); let metadata = path_arg(&metadata.canonicalize().unwrap()); @@ -326,7 +424,9 @@ mod tests { let metadata = external.path().join(".git"); std::fs::create_dir(&metadata).unwrap(); let mut policy = base_policy(); - policy.writable_roots = vec![external.path().to_path_buf(), metadata.clone()]; + policy.writable_roots = vec![external.path().to_path_buf()]; + policy.metadata_protection_roots = policy.writable_roots.clone(); + policy.metadata_writable_roots = vec![metadata.clone()]; let argv = build_bwrap_argv(&policy, "true"); let metadata = path_arg(&metadata); @@ -346,6 +446,7 @@ mod tests { let mut policy = base_policy(); let target = external.path().canonicalize().unwrap(); policy.writable_roots = vec![target.clone()]; + policy.metadata_protection_roots = policy.writable_roots.clone(); let argv = build_bwrap_argv(&policy, "true"); let target = path_arg(&target); diff --git a/crates/orca-tools/src/sandbox/linux.rs b/crates/orca-tools/src/sandbox/linux.rs index ff430a4b..b12b599a 100644 --- a/crates/orca-tools/src/sandbox/linux.rs +++ b/crates/orca-tools/src/sandbox/linux.rs @@ -68,15 +68,19 @@ pub(crate) fn sandbox_command(request: LinuxSandboxRequest) -> Command { return bwrap_command(bwrap, &request); } - // No bwrap: try the in-process Landlock + seccomp fallback. // Some nested deny/read-only policies require namespace mounts that - // Landlock cannot express. Restricted-read requests and policies that need - // bwrap-specific enforcement must fail closed when no backend is available; - // other non-strict capability modes retain their compatibility fallback. - let must_fail_closed = request.strict || policy_requires_bwrap(&request); + // Landlock cannot express. They must fail before selecting the Landlock + // fallback, even when Landlock itself is available. + if policy_requires_bwrap(&request) { + return fail_closed_command("this Linux sandbox policy requires bubblewrap"); + } + + // No bwrap: try the in-process Landlock + seccomp fallback. Strict requests + // fail closed if that backend is unavailable; other capability modes retain + // their compatibility fallback. match landlock_command(&request) { Ok(command) => command, - Err(_) if must_fail_closed => { + Err(_) if request.strict => { fail_closed_command("no compatible Linux sandbox backend is available") } Err(_) => plain_command(&request.command, &request.policy.cwd), @@ -95,6 +99,7 @@ fn policy_requires_bwrap(request: &LinuxSandboxRequest) -> bool { policy .writable_roots .iter() + .chain(&policy.metadata_writable_roots) .any(|writable| paths_overlap(read_only, writable)) }); if writable_overlap { @@ -116,6 +121,7 @@ fn policy_requires_bwrap(request: &LinuxSandboxRequest) -> bool { let mut accessible = vec![policy.cwd.as_path()]; accessible.extend(policy.readable_roots.iter().map(PathBuf::as_path)); accessible.extend(policy.writable_roots.iter().map(PathBuf::as_path)); + accessible.extend(policy.metadata_writable_roots.iter().map(PathBuf::as_path)); accessible.extend( policy .allowed_unix_socket_roots @@ -271,6 +277,9 @@ mod linux_landlock { for root in &policy.writable_roots { push_unique(&mut readable_roots, root.clone()); } + for root in &policy.metadata_writable_roots { + push_unique(&mut readable_roots, root.clone()); + } for root in &policy.allowed_unix_socket_roots { push_unique(&mut readable_roots, root.clone()); } @@ -309,6 +318,7 @@ mod linux_landlock { let writable: Vec<&PathBuf> = policy .writable_roots .iter() + .chain(&policy.metadata_writable_roots) .filter(|r| r.exists()) .collect(); if !writable.is_empty() { @@ -489,6 +499,8 @@ mod tests { readable_roots: Vec::new(), allowed_unix_socket_roots: Vec::new(), writable_roots: Vec::new(), + metadata_protection_roots: Vec::new(), + metadata_writable_roots: Vec::new(), read_only_roots: Vec::new(), denied_roots: Vec::new(), network_access: false, @@ -545,6 +557,8 @@ mod tests { readable_roots: Vec::new(), allowed_unix_socket_roots: Vec::new(), writable_roots: vec![workspace.path().to_path_buf()], + metadata_protection_roots: vec![workspace.path().to_path_buf()], + metadata_writable_roots: Vec::new(), read_only_roots: vec![workspace.path().join(".git")], denied_roots: Vec::new(), network_access: false, @@ -576,6 +590,8 @@ mod tests { readable_roots: Vec::new(), allowed_unix_socket_roots: Vec::new(), writable_roots: vec![workspace.path().to_path_buf()], + metadata_protection_roots: vec![workspace.path().to_path_buf()], + metadata_writable_roots: Vec::new(), read_only_roots: vec![metadata], denied_roots: Vec::new(), network_access: true, @@ -601,6 +617,8 @@ mod tests { readable_roots: Vec::new(), allowed_unix_socket_roots: vec![socket_root.path().to_path_buf()], writable_roots: Vec::new(), + metadata_protection_roots: Vec::new(), + metadata_writable_roots: Vec::new(), read_only_roots: Vec::new(), denied_roots: Vec::new(), network_access: false, diff --git a/crates/orca-tools/src/sandbox/mod.rs b/crates/orca-tools/src/sandbox/mod.rs index c4605e84..7f3cd2b4 100644 --- a/crates/orca-tools/src/sandbox/mod.rs +++ b/crates/orca-tools/src/sandbox/mod.rs @@ -24,9 +24,31 @@ pub fn is_safe_metadata_writable_root(path: &Path) -> bool { if !is_protected_metadata_root(path) { return false; } - match std::fs::symlink_metadata(path) { - Ok(metadata) => !metadata.file_type().is_symlink(), - Err(error) => error.kind() == std::io::ErrorKind::NotFound, + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + !metadata.file_type().is_symlink() + && path.canonicalize().is_ok_and(|canonical| canonical == path) +} + +#[cfg(all(test, unix))] +mod metadata_root_tests { + use super::*; + + #[test] + fn metadata_grant_rejects_symlinked_parent_components() { + let parent = tempfile::tempdir().unwrap(); + let workspace = parent.path().join("workspace"); + let metadata = workspace.join(".git"); + std::fs::create_dir_all(&metadata).unwrap(); + let alias = parent.path().join("workspace-link"); + std::os::unix::fs::symlink(&workspace, &alias).unwrap(); + + assert!(is_safe_metadata_writable_root( + &metadata.canonicalize().unwrap() + )); + assert!(!is_safe_metadata_writable_root(&alias.join(".git"))); + assert!(!is_safe_metadata_writable_root(&workspace.join(".agents"))); } } @@ -283,11 +305,7 @@ mod platform { writable_roots.push(root.clone()); } } - for root in &metadata_writable_roots { - if !writable_roots.contains(root) { - writable_roots.push(root.clone()); - } - } + let metadata_protection_roots = writable_roots.clone(); if !context.exclude_slash_tmp { writable_roots.push(PathBuf::from("/tmp")); } @@ -328,6 +346,8 @@ mod platform { readable_roots: canonicalize_all(context.readable_roots), allowed_unix_socket_roots: canonicalize_all(context.allowed_unix_socket_roots), writable_roots, + metadata_protection_roots, + metadata_writable_roots, read_only_roots, denied_roots, network_access: context.network_access, @@ -417,6 +437,7 @@ mod platform { // Additional roots are writable even in read-only mode (e.g. an // explicitly granted output directory), matching the Seatbelt profile. let mut writable_roots = canonicalize_all(context.additional_roots); + let metadata_protection_roots = writable_roots.clone(); let metadata_writable_roots = canonicalize_all( &context .metadata_writable_roots @@ -425,11 +446,6 @@ mod platform { .cloned() .collect::>(), ); - for root in &metadata_writable_roots { - if !writable_roots.contains(root) { - writable_roots.push(root.clone()); - } - } let mut read_only_roots = Vec::new(); for name in PROTECTED_METADATA_DIRS { @@ -463,6 +479,8 @@ mod platform { readable_roots: canonicalize_all(context.readable_roots), allowed_unix_socket_roots: canonicalize_all(context.allowed_unix_socket_roots), writable_roots, + metadata_protection_roots, + metadata_writable_roots, read_only_roots, denied_roots, network_access: context.network_access, diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md index 86a04764..2f3dcca1 100644 --- a/docs/releases/v0.3.0.md +++ b/docs/releases/v0.3.0.md @@ -33,6 +33,16 @@ host. - GitHub Releases and npm now cover six native targets. Windows archives contain the CLI, runner, setup helper, and license; native x64 and ARM64 CI run reviewed platform contracts and the complete workspace test suite. +- Session-scoped filesystem grants are now committed by the runtime in the + same durable batch as the permission resolution. A failed append cannot + mutate settings or wake the waiting tool, and a later agent turn restores + the exact dedicated metadata grant without loading session history from the + Bash execution path. +- macOS and Linux keep `.git`, `.agents`, and `.codex` read-only under ordinary + writable roots. Symlinked metadata roots cannot widen an explicit grant; + Linux discovers existing nested metadata before launch, keeps dedicated + metadata authority separate from ordinary writable roots, and fails closed + when an overlapping policy requires bubblewrap but it is unavailable. ## Compatibility @@ -40,7 +50,11 @@ Existing CLI flags, configuration, JSONL, app-server, and ACP contracts remain compatible. Windows sandboxed execution requires a provisioned workspace capability; without it, restricted commands fail with setup guidance. Direct domain-restricted network policies remain unsupported on Windows and fail -closed. `danger-full-access` remains the explicit unrestricted mode. +closed. On Linux, policies that combine a writable ancestor with an existing +read-only metadata descendant require bubblewrap and fail closed when it is +unavailable. Metadata directories created after a command has already started +cannot be discovered by the launch-time mount policy. `danger-full-access` +remains the explicit unrestricted mode. ## Verification diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 6df8014f..b518b9f5 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -2,37 +2,37 @@ https://orcaagent.dev/ - 2026-07-29 + 2026-07-30 weekly 1.0 https://orcaagent.dev/changelog/ - 2026-07-29 + 2026-07-30 weekly 0.7 https://orcaagent.dev/terminal-coding-agent/ - 2026-07-29 + 2026-07-30 monthly 0.8 https://orcaagent.dev/deepseek-coding-agent/ - 2026-07-29 + 2026-07-30 monthly 0.8 https://orcaagent.dev/github/ - 2026-07-29 + 2026-07-30 monthly 0.75 https://orcaagent.dev/mcp/ - 2026-07-29 + 2026-07-30 monthly 0.75 diff --git a/site/src/changelog/Changelog.tsx b/site/src/changelog/Changelog.tsx index 3c7f937b..54096d2f 100644 --- a/site/src/changelog/Changelog.tsx +++ b/site/src/changelog/Changelog.tsx @@ -77,7 +77,7 @@ const copy = { }, summaries: { "v0.3.0": - "Orca now ships native Windows x64 and ARM64 support across the CLI, TUI, shell sessions, sandboxing, updates, persistence, npm packages and GitHub release archives. PowerShell 7, Windows PowerShell and cmd.exe resolve through explicit dialect-aware commands; ConPTY provides interactive terminal sessions; AltGr input, clipboard access, process-tree cleanup, atomic replacement and cross-process locks follow Windows semantics. The PowerShell installer verifies checksums, installs the runtime plus sandbox helpers and can provision, repair or remove the per-workspace sandbox capability. Native x64 and ARM64 CI run the platform contracts and full workspace tests before release.", + "Orca now ships native Windows x64 and ARM64 support across the CLI, TUI, shell sessions, sandboxing, updates, persistence, npm packages and GitHub release archives. PowerShell 7, Windows PowerShell and cmd.exe resolve through explicit dialect-aware commands; ConPTY provides interactive terminal sessions; AltGr input, clipboard access, process-tree cleanup, atomic replacement and cross-process locks follow Windows semantics. Session permission grants now commit atomically with their resolution, and macOS/Linux keep protected metadata read-only unless the exact non-symlink metadata root is explicitly granted. Linux fails closed when an overlapping metadata policy requires bubblewrap. Native x64 and ARM64 CI run the platform contracts and full workspace tests before release.", "v0.2.56": "The CLI binary is now limited to argument parsing and library forwarding: configuration, launch, update, history, trust, workflow, protocol and worker ownership live in orca-runtime and orca-tui. Stateless JSONL submissions now own their complete turn lifecycle without requiring a persisted thread, including exact EOF cancellation and settlement. macOS Seatbelt execution now uses the absolute system binary, parameterized path rules, protected metadata write roots and fail-closed enforcement, while trust and command-output failures propagate instead of being reported as success.", "v0.2.55": @@ -559,7 +559,7 @@ const copy = { }, summaries: { "v0.3.0": - "Orca 现在原生支持 Windows x64 与 ARM64,覆盖 CLI、TUI、Shell Session、沙箱、更新、持久化、npm 包和 GitHub Release。PowerShell 7、Windows PowerShell 与 cmd.exe 使用各自的命令方言,ConPTY 提供交互终端,AltGr 输入、剪贴板、进程树清理、原子替换和跨进程锁按 Windows 语义实现。PowerShell 安装器会校验 checksum,安装主程序与沙箱 helper,并支持配置、修复或移除按工作区绑定的沙箱 capability。发布前由原生 x64 和 ARM64 runner 执行平台契约与完整工作区测试。", + "Orca 现在原生支持 Windows x64 与 ARM64,覆盖 CLI、TUI、Shell Session、沙箱、更新、持久化、npm 包和 GitHub Release。PowerShell 7、Windows PowerShell 与 cmd.exe 使用各自的命令方言,ConPTY 提供交互终端,AltGr 输入、剪贴板、进程树清理、原子替换和跨进程锁按 Windows 语义实现。Session 权限授权会与交互结果原子提交;macOS/Linux 默认保持受保护 metadata 只读,只有精确且非软链接的 metadata 根目录可以显式放开。Linux 在重叠策略必须依赖 bubblewrap 时会 fail closed。发布前由原生 x64 和 ARM64 runner 执行平台契约与完整工作区测试。", "v0.2.56": "CLI 二进制现在只负责参数解析和转发:配置、启动、更新、历史、信任、workflow、协议与 worker 生命周期都下沉到 orca-runtime 和 orca-tui。无状态 JSONL submit 不再依赖已持久化 thread,由 runtime 完整拥有 turn,并在 EOF 时精确取消和结算。macOS Seatbelt 改用系统绝对路径、参数化路径规则、受保护 metadata 写入根目录与 fail-closed 强制;信任和命令输出失败也会向上返回,不再误报成功。", "v0.2.55": diff --git a/site/src/shared.ts b/site/src/shared.ts index 4242ee2c..efccd8e1 100644 --- a/site/src/shared.ts +++ b/site/src/shared.ts @@ -9,7 +9,7 @@ export const releaseVersion = "v0.3.0"; export const releases = [ { version: "v0.3.0", - date: "2026-07-29", + date: "2026-07-30", url: "https://github.com/echoVic/orca-agent/releases/tag/v0.3.0", }, { diff --git a/tests/server_runtime_contract.rs b/tests/server_runtime_contract.rs index 909c240d..fdf4d902 100644 --- a/tests/server_runtime_contract.rs +++ b/tests/server_runtime_contract.rs @@ -1207,13 +1207,14 @@ fn server_thread_directory_update_cannot_forge_metadata_escalation() { .load_session(&thread_id) .expect("load session"); assert!( - persisted + !persisted .meta .additional_working_directories .iter() .any(|directory| { directory.path == git_dir && directory.source == "session-metadata" - }) + }), + "reserved metadata sources must be ignored by ordinary directory updates" ); assert!( persisted.meta.metadata_writable_directories.is_empty(), @@ -1222,6 +1223,35 @@ fn server_thread_directory_update_cannot_forge_metadata_escalation() { }); } +#[test] +fn server_thread_start_cannot_forge_metadata_escalation() { + with_orca_home(|home| { + let mut runtime = start_server_runtime(); + let mut config = test_run_config(home); + config.history_mode = HistoryMode::Record; + let git_dir = home.join(".git"); + std::fs::create_dir_all(&git_dir).expect("git dir"); + config.additional_working_directories = vec![AdditionalWorkingDirectory::new( + git_dir.clone(), + "session-metadata", + )]; + + let thread_id = runtime.start_thread(&config).expect("start thread"); + let persisted = SessionStore::new() + .load_session(&thread_id) + .expect("load session"); + + assert!( + persisted.meta.additional_working_directories.is_empty(), + "reserved metadata sources must be ignored at the runtime boundary" + ); + assert!( + persisted.meta.metadata_writable_directories.is_empty(), + "runtime configuration must not mint metadata escalation authority" + ); + }); +} + #[test] fn server_thread_runtime_turn_start_persists_runtime_workspace_roots() { with_orca_home(|home| { diff --git a/tests/session_server_contract.rs b/tests/session_server_contract.rs index ad471fa7..f8e6c676 100644 --- a/tests/session_server_contract.rs +++ b/tests/session_server_contract.rs @@ -9162,6 +9162,113 @@ fn server_mode_request_permissions_session_scope_persists_directory_grant() { assert!(output.stderr.is_empty()); } +#[cfg(target_os = "macos")] +#[test] +fn server_mode_session_metadata_grant_is_committed_before_later_agent_turn_uses_it() { + let parent = sandbox_test_parent("orca-request-permissions-session-metadata-"); + let workspace = parent.path().join("workspace"); + let home = parent.path().join("home"); + let metadata = workspace.join(".git"); + std::fs::create_dir_all(&metadata).expect("create metadata"); + std::fs::create_dir_all(&home).expect("create home"); + std::fs::write( + home.join("config.toml"), + "mode = \"suggest\"\n[[permissions.rules]]\ntool = \"bash\"\npattern = \"**\"\ndecision = \"allow\"\n", + ) + .expect("write config"); + let first_output = metadata.join("first.lock"); + let second_output = metadata.join("second.lock"); + + let mut child = orca_command() + .args([ + "--mode", + "server", + "--provider", + "mock", + "--cwd", + workspace.to_str().unwrap(), + ]) + .env("ORCA_HOME", &home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn orca server"); + { + let stdin = child.stdin_mut(); + writeln!( + stdin, + r#"{{"id":"thread-req","method":"thread/start","params":{{}}}}"# + ) + .expect("write thread/start request"); + stdin.flush().expect("flush thread/start request"); + } + let thread_started = child.expect_event("thread-req", "thread_started"); + let thread_id = thread_started["threadId"] + .as_str() + .expect("thread id") + .to_string(); + + { + let stdin = child.stdin_mut(); + writeln!( + stdin, + r#"{{"id":"turn-1","method":"turn/start","params":{{"threadId":"{}","input":[{{"type":"text","text":"request_permissions_then_bash {} :: printf first > {}"}}]}}}}"#, + thread_id, + metadata.display(), + first_output.display(), + ) + .expect("write first turn"); + stdin.flush().expect("flush first turn"); + } + let permission_request = child.expect_event("turn-1", "permission_request"); + let request_id = permission_request["requestId"] + .as_str() + .expect("permission request id") + .to_string(); + + { + let stdin = child.stdin_mut(); + writeln!( + stdin, + r#"{{"id":"permission-response","method":"permission/respond","params":{{"requestId":"{}","decision":"allow","scope":"session","permissions":{{"fileSystem":{{"write":["{}"],"read":null}},"network":null}}}}}}"#, + request_id, + metadata.display(), + ) + .expect("write metadata session permission/respond"); + stdin + .flush() + .expect("flush metadata session permission/respond"); + } + let _resolved = child.expect_event("permission-response", "permission_resolved"); + let first_completed = child.expect_event("turn-1", "turn_completed"); + assert_eq!(first_completed["status"], "success"); + + { + let stdin = child.stdin_mut(); + writeln!( + stdin, + r#"{{"id":"turn-2","method":"turn/start","params":{{"threadId":"{}","input":[{{"type":"text","text":"force_bash printf second > {}"}}]}}}}"#, + thread_id, + second_output.display(), + ) + .expect("write second turn"); + stdin.flush().expect("flush second turn"); + } + let second_tool = + child.expect_event_matching("turn-2", "tool_completed", |event| event["tool"] == "bash"); + assert_eq!(second_tool["status"], "completed", "{second_tool:#}"); + let second_completed = child.expect_event("turn-2", "turn_completed"); + assert_eq!(second_completed["status"], "success"); + + child.close_stdin(); + let output = child.wait_with_output().expect("wait for server"); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + assert_eq!(std::fs::read_to_string(first_output).unwrap(), "first"); + assert_eq!(std::fs::read_to_string(second_output).unwrap(), "second"); +} + #[test] fn server_mode_request_permissions_session_scope_accepts_file_system_entries() { let parent = sandbox_test_parent("orca-request-permissions-entries-"); From 22c1a47ffb86a5a95805140e29521893969a81e6 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Thu, 30 Jul 2026 16:36:35 +0800 Subject: [PATCH 7/8] fix(windows): register unix symlink test fixtures in platform boundary manifest The Seatbelt hardening added `#[cfg(unix)]`/`#[cfg(all(test, unix))]` regression tests that use `std::os::unix::fs::symlink` to prove symlinked metadata roots cannot escalate into writable grants. The reviewed Windows platform boundary contract scans every `.rs` file for `std::os::unix::` usage and requires each occurrence to be registered with an exact count, so the unregistered fixtures failed the `Validate reviewed platform boundaries` gate on both native-x64 and native-arm64. Register the new unix-only test fixtures and correct one count the earlier metadata-escape commit left stale: - runtime_permission.rs: approved_symlink_metadata_root_is_not_made_writable - sandbox/bwrap.rs: symlinked_metadata_does_not_treat_its_writable_target... - sandbox/mod.rs: metadata_grant_rejects_symlinked_parent_components - sandbox/seatbelt.rs: bump unix_std_api 5 -> 6 for the added symlink test All entries are `#[cfg(unix)]`-gated fixtures (Windows symlink creation requires privilege), consistent with the existing runtime-surface-test-symlink boundary. Verified locally: `node scripts/validate-windows-platform-boundaries.mjs` and `node scripts/test-validate-windows-platform-boundaries.mjs` both pass. Co-authored-by: TRAE CLI --- ...-windows-platform-foundation.manifest.json | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-28-native-windows-platform-foundation.manifest.json b/docs/superpowers/specs/2026-07-28-native-windows-platform-foundation.manifest.json index 3e4da9d6..c174919b 100644 --- a/docs/superpowers/specs/2026-07-28-native-windows-platform-foundation.manifest.json +++ b/docs/superpowers/specs/2026-07-28-native-windows-platform-foundation.manifest.json @@ -115,6 +115,13 @@ 1, "process-ownership-plan" ], + [ + "runtime-permission-test-symlink", + "crates/orca-runtime/src/runtime_permission.rs", + "unix_std_api", + 1, + "windows-test-portability" + ], [ "sandbox-denial-unix-fixture", "crates/orca-runtime/src/sandbox_denial.rs", @@ -262,6 +269,20 @@ 3, "windows-sandbox-plan" ], + [ + "bwrap-sandbox-test-symlink", + "crates/orca-tools/src/sandbox/bwrap.rs", + "unix_std_api", + 1, + "windows-sandbox-plan" + ], + [ + "sandbox-metadata-test-symlink", + "crates/orca-tools/src/sandbox/mod.rs", + "unix_std_api", + 1, + "windows-sandbox-plan" + ], [ "sandbox-test-shells", "crates/orca-tools/src/sandbox/mod.rs", @@ -273,7 +294,7 @@ "seatbelt-unix-api", "crates/orca-tools/src/sandbox/seatbelt.rs", "unix_std_api", - 5, + 6, "windows-sandbox-plan" ], [ From 10bf009121ac119d4776d4fbfc1088a6c0461e37 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Thu, 30 Jul 2026 17:35:14 +0800 Subject: [PATCH 8/8] test(subagent): surface async launch stdout/stderr on Windows failure The async subagent contract tests capture the child `orca exec` streams, so when the launch exits non-zero on Windows the assertion collapses to `Some(1)` with no diagnostic. Add an `assert_launched_ok` helper that prints the captured stdout and stderr on mismatch to reveal the real failure reason from CI. Temporary diagnostic to root-cause the Windows async worker adoption path. Co-authored-by: TRAE CLI --- tests/subagent_contract.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/subagent_contract.rs b/tests/subagent_contract.rs index 44b20197..4632ae3a 100644 --- a/tests/subagent_contract.rs +++ b/tests/subagent_contract.rs @@ -93,7 +93,7 @@ fn async_subagent_launches_without_blocking_parent_tool() { .output() .expect("run orca"); - assert_eq!(output.status.code(), Some(0)); + assert_launched_ok(&output); let events = parse_jsonl(&output.stdout); assert!( @@ -147,7 +147,7 @@ fn subagent_status_can_read_persisted_async_handle() { ]) .output() .expect("run orca"); - assert_eq!(launched.status.code(), Some(0)); + assert_launched_ok(&launched); let launch_events = parse_jsonl(&launched.stdout); let launch_completed = find_event(&launch_events, "tool.call.completed"); let launch_payload: Value = @@ -201,7 +201,7 @@ fn async_subagent_completes_after_launching_exec_process_exits() { ]) .output() .expect("run orca"); - assert_eq!(launched.status.code(), Some(0)); + assert_launched_ok(&launched); let launch_events = parse_jsonl(&launched.stdout); let launch_completed = find_event(&launch_events, "tool.call.completed"); let launch_payload: Value = @@ -351,7 +351,7 @@ fn async_subagent_schema_failure_persists_failed_task() { ]) .output() .expect("run orca"); - assert_eq!(launched.status.code(), Some(0)); + assert_launched_ok(&launched); let launch_events = parse_jsonl(&launched.stdout); let launch_completed = find_event(&launch_events, "tool.call.completed"); let launch_payload: Value = @@ -528,6 +528,18 @@ fn subagent_cli_test_guard() -> MutexGuard<'static, ()> { .unwrap_or_else(std::sync::PoisonError::into_inner) } +fn assert_launched_ok(output: &std::process::Output) { + // Surface the child's captured streams so a Windows-only async launch + // failure is diagnosable from CI instead of collapsing to `Some(1)`. + assert_eq!( + output.status.code(), + Some(0), + "orca exec exited non-zero\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + fn run_git(cwd: &std::path::Path, args: &[&str]) { let output = Command::new("git") .current_dir(cwd)