feat: split direct and delegation scope authority

This commit is contained in:
2026-06-08 15:22:39 +09:00
parent fa39f921d5
commit a4a9b002c6
9 changed files with 320 additions and 20 deletions
+16 -2
View File
@@ -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<C: LlmClient, St: Store> {
/// 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<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
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<C: LlmClient, St: Store> Pod<C, St> {
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<C: LlmClient, St: Store> Pod<C, St> {
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<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
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,
+40 -5
View File
@@ -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<ScopeRuleInput>,
}
@@ -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<PodRuntimeCommand>,
) -> 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)
+71 -10
View File
@@ -175,20 +175,31 @@ 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()
};
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 +316,56 @@ 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_scope_outside_spawner() {
let _env = EnvGuard::acquire();
@@ -346,8 +407,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:?}"),