diff --git a/.yoi/profiles/_base.lua b/.yoi/profiles/_base.lua index 9bd0f114..1777d041 100644 --- a/.yoi/profiles/_base.lua +++ b/.yoi/profiles/_base.lua @@ -8,6 +8,7 @@ return function(opts) description = opts.description, scope = opts.scope or scope.workspace_read(), + delegation_scope = opts.delegation_scope, session = { record_event_trace = true, diff --git a/.yoi/profiles/orchestrator.lua b/.yoi/profiles/orchestrator.lua index b2cc373d..87c6ad15 100644 --- a/.yoi/profiles/orchestrator.lua +++ b/.yoi/profiles/orchestrator.lua @@ -1,8 +1,10 @@ local base = require("_base") +local scope = require("yoi.scope") return base { slug = "orchestrator", description = "Orchestrator role profile: GPT-5.5 with bundled default behavior", + delegation_scope = scope.workspace_write(), model_ref = "codex-oauth/gpt-5.5", language = "Japanese", } diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index f321d36e..c6232c6a 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -38,6 +38,9 @@ pub struct PodManifestConfig { pub worker: WorkerManifestConfig, #[serde(default)] pub scope: ScopeConfig, + /// Scope that may be subdelegated to spawned child Pods. Defaults empty. + #[serde(default)] + pub delegation_scope: ScopeConfig, #[serde(default)] pub session: Option, /// Optional `[permissions]` section. `None` means the permission layer @@ -243,6 +246,7 @@ impl PodManifestConfig { /// /// Affected fields: `model.auth.file`, /// `scope.allow[].target`, `scope.deny[].target`, + /// `delegation_scope.allow[].target`, `delegation_scope.deny[].target`, /// `compaction.model.auth.file`. pub fn resolve_paths(mut self, base: &Path) -> Self { debug_assert!( @@ -260,6 +264,12 @@ impl PodManifestConfig { for rule in &mut self.scope.deny { rule.target = join_if_relative(base, &rule.target); } + for rule in &mut self.delegation_scope.allow { + rule.target = join_if_relative(base, &rule.target); + } + for rule in &mut self.delegation_scope.deny { + rule.target = join_if_relative(base, &rule.target); + } if let Some(ref mut memory) = self.memory && let Some(ref mut root) = memory.workspace_root { @@ -288,6 +298,7 @@ impl PodManifestConfig { model: self.model.merge(upper.model), worker: self.worker.merge(upper.worker), scope: merge_scope(self.scope, upper.scope), + delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope), session: merge_option(self.session, upper.session, SessionConfigPartial::merge), permissions: merge_option( self.permissions, @@ -588,6 +599,12 @@ impl TryFrom for PodManifest { for rule in &cfg.scope.deny { ensure_absolute("scope.deny.target", &rule.target)?; } + for rule in &cfg.delegation_scope.allow { + ensure_absolute("delegation_scope.allow.target", &rule.target)?; + } + for rule in &cfg.delegation_scope.deny { + ensure_absolute("delegation_scope.deny.target", &rule.target)?; + } let session = SessionConfig { record_event_trace: cfg .session @@ -670,6 +687,7 @@ impl TryFrom for PodManifest { model: cfg.model, worker, scope: cfg.scope, + delegation_scope: cfg.delegation_scope, session, permissions, compaction, @@ -715,6 +733,7 @@ mod tests { }], deny: Vec::new(), }, + delegation_scope: ScopeConfig::default(), permissions: None, session: None, compaction: None, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 2839ea96..6c6ea654 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -21,7 +21,7 @@ pub use profile::{ resolve_profile_artifact, }; pub use protocol::{Permission, ScopeRule}; -pub use scope::{Scope, ScopeError, SharedScope}; +pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; use std::collections::HashMap; use std::num::NonZeroU32; @@ -40,7 +40,12 @@ pub struct PodManifest { pub pod: PodMeta, pub model: ModelManifest, pub worker: WorkerManifest, + /// Direct filesystem authority for this Pod's own tools. pub scope: ScopeConfig, + /// Filesystem authority this Pod may pass to spawned children. Missing + /// metadata/config defaults to no delegation authority. + #[serde(default)] + pub delegation_scope: ScopeConfig, /// Session/debug persistence settings. Defaults keep extra traces off. #[serde(default)] pub session: SessionConfig, @@ -644,6 +649,8 @@ permission = "write" assert!(manifest.model.auth.is_none()); assert_eq!(manifest.scope.allow.len(), 1); assert!(manifest.scope.deny.is_empty()); + assert!(manifest.delegation_scope.allow.is_empty()); + assert!(manifest.delegation_scope.deny.is_empty()); assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION); assert!(manifest.worker.top_p.is_none()); assert!(manifest.worker.top_k.is_none()); @@ -651,6 +658,17 @@ permission = "write" assert!(manifest.web.is_none()); } + #[test] + fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() { + let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); + let mut snapshot = serde_json::to_value(&manifest).unwrap(); + snapshot.as_object_mut().unwrap().remove("delegation_scope"); + let restored: PodManifest = serde_json::from_value(snapshot).unwrap(); + assert_eq!(restored.scope.allow.len(), 1); + assert!(restored.delegation_scope.allow.is_empty()); + assert!(restored.delegation_scope.deny.is_empty()); + } + #[test] fn parse_web_config() { let toml = format!( @@ -702,6 +720,14 @@ recursive = false [[scope.deny]] target = "/abs/project/secrets.rs" permission = "write" + +[[delegation_scope.allow]] +target = "/abs/project/tasks" +permission = "write" + +[[delegation_scope.deny]] +target = "/abs/project/tasks/private" +permission = "write" "#; let manifest = PodManifest::from_toml(toml).unwrap(); assert_eq!(manifest.pod.name, "code-reviewer"); @@ -728,6 +754,12 @@ permission = "write" assert!(!allow[1].recursive); assert_eq!(manifest.scope.deny.len(), 1); assert_eq!(manifest.scope.deny[0].permission, Permission::Write); + assert_eq!(manifest.delegation_scope.allow.len(), 1); + assert_eq!( + manifest.delegation_scope.allow[0].permission, + Permission::Write + ); + assert_eq!(manifest.delegation_scope.deny.len(), 1); } #[test] diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index d0d6334a..32b9600f 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -565,6 +565,10 @@ fn resolve_lua_profile_value( model: profile.model.unwrap_or_default(), worker: profile.worker.unwrap_or_default(), scope: profile_scope_to_config(profile.scope, workspace_base), + delegation_scope: profile_delegation_scope_to_config( + profile.delegation_scope, + workspace_base, + ), session: profile.session, permissions: profile.permissions, compaction, @@ -620,6 +624,8 @@ struct ProfileConfig { #[serde(default)] scope: Option, #[serde(default)] + delegation_scope: Option, + #[serde(default)] session: Option, #[serde(default)] permissions: Option, @@ -1133,12 +1139,34 @@ fn reject_absolute_auth_file( fn profile_scope_to_config( scope: Option, workspace_base: &Path, +) -> ScopeConfig { + profile_scope_intent_to_config( + scope, + workspace_base, + Some(ProfileScopeIntent::WorkspaceWrite), + ) +} + +fn profile_delegation_scope_to_config( + scope: Option, + workspace_base: &Path, +) -> ScopeConfig { + profile_scope_intent_to_config(scope, workspace_base, None) +} + +fn profile_scope_intent_to_config( + scope: Option, + workspace_base: &Path, + default_intent: Option, ) -> ScopeConfig { let intent = match scope { Some(ProfileScopeConfig::Intent { intent }) | Some(ProfileScopeConfig::String(intent)) => { - intent + Some(intent) } - None => ProfileScopeIntent::WorkspaceWrite, + None => default_intent, + }; + let Some(intent) = intent else { + return ScopeConfig::default(); }; let permission = match intent { ProfileScopeIntent::WorkspaceRead => Permission::Read, @@ -1419,6 +1447,7 @@ return profile { Some(ReasoningControl::Effort(ReasoningEffort::High)) ); assert_eq!(resolved.manifest.scope.allow[0].target, workspace); + assert!(resolved.manifest.delegation_scope.allow.is_empty()); assert_eq!( resolved.manifest.scope.allow[0].permission, Permission::Read @@ -1446,6 +1475,7 @@ return yoi.profile { slug = "main", model = shared.model, scope = yoi.scope.workspace_write(), + delegation_scope = yoi.scope.workspace_write(), } "#, ); @@ -1464,6 +1494,14 @@ return yoi.profile { resolved.manifest.scope.allow[0].permission, Permission::Write ); + assert_eq!( + resolved.manifest.delegation_scope.allow[0].target, + tmp.path().canonicalize().unwrap() + ); + assert_eq!( + resolved.manifest.delegation_scope.allow[0].permission, + Permission::Write + ); } #[test] fn sandbox_denies_unsafe_libraries() { diff --git a/crates/manifest/src/scope.rs b/crates/manifest/src/scope.rs index a4097865..5d229984 100644 --- a/crates/manifest/src/scope.rs +++ b/crates/manifest/src/scope.rs @@ -32,6 +32,127 @@ struct ResolvedRule { recursive: bool, } +/// Parsed filesystem authority this Pod may pass to spawned children. +/// +/// Unlike [`Scope`], an empty allow list is valid and means no delegation +/// authority. Direct tools never consult this type. +#[derive(Debug, Clone)] +pub struct DelegationScope { + allow: Vec, + deny: Vec, +} + +impl DelegationScope { + pub fn from_config(config: &ScopeConfig) -> Result { + let allow = config + .allow + .iter() + .map(resolve_rule) + .collect::, _>>()?; + let deny = config + .deny + .iter() + .map(resolve_rule) + .collect::, _>>()?; + Ok(Self { allow, deny }) + } + + pub fn is_empty(&self) -> bool { + self.allow.is_empty() + } + + pub fn allows_rule(&self, requested: &ScopeRule) -> Result { + let requested = resolve_rule(requested)?; + let covered = self + .allow + .iter() + .any(|candidate| rule_covers(candidate, &requested)); + if !covered { + return Ok(false); + } + let denied = self + .deny + .iter() + .any(|deny| denial_overlaps_requested(deny, &requested)); + Ok(!denied) + } +} + +fn permission_covers(available: Permission, requested: Permission) -> bool { + match (available, requested) { + (Permission::Write, Permission::Write) + | (Permission::Write, Permission::Read) + | (Permission::Read, Permission::Read) => true, + (Permission::Read, Permission::Write) => false, + } +} + +fn permission_denies_requested(denied: Permission, requested: Permission) -> bool { + match (denied, requested) { + (Permission::Write, Permission::Write) + | (Permission::Read, Permission::Read) + | (Permission::Read, Permission::Write) => true, + (Permission::Write, Permission::Read) => false, + } +} + +fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool { + permission_covers(available.permission, requested.permission) + && rule_path_set_contains(available, requested) +} + +fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool { + permission_denies_requested(deny.permission, requested.permission) + && rule_path_sets_overlap(deny, requested) +} + +fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool { + match (available.recursive, requested.recursive) { + // A recursive grant contains every possible requested path below its target. + (true, _) => requested.target.starts_with(&available.target), + // A non-recursive grant contains only the target and its direct children; + // a recursive request always includes descendants beyond that finite-depth + // set. + (false, true) => false, + // Two non-recursive rules have the same finite-depth set only when their + // target is identical. A request rooted at a direct child would also grant + // that child's children, which are grandchildren of `available.target`. + (false, false) => requested.target == available.target, + } +} + +fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool { + match (left.recursive, right.recursive) { + (true, true) => { + left.target.starts_with(&right.target) || right.target.starts_with(&left.target) + } + (true, false) => recursive_and_non_recursive_sets_overlap(left, right), + (false, true) => recursive_and_non_recursive_sets_overlap(right, left), + (false, false) => { + left.target == right.target + || direct_child(&left.target, &right.target) + || direct_child(&right.target, &left.target) + } + } +} + +fn recursive_and_non_recursive_sets_overlap( + recursive: &ResolvedRule, + non_recursive: &ResolvedRule, +) -> bool { + // The non-recursive set is `{target} + direct children`. It overlaps a + // recursive subtree when either the non-recursive target is inside that + // subtree, or the recursive subtree begins at the non-recursive target or + // one of its direct children. + non_recursive.target.starts_with(&recursive.target) + || recursive.target == non_recursive.target + || direct_child(&recursive.target, &non_recursive.target) +} + +fn direct_child(child: &Path, parent: &Path) -> bool { + child.parent().is_some_and(|candidate| candidate == parent) +} + /// Errors raised when constructing a [`Scope`] from a [`ScopeConfig`]. #[derive(Debug, thiserror::Error)] pub enum ScopeError { @@ -419,14 +540,18 @@ mod tests { use super::*; use tempfile::TempDir; - fn allow_rule(target: &Path, permission: Permission) -> ScopeRule { + fn rule(target: &Path, permission: Permission, recursive: bool) -> ScopeRule { ScopeRule { target: target.to_path_buf(), permission, - recursive: true, + recursive, } } + fn allow_rule(target: &Path, permission: Permission) -> ScopeRule { + rule(target, permission, true) + } + #[test] fn writable_shortcut_permits_root() { let dir = TempDir::new().unwrap(); @@ -542,6 +667,80 @@ mod tests { assert!(!scope.is_writable(&nested.join("deep.txt"))); } + #[test] + fn delegation_non_recursive_grant_rejects_child_non_recursive_request() { + let dir = TempDir::new().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let delegation = DelegationScope::from_config(&ScopeConfig { + allow: vec![rule(dir.path(), Permission::Write, false)], + deny: Vec::new(), + }) + .unwrap(); + + assert!( + !delegation + .allows_rule(&rule(&child, Permission::Write, false)) + .unwrap(), + "a non-recursive child request includes grandchildren outside the parent non-recursive grant" + ); + } + + #[test] + fn delegation_non_recursive_grant_allows_exact_non_recursive_request() { + let dir = TempDir::new().unwrap(); + let delegation = DelegationScope::from_config(&ScopeConfig { + allow: vec![rule(dir.path(), Permission::Write, false)], + deny: Vec::new(), + }) + .unwrap(); + + assert!( + delegation + .allows_rule(&rule(dir.path(), Permission::Write, false)) + .unwrap(), + "identical non-recursive path sets should be delegable" + ); + } + + #[test] + fn delegation_recursive_grant_allows_child_non_recursive_request() { + let dir = TempDir::new().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let delegation = DelegationScope::from_config(&ScopeConfig { + allow: vec![rule(dir.path(), Permission::Write, true)], + deny: Vec::new(), + }) + .unwrap(); + + assert!( + delegation + .allows_rule(&rule(&child, Permission::Write, false)) + .unwrap(), + "recursive parent grants cover non-recursive child path sets" + ); + } + + #[test] + fn delegation_non_recursive_deny_overlaps_child_recursive_request() { + let dir = TempDir::new().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let delegation = DelegationScope::from_config(&ScopeConfig { + allow: vec![rule(dir.path(), Permission::Write, true)], + deny: vec![rule(dir.path(), Permission::Write, false)], + }) + .unwrap(); + + assert!( + !delegation + .allows_rule(&rule(&child, Permission::Write, true)) + .unwrap(), + "non-recursive deny at the parent includes the direct child path requested recursively" + ); + } + #[test] fn empty_allow_rejected() { let cfg = ScopeConfig { diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 8a741e83..a20e9cf1 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -22,8 +22,8 @@ use tracing::{info, warn}; use crate::segment_log_sink::SegmentLogSink; use manifest::{ - Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError, - ScopeRule, SharedScope, WorkerManifest, + DelegationScope, Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, + ScopeError, ScopeRule, SharedScope, WorkerManifest, }; use crate::compact::state::CompactState; @@ -238,6 +238,9 @@ pub struct Pod { /// compact worker) so scope updates propagate to every consumer /// at the next permission check. scope: SharedScope, + /// Filesystem authority this Pod may pass to spawned children. Direct tools + /// continue to use `scope`; SpawnPod validates requested child scope here. + delegation_scope: DelegationScope, hook_builder: HookRegistryBuilder, interceptor_installed: bool, /// Shared compaction state (present when threshold is configured). @@ -415,6 +418,7 @@ impl Pod { segment_state: self.segment_state.clone(), pwd: self.pwd.clone(), scope: self.scope.clone(), + delegation_scope: self.delegation_scope.clone(), hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, @@ -585,6 +589,8 @@ impl Pod { let session_id = session_store::new_session_id(); let segment_id = session_store::new_segment_id(); let prompts = PromptCatalog::builtins_only()?; + let delegation_scope = + DelegationScope::from_config(&manifest.delegation_scope).map_err(PodError::Scope)?; let mut pod = Self { manifest, worker: Some(worker), @@ -593,6 +599,7 @@ impl Pod { segment_state: SegmentState::new(session_id, segment_id, 0), pwd, scope: SharedScope::new(scope), + delegation_scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, @@ -3724,6 +3731,7 @@ where segment_state: SegmentState::new(session_id, segment_id, 0), pwd: common.pwd, scope: SharedScope::new(common.scope), + delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, @@ -3802,6 +3810,7 @@ where segment_state: SegmentState::new(session_id, segment_id, 0), pwd: common.pwd, scope: SharedScope::new(common.scope), + delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, @@ -3979,6 +3988,7 @@ where segment_state: SegmentState::new(session_id, segment_id, state.entries_count), pwd: common.pwd, scope: SharedScope::new(common.scope), + delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, @@ -4606,6 +4616,7 @@ pub enum PodError { struct PodCommon { pwd: PathBuf, scope: Scope, + delegation_scope: DelegationScope, client: Box, prompts: Arc, workflow_registry: workflow_crate::WorkflowRegistry, @@ -4719,6 +4730,8 @@ fn prepare_pod_common_from_scope( if !scope.is_readable(&pwd) { return Err(PodError::PwdOutsideScope { pwd }); } + let delegation_scope = + DelegationScope::from_config(&manifest.delegation_scope).map_err(PodError::Scope)?; let client = provider::build_client(&manifest.model)?; let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?; @@ -4744,6 +4757,7 @@ fn prepare_pod_common_from_scope( Ok(PodCommon { pwd, scope, + delegation_scope, client, prompts, workflow_registry, diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 7a75a322..f0540b5a 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -15,10 +15,11 @@ use async_trait::async_trait; use client::PodRuntimeCommand; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ - CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial, - PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry, - ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, - ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig, + CompactionConfigPartial, DelegationScope, FileUploadLimitsPartial, Permission, + PermissionConfigPartial, PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, + ProfileError, ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, + ProfileSelector, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope, + ToolOutputLimitsPartial, WorkerManifestConfig, }; use serde::Deserialize; use tokio::net::UnixStream; @@ -54,7 +55,8 @@ struct SpawnPodInput { /// First message sent to the spawned Pod via `Method::Run`. task: String, /// Allow rules delegated to the spawned Pod. Must be a subset of the - /// spawner's effective write scope. + /// spawner's explicit delegation authority; direct tool scope alone is not + /// sufficient. scope: Vec, } @@ -248,6 +250,10 @@ pub struct SpawnPodTool { /// `effective_write` semantics: Write is the only permission /// tracked across Pods, so revocation only touches Write. spawner_scope: SharedScope, + /// Filesystem scope this Pod is allowed to subdelegate to children. + /// This is intentionally separate from `spawner_scope`, which authorizes + /// the current Pod's own direct tools. + delegation_scope: DelegationScope, } impl SpawnPodTool { @@ -261,6 +267,7 @@ impl SpawnPodTool { spawner_manifest: PodManifest, available_profiles: AvailableProfiles, spawner_scope: SharedScope, + delegation_scope: DelegationScope, runtime_command: Option, ) -> Self { Self { @@ -274,6 +281,7 @@ impl SpawnPodTool { spawner_manifest, available_profiles, spawner_scope, + delegation_scope, } } } @@ -295,6 +303,7 @@ impl Tool for SpawnPodTool { } let scope_allow = parse_scope(&input.scope)?; + self.validate_delegation_scope(&scope_allow)?; let spawn_selector = parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| { @@ -471,6 +480,28 @@ impl SpawnPodTool { } } + fn validate_delegation_scope(&self, scope_allow: &[ScopeRule]) -> Result<(), ToolError> { + if self.delegation_scope.is_empty() && !scope_allow.is_empty() { + return Err(ToolError::InvalidArgument( + "SpawnPod requires delegation authority, but this Pod has no delegation scope grant; direct filesystem scope only authorizes this Pod's own tools".into(), + )); + } + for rule in scope_allow { + let allowed = self + .delegation_scope + .allows_rule(rule) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + if !allowed { + return Err(ToolError::InvalidArgument(format!( + "requested child scope {} {:?} is outside this Pod's delegation scope grant", + rule.target.display(), + rule.permission + ))); + } + } + Ok(()) + } + fn release_reservation(&self, lock_path: &Path, pod_name: &str) { if let Ok(mut g) = LockFileGuard::open(lock_path) { let _ = pod_registry::release_pod(&mut g, pod_name); @@ -654,6 +685,8 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig { allow: manifest.scope.allow.clone(), deny: manifest.scope.deny.clone(), }, + // `inherit` reuses behavioral configuration, not subdelegation authority. + delegation_scope: ScopeConfig::default(), session: Some(SessionConfigPartial { record_event_trace: Some(manifest.session.record_event_trace), }), @@ -857,6 +890,8 @@ fn spawn_pod_tool_impl( spawner_manifest.clone(), available_profiles, spawner_scope.clone(), + DelegationScope::from_config(&spawner_manifest.delegation_scope) + .expect("resolved Pod manifest has a valid delegation scope"), runtime_command.clone(), )); (meta, tool) diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 7089ba36..522b45ef 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -175,20 +175,38 @@ fn dummy_model() -> ModelManifest { } fn dummy_manifest(allow_root: &Path) -> PodManifest { + dummy_manifest_with_delegation(allow_root, true) +} + +fn dummy_manifest_with_delegation(allow_root: &Path, allow_delegation: bool) -> PodManifest { + let direct_scope = ScopeConfig { + allow: vec![ScopeRule { + target: allow_root.to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }; + let delegation_scope = if allow_delegation { + direct_scope.clone() + } else { + ScopeConfig::default() + }; + dummy_manifest_with_scopes(direct_scope, delegation_scope) +} + +fn dummy_manifest_with_scopes( + direct_scope: ScopeConfig, + delegation_scope: ScopeConfig, +) -> PodManifest { PodManifestConfig { pod: PodMetaConfig { name: Some("root".into()), prompt_pack: None, }, model: dummy_model(), - scope: ScopeConfig { - allow: vec![ScopeRule { - target: allow_root.to_path_buf(), - permission: Permission::Write, - recursive: true, - }], - deny: Vec::new(), - }, + scope: direct_scope, + delegation_scope, ..Default::default() } .try_into() @@ -305,6 +323,125 @@ async fn spawn_pod_delegates_scope_and_sends_run() { clear_env(); } +#[tokio::test] +async fn spawn_pod_requires_explicit_delegation_even_with_direct_scope() { + let _env = EnvGuard::acquire(); + + let allow_root = TempDir::new().unwrap(); + let (_tmp, runtime_base, spawner_socket, spawner_rd) = + setup_spawner("root", allow_root.path()).await; + + let manifest = dummy_manifest_with_delegation(allow_root.path(), false); + let direct = Scope::from_config(&manifest.scope).unwrap(); + assert!(direct.is_writable(&allow_root.path().join("direct.txt"))); + + let registry = SpawnedPodRegistry::new(spawner_rd.clone()); + let def = spawn_pod_tool_with_runtime_command( + "root".into(), + spawner_socket, + runtime_base, + allow_root.path().to_path_buf(), + registry, + None, + manifest, + shared_scope_for(allow_root.path()), + builtin_prompts(), + mock_runtime_command(), + ); + let (_meta, tool) = def(); + + let input = json!({ + "name": "child-no-delegation", + "task": "hello", + "profile": "inherit", + "scope": [{ + "target": allow_root.path().to_str().unwrap(), + "permission": "write" + }] + }) + .to_string(); + + let err = tool.execute(&input).await.unwrap_err(); + match err { + ToolError::InvalidArgument(message) => { + assert!(message.contains("no delegation scope grant"), "{message}"); + assert!(message.contains("direct filesystem scope"), "{message}"); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + + clear_env(); +} + +#[tokio::test] +async fn spawn_pod_rejects_child_non_recursive_scope_under_parent_non_recursive_delegation() { + let _env = EnvGuard::acquire(); + + let allow_root = TempDir::new().unwrap(); + let child = allow_root.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let (_tmp, runtime_base, spawner_socket, spawner_rd) = + setup_spawner("root", allow_root.path()).await; + + let direct_scope = ScopeConfig { + allow: vec![ScopeRule { + target: allow_root.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }; + let delegation_scope = ScopeConfig { + allow: vec![ScopeRule { + target: allow_root.path().to_path_buf(), + permission: Permission::Write, + recursive: false, + }], + deny: Vec::new(), + }; + let manifest = dummy_manifest_with_scopes(direct_scope, delegation_scope); + + let registry = SpawnedPodRegistry::new(spawner_rd.clone()); + let def = spawn_pod_tool_with_runtime_command( + "root".into(), + spawner_socket, + runtime_base, + allow_root.path().to_path_buf(), + registry, + None, + manifest, + shared_scope_for(allow_root.path()), + builtin_prompts(), + mock_runtime_command(), + ); + let (_meta, tool) = def(); + + let input = json!({ + "name": "child-nonrecursive-overgrant", + "task": "hello", + "profile": "inherit", + "scope": [{ + "target": child.to_str().unwrap(), + "permission": "write", + "recursive": false + }] + }) + .to_string(); + + let err = tool.execute(&input).await.unwrap_err(); + match err { + ToolError::InvalidArgument(message) => { + assert!( + message.contains("outside this Pod's delegation scope grant"), + "{message}" + ); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + + clear_env(); +} + #[tokio::test] async fn spawn_pod_rejects_scope_outside_spawner() { let _env = EnvGuard::acquire(); @@ -346,8 +483,8 @@ async fn spawn_pod_rejects_scope_outside_spawner() { match err { ToolError::InvalidArgument(msg) => { assert!( - msg.contains("not within"), - "expected NotSubset wording: {msg}" + msg.contains("outside this Pod's delegation scope grant"), + "expected delegation-scope wording: {msg}" ); } other => panic!("expected InvalidArgument, got {other:?}"),