diff --git a/Cargo.lock b/Cargo.lock index a43528a3..ed0a0fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1709,7 +1709,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "devo-arg0" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "clap", @@ -1729,7 +1729,7 @@ dependencies = [ [[package]] name = "devo-cli" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "async-trait", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "devo-client" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "chrono", @@ -1776,7 +1776,7 @@ dependencies = [ [[package]] name = "devo-code-search" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "bm25", @@ -1827,7 +1827,7 @@ dependencies = [ [[package]] name = "devo-config" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "devo-protocol", @@ -1843,7 +1843,7 @@ dependencies = [ [[package]] name = "devo-core" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "async-trait", @@ -1890,7 +1890,7 @@ dependencies = [ [[package]] name = "devo-execpolicy" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "clap", @@ -1907,7 +1907,7 @@ dependencies = [ [[package]] name = "devo-file-search" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "clap", @@ -1923,7 +1923,7 @@ dependencies = [ [[package]] name = "devo-keyring-store" -version = "0.1.30" +version = "0.1.31" dependencies = [ "keyring", "tracing", @@ -1931,7 +1931,7 @@ dependencies = [ [[package]] name = "devo-linux-sandbox" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "clap", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "devo-mcp" -version = "0.1.30" +version = "0.1.31" dependencies = [ "async-trait", "chrono", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "devo-network-proxy" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "pretty_assertions", @@ -1971,7 +1971,7 @@ dependencies = [ [[package]] name = "devo-protocol" -version = "0.1.30" +version = "0.1.31" dependencies = [ "agent-client-protocol", "chrono", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "devo-provider" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "async-stream", @@ -2011,7 +2011,7 @@ dependencies = [ [[package]] name = "devo-rmcp-client" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "axum", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "devo-safety" -version = "0.1.30" +version = "0.1.31" dependencies = [ "async-trait", "devo-config", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "devo-sandbox" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "chrono", @@ -2091,7 +2091,7 @@ dependencies = [ [[package]] name = "devo-sandbox-network-proxy" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "devo-sandbox", @@ -2103,7 +2103,7 @@ dependencies = [ [[package]] name = "devo-server" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "async-trait", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "devo-skills" -version = "0.1.30" +version = "0.1.31" dependencies = [ "chrono", "include_dir", @@ -2165,7 +2165,7 @@ dependencies = [ [[package]] name = "devo-tasks" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "async-trait", @@ -2181,7 +2181,7 @@ dependencies = [ [[package]] name = "devo-tools" -version = "0.1.30" +version = "0.1.31" dependencies = [ "async-trait", "devo-protocol", @@ -2196,7 +2196,7 @@ dependencies = [ [[package]] name = "devo-tui" -version = "0.1.30" +version = "0.1.31" dependencies = [ "ansi-to-tui", "anyhow", @@ -2257,14 +2257,14 @@ dependencies = [ [[package]] name = "devo-util-fuzzy" -version = "0.1.30" +version = "0.1.31" dependencies = [ "pretty_assertions", ] [[package]] name = "devo-util-git" -version = "0.1.30" +version = "0.1.31" dependencies = [ "assert_matches", "devo-util-paths", @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "devo-util-paths" -version = "0.1.30" +version = "0.1.31" dependencies = [ "dirs", "pretty_assertions", @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "devo-util-process" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "devo-sandbox", @@ -2312,7 +2312,7 @@ dependencies = [ [[package]] name = "devo-util-shell-command" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "base64 0.22.1", @@ -2332,7 +2332,7 @@ dependencies = [ [[package]] name = "devo-windows-sandbox" -version = "0.1.30" +version = "0.1.31" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 81a677bc..72e2a1ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ resolver = "3" edition = "2024" license = "MIT" rust-version = "1.88.0" -version = "0.1.30" +version = "0.1.31" [workspace.dependencies] agent-client-protocol = "0.14.0" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7202dd86..a9eab294 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devo/desktop", "productName": "Devo", - "version": "0.1.30", + "version": "0.1.31", "description": "AI-powered coding assistant desktop app", "author": "Devo", "license": "MIT", diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index f8b478b2..972b9aef 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -111,7 +111,6 @@ use crate::approval_reviewer::ReviewerDecision; use crate::approval_reviewer::build_approval_review_request; use crate::approval_reviewer::parse_reviewer_decision; use crate::db::QueueType; -use crate::execution::PendingApproval; use crate::execution::PendingUserInput; use crate::execution::RuntimeSession; use crate::execution::ServerRuntimeDependencies; diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index b7645825..f11fe2d2 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -1,5 +1,9 @@ use super::*; +use crate::execution::PendingApproval; +use crate::runtime::session_actor::approval_scope::{ + apply_approval_scope_to_state, apply_path_scope_to_permission_profile, +}; use crate::runtime::session_interactive::complete_approval_wait; use std::path::Component; @@ -84,6 +88,10 @@ impl ServerRuntime { if let Some(grant) = self.approval_cache_grant(session_id, &request).await { return Ok(grant); } + let permission_profile = self + .live_permission_profile(session_id) + .await + .unwrap_or(permission_profile); let policy = policy_decision( &permission_profile, &request, @@ -394,6 +402,59 @@ impl ServerRuntime { None } + /// Prefer the live turn-inline profile when a turn is in flight so mid-turn + /// PathPrefix/Session grants are visible to `policy_decision`. + async fn live_permission_profile( + &self, + session_id: SessionId, + ) -> Option { + if let Some(profile) = self.turn_inline_permission_profile(session_id).await { + return Some(profile); + } + if let Some(parent_session_id) = self.parent_session_id(session_id).await { + return self.turn_inline_permission_profile(parent_session_id).await; + } + None + } + + async fn turn_inline_permission_profile( + &self, + session_id: SessionId, + ) -> Option { + let stream = self.active_stream_state(session_id).await?; + let stream = stream.lock().await; + stream + .turn_inline + .as_ref() + .map(|inline| inline.hook_context.config.permission_profile.clone()) + } + + async fn apply_approval_scope_to_turn_inline( + &self, + session_id: SessionId, + scope: &ApprovalScopeValue, + pending: &PendingApproval, + ) { + let Some(stream) = self.active_stream_state(session_id).await else { + return; + }; + let mut stream = stream.lock().await; + let Some(inline) = stream.turn_inline.as_mut() else { + return; + }; + apply_approval_scope_to_state( + &mut inline.session_approval_cache, + &mut inline.turn_approval_cache, + scope, + pending, + ); + apply_path_scope_to_permission_profile( + &mut inline.hook_context.config.permission_profile, + scope, + pending, + ); + } + async fn session_approval_cache_grant( &self, session_id: SessionId, @@ -593,40 +654,47 @@ impl ServerRuntime { .await { let _ = pending.tx.send(decision.clone()); - if matches!(decision, ApprovalDecisionValue::Approve) - && let Some(session_handle) = self.session(host_session_id).await - { - let prefix_to_persist = (scope == ApprovalScopeValue::CommandPrefixPersist) - .then(|| pending.command_prefix.clone()) - .flatten(); + if matches!(decision, ApprovalDecisionValue::Approve) { let (scope_tx, _) = oneshot::channel(); - session_handle - .apply_approval_scope( - scope, - PendingApproval { - owner_session_id: pending.owner_session_id, - tool_name: pending.tool_name, - resource: pending.resource, - path: pending.path, - host: pending.host, - command_prefix: pending.command_prefix, - command_pattern: pending.command_pattern, - requests_escalation: pending.requests_escalation, - command: pending.command, - cwd: pending.cwd, - sandbox_permissions: pending.sandbox_permissions, - tx: scope_tx, - }, - ) - .await; - if let Some(prefix) = prefix_to_persist - && let Err(error) = self.persist_command_prefix_rule(&prefix).await - { - tracing::warn!( - session_id = %host_session_id, - error = %error, - "failed to persist command prefix rule" - ); + let pending_for_scope = PendingApproval { + owner_session_id: pending.owner_session_id, + tool_name: pending.tool_name, + resource: pending.resource, + path: pending.path, + host: pending.host, + command_prefix: pending.command_prefix, + command_pattern: pending.command_pattern, + requests_escalation: pending.requests_escalation, + command: pending.command, + cwd: pending.cwd, + sandbox_permissions: pending.sandbox_permissions, + tx: scope_tx, + }; + // ExecuteTurn owns the session mailbox, so ApplyApprovalScope cannot + // run until the turn ends. Update live TurnInlineState here so the + // same turn's later tool calls see PathPrefix/Session grants. + self.apply_approval_scope_to_turn_inline( + host_session_id, + &scope, + &pending_for_scope, + ) + .await; + if let Some(session_handle) = self.session(host_session_id).await { + let prefix_to_persist = (scope == ApprovalScopeValue::CommandPrefixPersist) + .then(|| pending_for_scope.command_prefix.clone()) + .flatten(); + session_handle + .apply_approval_scope(scope, pending_for_scope) + .await; + if let Some(prefix) = prefix_to_persist + && let Err(error) = self.persist_command_prefix_rule(&prefix).await + { + tracing::warn!( + session_id = %host_session_id, + error = %error, + "failed to persist command prefix rule" + ); + } } } } diff --git a/crates/server/src/runtime/session_actor/approval_scope.rs b/crates/server/src/runtime/session_actor/approval_scope.rs new file mode 100644 index 00000000..d6860910 --- /dev/null +++ b/crates/server/src/runtime/session_actor/approval_scope.rs @@ -0,0 +1,461 @@ +use std::path::{Path, PathBuf}; + +use devo_protocol::ApprovalScopeValue; +use devo_safety::RuntimePermissionProfile; + +use crate::execution::ApprovalGrantCache; +use crate::execution::PendingApproval; + +/// Applies an approval scope into session/turn grant caches. +pub(crate) fn apply_approval_scope_to_state( + session_cache: &mut ApprovalGrantCache, + turn_cache: &mut ApprovalGrantCache, + scope: &ApprovalScopeValue, + pending: &PendingApproval, +) { + match scope { + ApprovalScopeValue::Once => {} + ApprovalScopeValue::Turn => { + turn_cache.tools.insert(pending.tool_name.clone()); + } + ApprovalScopeValue::Session => { + // Prefer exact command + cwd. Fall back to a + // generalized pattern only when the exact command is unavailable, + // then to a whole-tool grant for non-shell tools. + if let Some(command) = pending.command.as_ref() { + session_cache + .exact_commands + .insert((command.clone(), pending.cwd.clone())); + } else if let Some(pattern) = pending.command_pattern.clone() { + session_cache.command_patterns.insert(pattern); + } else { + session_cache.tools.insert(pending.tool_name.clone()); + } + if let Some(path) = pending.path.as_ref() { + insert_path_prefix_grant(session_cache, pending.resource.as_ref(), path); + } + } + ApprovalScopeValue::PathPrefix => { + if let Some(path) = pending.path.as_ref() { + // Session-scoped so "don't ask again for these files" lasts for + // the rest of the conversation (session-scoped file approval). + insert_path_prefix_grant(session_cache, pending.resource.as_ref(), path); + } + } + ApprovalScopeValue::Host => { + if let Some(host) = pending.host.clone() { + session_cache.hosts.insert(host); + } + } + ApprovalScopeValue::Tool => { + turn_cache.tools.insert(pending.tool_name.clone()); + } + ApprovalScopeValue::CommandPrefix => { + if let Some(command_prefix) = pending.command_prefix.clone() { + session_cache.command_prefixes.insert(command_prefix); + } + } + ApprovalScopeValue::CommandPrefixPersist => { + if let Some(command_prefix) = pending.command_prefix.clone() { + session_cache.command_prefixes.insert(command_prefix); + } + } + } + if pending.requests_escalation + && matches!(scope, ApprovalScopeValue::Session) + && let Some(key) = crate::execution::sandbox_bypass_key_from_pending(pending) + { + session_cache.sandbox_bypass_commands.insert(key); + } +} + +/// Grants PathPrefix/Session path roots onto a runtime permission profile. +pub(crate) fn apply_path_scope_to_permission_profile( + profile: &mut RuntimePermissionProfile, + scope: &ApprovalScopeValue, + pending: &PendingApproval, +) { + if !matches!( + scope, + ApprovalScopeValue::PathPrefix | ApprovalScopeValue::Session + ) { + return; + } + let Some(path) = pending.path.as_ref() else { + return; + }; + let grant = path_prefix_grant_root(path); + match pending.resource.as_ref() { + Some(devo_safety::ResourceKind::FileWrite) => { + profile.grant_writable_root(grant); + } + Some(devo_safety::ResourceKind::FileRead) | Some(_) | None => { + // Read (and unknown) approvals must not elevate write roots. + profile.grant_readable_root(grant); + } + } +} + +fn insert_path_prefix_grant( + cache: &mut ApprovalGrantCache, + resource: Option<&devo_safety::ResourceKind>, + path: &Path, +) { + let grant = path_prefix_grant_root(path); + match resource { + Some(devo_safety::ResourceKind::FileWrite) => { + cache.write_path_prefixes.insert(grant); + } + Some(devo_safety::ResourceKind::FileRead) => { + cache.read_path_prefixes.insert(grant); + } + // Unknown / non-file resources: do not elevate write rights. + Some(_) | None => { + cache.read_path_prefixes.insert(grant); + } + } +} + +pub(crate) fn path_prefix_grant_root(path: &Path) -> PathBuf { + if path.is_dir() { + path.to_path_buf() + } else { + path.parent().unwrap_or(path).to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use devo_protocol::ApprovalScopeValue; + use pretty_assertions::assert_eq; + + use super::apply_approval_scope_to_state; + use super::apply_path_scope_to_permission_profile; + use super::path_prefix_grant_root; + use devo_safety::PermissionPreset; + use devo_safety::RuntimePermissionProfile; + + #[test] + fn command_prefix_persist_scope_stores_prefix_in_session_cache() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let mut pending = pending_approval(/*command_pattern*/ None); + pending.command_prefix = Some(vec!["git".to_string(), "pull".to_string()]); + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::CommandPrefixPersist, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .command_prefixes + .insert(vec!["git".to_string(), "pull".to_string()]); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn host_scope_stores_host_in_session_cache() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let pending = crate::execution::PendingApproval { + owner_session_id: devo_protocol::SessionId::new(), + tool_name: "fetch".to_string(), + resource: Some(devo_safety::ResourceKind::Network), + path: None, + host: Some("api.example.com".to_string()), + command_prefix: None, + command_pattern: None, + requests_escalation: false, + command: None, + cwd: PathBuf::from("/workspace"), + sandbox_permissions: String::new(), + tx, + }; + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::Host, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .hosts + .insert("api.example.com".to_string()); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + fn pending_approval(command_pattern: Option>) -> crate::execution::PendingApproval { + pending_approval_with_escalation(command_pattern, false, None) + } + + fn pending_approval_with_escalation( + command_pattern: Option>, + requests_escalation: bool, + command: Option, + ) -> crate::execution::PendingApproval { + let (tx, _rx) = tokio::sync::oneshot::channel(); + crate::execution::PendingApproval { + owner_session_id: devo_protocol::SessionId::new(), + tool_name: "shell_command".to_string(), + resource: Some(devo_safety::ResourceKind::ShellExec), + path: None, + host: None, + command_prefix: None, + command_pattern, + requests_escalation, + command, + cwd: PathBuf::from("/workspace"), + sandbox_permissions: if requests_escalation { + "require_escalated".to_string() + } else { + String::new() + }, + tx, + } + } + + #[test] + fn path_prefix_scope_stores_parent_directory_for_files() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let file_path = PathBuf::from("/workspace/src/main.rs"); + let pending = crate::execution::PendingApproval { + owner_session_id: devo_protocol::SessionId::new(), + tool_name: "write".to_string(), + resource: Some(devo_safety::ResourceKind::FileWrite), + path: Some(file_path.clone()), + host: None, + command_prefix: None, + command_pattern: None, + requests_escalation: false, + command: None, + cwd: PathBuf::from("/workspace"), + sandbox_permissions: String::new(), + tx, + }; + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::PathPrefix, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .write_path_prefixes + .insert(PathBuf::from("/workspace/src")); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn path_prefix_scope_stores_read_grants_separately() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let file_path = PathBuf::from("/workspace/src/main.rs"); + let pending = crate::execution::PendingApproval { + owner_session_id: devo_protocol::SessionId::new(), + tool_name: "read".to_string(), + resource: Some(devo_safety::ResourceKind::FileRead), + path: Some(file_path), + host: None, + command_prefix: None, + command_pattern: None, + requests_escalation: false, + command: None, + cwd: PathBuf::from("/workspace"), + sandbox_permissions: String::new(), + tx, + }; + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::PathPrefix, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .read_path_prefixes + .insert(PathBuf::from("/workspace/src")); + assert_eq!(session_cache, expected_session_cache); + assert!(session_cache.write_path_prefixes.is_empty()); + } + + #[test] + fn session_scope_stores_sandbox_bypass_for_escalation() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let pending = pending_approval_with_escalation(None, true, Some("npm install".to_string())); + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::Session, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .exact_commands + .insert(("npm install".to_string(), PathBuf::from("/workspace"))); + expected_session_cache + .sandbox_bypass_commands + .insert(crate::execution::SandboxBypassKey { + command: "npm install".to_string(), + cwd: PathBuf::from("/workspace"), + sandbox_permissions: "require_escalated".to_string(), + }); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn session_scope_with_exact_command_prefers_exact_over_pattern() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let mut pending = pending_approval(Some(vec![ + "git".to_string(), + "add".to_string(), + "*".to_string(), + ])); + pending.command = Some("git add file.txt".to_string()); + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::Session, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .exact_commands + .insert(("git add file.txt".to_string(), PathBuf::from("/workspace"))); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn session_scope_with_pattern_stores_pattern_not_tool_name() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let pending = pending_approval(Some(vec![ + "git".to_string(), + "add".to_string(), + "*".to_string(), + ])); + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::Session, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache.command_patterns.insert(vec![ + "git".to_string(), + "add".to_string(), + "*".to_string(), + ]); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn session_scope_without_pattern_keeps_tool_grant() { + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let pending = pending_approval(/*command_pattern*/ None); + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::Session, + &pending, + ); + + let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); + expected_session_cache + .tools + .insert("shell_command".to_string()); + assert_eq!(session_cache, expected_session_cache); + assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); + } + + #[test] + fn path_prefix_scope_updates_inline_cache_and_profile_for_child_path() { + // Mid-turn authorize reads TurnInlineState; applying scope there must + // make a sibling/child path grant-visible without waiting on the actor. + let temp = tempfile::tempdir().expect("tempdir"); + let dir = temp.path().join("src"); + std::fs::create_dir_all(&dir).expect("create src dir"); + let child = dir.join("helper.rs"); + + let mut session_cache = crate::execution::ApprovalGrantCache::default(); + let mut turn_cache = crate::execution::ApprovalGrantCache::default(); + let mut profile = RuntimePermissionProfile::from_preset( + PermissionPreset::Default, + temp.path().to_path_buf(), + ); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let pending = crate::execution::PendingApproval { + owner_session_id: devo_protocol::SessionId::new(), + tool_name: "read".to_string(), + resource: Some(devo_safety::ResourceKind::FileRead), + path: Some(dir.clone()), + host: None, + command_prefix: None, + command_pattern: None, + requests_escalation: false, + command: None, + cwd: temp.path().to_path_buf(), + sandbox_permissions: String::new(), + tx, + }; + + apply_approval_scope_to_state( + &mut session_cache, + &mut turn_cache, + &ApprovalScopeValue::PathPrefix, + &pending, + ); + apply_path_scope_to_permission_profile( + &mut profile, + &ApprovalScopeValue::PathPrefix, + &pending, + ); + + let grant_root = path_prefix_grant_root(&dir); + assert_eq!(grant_root, dir); + assert!(session_cache.read_path_prefixes.contains(&grant_root)); + assert!(profile.readable_roots.contains(&grant_root)); + assert!( + session_cache + .read_path_prefixes + .iter() + .any(|prefix| child.starts_with(prefix)) + ); + assert!( + profile + .readable_roots + .iter() + .any(|prefix| child.starts_with(prefix)) + ); + } +} diff --git a/crates/server/src/runtime/session_actor/loop_.rs b/crates/server/src/runtime/session_actor/loop_.rs index b8bdacd2..c80f0f15 100644 --- a/crates/server/src/runtime/session_actor/loop_.rs +++ b/crates/server/src/runtime/session_actor/loop_.rs @@ -1,4 +1,3 @@ -use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; @@ -7,9 +6,11 @@ use devo_core::SessionTitleFinalSource; use devo_core::SessionTitleState; use devo_core::TurnConfig; use devo_core::TurnStatus; -use devo_protocol::ApprovalScopeValue; use tokio::sync::mpsc; +use super::approval_scope::{ + apply_approval_scope_to_state, apply_path_scope_to_permission_profile, +}; use super::commands::SessionCommand; use super::snapshots::{ HookContextSnapshot, PendingQueueSnapshot, QueuedTurnInputData, ShellExecContextSnapshot, @@ -18,7 +19,6 @@ use super::snapshots::{ use super::state::SessionActorState; use super::turn::execute_turn_in_actor; use crate::SessionRuntimeStatus; -use crate::execution::PendingApproval; use crate::persistence::build_turn_record; use crate::runtime::session_model_selection; @@ -255,32 +255,16 @@ pub(super) async fn run_session_actor( &scope, &pending, ); - if matches!( - scope, - ApprovalScopeValue::PathPrefix | ApprovalScopeValue::Session - ) && let Some(path) = pending.path.as_ref() - { - let grant = path_prefix_grant_root(path); - match pending.resource.as_ref() { - Some(devo_safety::ResourceKind::FileWrite) => { - state - .core - .config - .permission_profile - .grant_writable_root(grant.clone()); - state.config.permission_profile.grant_writable_root(grant); - } - Some(devo_safety::ResourceKind::FileRead) | Some(_) | None => { - // Read (and unknown) approvals must not elevate write roots. - state - .core - .config - .permission_profile - .grant_readable_root(grant.clone()); - state.config.permission_profile.grant_readable_root(grant); - } - } - } + apply_path_scope_to_permission_profile( + &mut state.core.config.permission_profile, + &scope, + &pending, + ); + apply_path_scope_to_permission_profile( + &mut state.config.permission_profile, + &scope, + &pending, + ); } SessionCommand::UpdateSummary { summary } => { state.summary = summary; @@ -681,106 +665,14 @@ fn subagent_usage_owner_from_pending_metadata( Some((parent_session_id, parent_turn_id)) } -fn apply_approval_scope_to_state( - session_cache: &mut crate::execution::ApprovalGrantCache, - turn_cache: &mut crate::execution::ApprovalGrantCache, - scope: &ApprovalScopeValue, - pending: &PendingApproval, -) { - match scope { - ApprovalScopeValue::Once => {} - ApprovalScopeValue::Turn => { - turn_cache.tools.insert(pending.tool_name.clone()); - } - ApprovalScopeValue::Session => { - // Prefer exact command + cwd. Fall back to a - // generalized pattern only when the exact command is unavailable, - // then to a whole-tool grant for non-shell tools. - if let Some(command) = pending.command.as_ref() { - session_cache - .exact_commands - .insert((command.clone(), pending.cwd.clone())); - } else if let Some(pattern) = pending.command_pattern.clone() { - session_cache.command_patterns.insert(pattern); - } else { - session_cache.tools.insert(pending.tool_name.clone()); - } - if let Some(path) = pending.path.as_ref() { - insert_path_prefix_grant(session_cache, pending.resource.as_ref(), path); - } - } - ApprovalScopeValue::PathPrefix => { - if let Some(path) = pending.path.as_ref() { - // Session-scoped so "don't ask again for these files" lasts for - // the rest of the conversation (session-scoped file approval). - insert_path_prefix_grant(session_cache, pending.resource.as_ref(), path); - } - } - ApprovalScopeValue::Host => { - if let Some(host) = pending.host.clone() { - session_cache.hosts.insert(host); - } - } - ApprovalScopeValue::Tool => { - turn_cache.tools.insert(pending.tool_name.clone()); - } - ApprovalScopeValue::CommandPrefix => { - if let Some(command_prefix) = pending.command_prefix.clone() { - session_cache.command_prefixes.insert(command_prefix); - } - } - ApprovalScopeValue::CommandPrefixPersist => { - if let Some(command_prefix) = pending.command_prefix.clone() { - session_cache.command_prefixes.insert(command_prefix); - } - } - } - if pending.requests_escalation - && matches!(scope, ApprovalScopeValue::Session) - && let Some(key) = crate::execution::sandbox_bypass_key_from_pending(pending) - { - session_cache.sandbox_bypass_commands.insert(key); - } -} - -fn insert_path_prefix_grant( - cache: &mut crate::execution::ApprovalGrantCache, - resource: Option<&devo_safety::ResourceKind>, - path: &Path, -) { - let grant = path_prefix_grant_root(path); - match resource { - Some(devo_safety::ResourceKind::FileWrite) => { - cache.write_path_prefixes.insert(grant); - } - Some(devo_safety::ResourceKind::FileRead) => { - cache.read_path_prefixes.insert(grant); - } - // Unknown / non-file resources: do not elevate write rights. - Some(_) | None => { - cache.read_path_prefixes.insert(grant); - } - } -} - -fn path_prefix_grant_root(path: &Path) -> PathBuf { - if path.is_dir() { - path.to_path_buf() - } else { - path.parent().unwrap_or(path).to_path_buf() - } -} - #[cfg(test)] mod tests { use chrono::Utc; - use devo_protocol::ApprovalScopeValue; use devo_protocol::PendingInputItem; use devo_protocol::PendingInputKind; use pretty_assertions::assert_eq; use super::QueuedTurnInputData; - use super::apply_approval_scope_to_state; use super::pop_queued_turn_input_data; #[test] @@ -809,267 +701,4 @@ mod tests { } ); } - - #[test] - fn command_prefix_persist_scope_stores_prefix_in_session_cache() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let mut pending = pending_approval(/*command_pattern*/ None); - pending.command_prefix = Some(vec!["git".to_string(), "pull".to_string()]); - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::CommandPrefixPersist, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache - .command_prefixes - .insert(vec!["git".to_string(), "pull".to_string()]); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - #[test] - fn host_scope_stores_host_in_session_cache() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - let pending = crate::execution::PendingApproval { - owner_session_id: devo_protocol::SessionId::new(), - tool_name: "fetch".to_string(), - resource: Some(devo_safety::ResourceKind::Network), - path: None, - host: Some("api.example.com".to_string()), - command_prefix: None, - command_pattern: None, - requests_escalation: false, - command: None, - cwd: std::path::PathBuf::from("/workspace"), - sandbox_permissions: String::new(), - tx, - }; - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::Host, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache - .hosts - .insert("api.example.com".to_string()); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - fn pending_approval(command_pattern: Option>) -> crate::execution::PendingApproval { - pending_approval_with_escalation(command_pattern, false, None) - } - - fn pending_approval_with_escalation( - command_pattern: Option>, - requests_escalation: bool, - command: Option, - ) -> crate::execution::PendingApproval { - let (tx, _rx) = tokio::sync::oneshot::channel(); - crate::execution::PendingApproval { - owner_session_id: devo_protocol::SessionId::new(), - tool_name: "shell_command".to_string(), - resource: Some(devo_safety::ResourceKind::ShellExec), - path: None, - host: None, - command_prefix: None, - command_pattern, - requests_escalation, - command, - cwd: std::path::PathBuf::from("/workspace"), - sandbox_permissions: if requests_escalation { - "require_escalated".to_string() - } else { - String::new() - }, - tx, - } - } - - #[test] - fn path_prefix_scope_stores_parent_directory_for_files() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - let file_path = std::path::PathBuf::from("/workspace/src/main.rs"); - let pending = crate::execution::PendingApproval { - owner_session_id: devo_protocol::SessionId::new(), - tool_name: "write".to_string(), - resource: Some(devo_safety::ResourceKind::FileWrite), - path: Some(file_path.clone()), - host: None, - command_prefix: None, - command_pattern: None, - requests_escalation: false, - command: None, - cwd: std::path::PathBuf::from("/workspace"), - sandbox_permissions: String::new(), - tx, - }; - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::PathPrefix, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache - .write_path_prefixes - .insert(std::path::PathBuf::from("/workspace/src")); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - #[test] - fn path_prefix_scope_stores_read_grants_separately() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - let file_path = std::path::PathBuf::from("/workspace/src/main.rs"); - let pending = crate::execution::PendingApproval { - owner_session_id: devo_protocol::SessionId::new(), - tool_name: "read".to_string(), - resource: Some(devo_safety::ResourceKind::FileRead), - path: Some(file_path), - host: None, - command_prefix: None, - command_pattern: None, - requests_escalation: false, - command: None, - cwd: std::path::PathBuf::from("/workspace"), - sandbox_permissions: String::new(), - tx, - }; - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::PathPrefix, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache - .read_path_prefixes - .insert(std::path::PathBuf::from("/workspace/src")); - assert_eq!(session_cache, expected_session_cache); - assert!(session_cache.write_path_prefixes.is_empty()); - } - - #[test] - fn session_scope_stores_sandbox_bypass_for_escalation() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let pending = pending_approval_with_escalation(None, true, Some("npm install".to_string())); - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::Session, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache.exact_commands.insert(( - "npm install".to_string(), - std::path::PathBuf::from("/workspace"), - )); - expected_session_cache - .sandbox_bypass_commands - .insert(crate::execution::SandboxBypassKey { - command: "npm install".to_string(), - cwd: std::path::PathBuf::from("/workspace"), - sandbox_permissions: "require_escalated".to_string(), - }); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - #[test] - fn session_scope_with_exact_command_prefers_exact_over_pattern() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let mut pending = pending_approval(Some(vec![ - "git".to_string(), - "add".to_string(), - "*".to_string(), - ])); - pending.command = Some("git add file.txt".to_string()); - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::Session, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache.exact_commands.insert(( - "git add file.txt".to_string(), - std::path::PathBuf::from("/workspace"), - )); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - #[test] - fn session_scope_with_pattern_stores_pattern_not_tool_name() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let pending = pending_approval(Some(vec![ - "git".to_string(), - "add".to_string(), - "*".to_string(), - ])); - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::Session, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache.command_patterns.insert(vec![ - "git".to_string(), - "add".to_string(), - "*".to_string(), - ]); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } - - #[test] - fn session_scope_without_pattern_keeps_tool_grant() { - let mut session_cache = crate::execution::ApprovalGrantCache::default(); - let mut turn_cache = crate::execution::ApprovalGrantCache::default(); - let pending = pending_approval(/*command_pattern*/ None); - - apply_approval_scope_to_state( - &mut session_cache, - &mut turn_cache, - &ApprovalScopeValue::Session, - &pending, - ); - - let mut expected_session_cache = crate::execution::ApprovalGrantCache::default(); - expected_session_cache - .tools - .insert("shell_command".to_string()); - assert_eq!(session_cache, expected_session_cache); - assert_eq!(turn_cache, crate::execution::ApprovalGrantCache::default()); - } } diff --git a/crates/server/src/runtime/session_actor/mod.rs b/crates/server/src/runtime/session_actor/mod.rs index fccbda9f..a8042494 100644 --- a/crates/server/src/runtime/session_actor/mod.rs +++ b/crates/server/src/runtime/session_actor/mod.rs @@ -4,6 +4,7 @@ // turn is in flight, transient execution state lives in ActiveTurnRegistry and // merges back through actor commands when the turn completes. +pub(crate) mod approval_scope; mod commands; mod handle; mod loop_;