merge: split direct and delegation authority
This commit is contained in:
commit
92d1c0bb10
|
|
@ -8,6 +8,7 @@ return function(opts)
|
||||||
description = opts.description,
|
description = opts.description,
|
||||||
|
|
||||||
scope = opts.scope or scope.workspace_read(),
|
scope = opts.scope or scope.workspace_read(),
|
||||||
|
delegation_scope = opts.delegation_scope,
|
||||||
|
|
||||||
session = {
|
session = {
|
||||||
record_event_trace = true,
|
record_event_trace = true,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
local base = require("_base")
|
local base = require("_base")
|
||||||
|
local scope = require("yoi.scope")
|
||||||
|
|
||||||
return base {
|
return base {
|
||||||
slug = "orchestrator",
|
slug = "orchestrator",
|
||||||
description = "Orchestrator role profile: GPT-5.5 with bundled default behavior",
|
description = "Orchestrator role profile: GPT-5.5 with bundled default behavior",
|
||||||
|
delegation_scope = scope.workspace_write(),
|
||||||
model_ref = "codex-oauth/gpt-5.5",
|
model_ref = "codex-oauth/gpt-5.5",
|
||||||
language = "Japanese",
|
language = "Japanese",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ pub struct PodManifestConfig {
|
||||||
pub worker: WorkerManifestConfig,
|
pub worker: WorkerManifestConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope: ScopeConfig,
|
pub scope: ScopeConfig,
|
||||||
|
/// Scope that may be subdelegated to spawned child Pods. Defaults empty.
|
||||||
|
#[serde(default)]
|
||||||
|
pub delegation_scope: ScopeConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub session: Option<SessionConfigPartial>,
|
pub session: Option<SessionConfigPartial>,
|
||||||
/// Optional `[permissions]` section. `None` means the permission layer
|
/// Optional `[permissions]` section. `None` means the permission layer
|
||||||
|
|
@ -243,6 +246,7 @@ impl PodManifestConfig {
|
||||||
///
|
///
|
||||||
/// Affected fields: `model.auth.file`,
|
/// Affected fields: `model.auth.file`,
|
||||||
/// `scope.allow[].target`, `scope.deny[].target`,
|
/// `scope.allow[].target`, `scope.deny[].target`,
|
||||||
|
/// `delegation_scope.allow[].target`, `delegation_scope.deny[].target`,
|
||||||
/// `compaction.model.auth.file`.
|
/// `compaction.model.auth.file`.
|
||||||
pub fn resolve_paths(mut self, base: &Path) -> Self {
|
pub fn resolve_paths(mut self, base: &Path) -> Self {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
|
|
@ -260,6 +264,12 @@ impl PodManifestConfig {
|
||||||
for rule in &mut self.scope.deny {
|
for rule in &mut self.scope.deny {
|
||||||
rule.target = join_if_relative(base, &rule.target);
|
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
|
if let Some(ref mut memory) = self.memory
|
||||||
&& let Some(ref mut root) = memory.workspace_root
|
&& let Some(ref mut root) = memory.workspace_root
|
||||||
{
|
{
|
||||||
|
|
@ -288,6 +298,7 @@ impl PodManifestConfig {
|
||||||
model: self.model.merge(upper.model),
|
model: self.model.merge(upper.model),
|
||||||
worker: self.worker.merge(upper.worker),
|
worker: self.worker.merge(upper.worker),
|
||||||
scope: merge_scope(self.scope, upper.scope),
|
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),
|
session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
|
||||||
permissions: merge_option(
|
permissions: merge_option(
|
||||||
self.permissions,
|
self.permissions,
|
||||||
|
|
@ -588,6 +599,12 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
for rule in &cfg.scope.deny {
|
for rule in &cfg.scope.deny {
|
||||||
ensure_absolute("scope.deny.target", &rule.target)?;
|
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 {
|
let session = SessionConfig {
|
||||||
record_event_trace: cfg
|
record_event_trace: cfg
|
||||||
.session
|
.session
|
||||||
|
|
@ -670,6 +687,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
worker,
|
worker,
|
||||||
scope: cfg.scope,
|
scope: cfg.scope,
|
||||||
|
delegation_scope: cfg.delegation_scope,
|
||||||
session,
|
session,
|
||||||
permissions,
|
permissions,
|
||||||
compaction,
|
compaction,
|
||||||
|
|
@ -715,6 +733,7 @@ mod tests {
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
|
delegation_scope: ScopeConfig::default(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pub use profile::{
|
||||||
resolve_profile_artifact,
|
resolve_profile_artifact,
|
||||||
};
|
};
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
|
|
@ -40,7 +40,12 @@ pub struct PodManifest {
|
||||||
pub pod: PodMeta,
|
pub pod: PodMeta,
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
pub worker: WorkerManifest,
|
pub worker: WorkerManifest,
|
||||||
|
/// Direct filesystem authority for this Pod's own tools.
|
||||||
pub scope: ScopeConfig,
|
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.
|
/// Session/debug persistence settings. Defaults keep extra traces off.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub session: SessionConfig,
|
pub session: SessionConfig,
|
||||||
|
|
@ -644,6 +649,8 @@ permission = "write"
|
||||||
assert!(manifest.model.auth.is_none());
|
assert!(manifest.model.auth.is_none());
|
||||||
assert_eq!(manifest.scope.allow.len(), 1);
|
assert_eq!(manifest.scope.allow.len(), 1);
|
||||||
assert!(manifest.scope.deny.is_empty());
|
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_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
|
||||||
assert!(manifest.worker.top_p.is_none());
|
assert!(manifest.worker.top_p.is_none());
|
||||||
assert!(manifest.worker.top_k.is_none());
|
assert!(manifest.worker.top_k.is_none());
|
||||||
|
|
@ -651,6 +658,17 @@ permission = "write"
|
||||||
assert!(manifest.web.is_none());
|
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]
|
#[test]
|
||||||
fn parse_web_config() {
|
fn parse_web_config() {
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
|
|
@ -702,6 +720,14 @@ recursive = false
|
||||||
[[scope.deny]]
|
[[scope.deny]]
|
||||||
target = "/abs/project/secrets.rs"
|
target = "/abs/project/secrets.rs"
|
||||||
permission = "write"
|
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();
|
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||||
|
|
@ -728,6 +754,12 @@ permission = "write"
|
||||||
assert!(!allow[1].recursive);
|
assert!(!allow[1].recursive);
|
||||||
assert_eq!(manifest.scope.deny.len(), 1);
|
assert_eq!(manifest.scope.deny.len(), 1);
|
||||||
assert_eq!(manifest.scope.deny[0].permission, Permission::Write);
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -565,6 +565,10 @@ fn resolve_lua_profile_value(
|
||||||
model: profile.model.unwrap_or_default(),
|
model: profile.model.unwrap_or_default(),
|
||||||
worker: profile.worker.unwrap_or_default(),
|
worker: profile.worker.unwrap_or_default(),
|
||||||
scope: profile_scope_to_config(profile.scope, workspace_base),
|
scope: profile_scope_to_config(profile.scope, workspace_base),
|
||||||
|
delegation_scope: profile_delegation_scope_to_config(
|
||||||
|
profile.delegation_scope,
|
||||||
|
workspace_base,
|
||||||
|
),
|
||||||
session: profile.session,
|
session: profile.session,
|
||||||
permissions: profile.permissions,
|
permissions: profile.permissions,
|
||||||
compaction,
|
compaction,
|
||||||
|
|
@ -620,6 +624,8 @@ struct ProfileConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
scope: Option<ProfileScopeConfig>,
|
scope: Option<ProfileScopeConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
delegation_scope: Option<ProfileScopeConfig>,
|
||||||
|
#[serde(default)]
|
||||||
session: Option<SessionConfigPartial>,
|
session: Option<SessionConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
permissions: Option<PermissionConfigPartial>,
|
permissions: Option<PermissionConfigPartial>,
|
||||||
|
|
@ -1133,12 +1139,34 @@ fn reject_absolute_auth_file(
|
||||||
fn profile_scope_to_config(
|
fn profile_scope_to_config(
|
||||||
scope: Option<ProfileScopeConfig>,
|
scope: Option<ProfileScopeConfig>,
|
||||||
workspace_base: &Path,
|
workspace_base: &Path,
|
||||||
|
) -> ScopeConfig {
|
||||||
|
profile_scope_intent_to_config(
|
||||||
|
scope,
|
||||||
|
workspace_base,
|
||||||
|
Some(ProfileScopeIntent::WorkspaceWrite),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_delegation_scope_to_config(
|
||||||
|
scope: Option<ProfileScopeConfig>,
|
||||||
|
workspace_base: &Path,
|
||||||
|
) -> ScopeConfig {
|
||||||
|
profile_scope_intent_to_config(scope, workspace_base, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_scope_intent_to_config(
|
||||||
|
scope: Option<ProfileScopeConfig>,
|
||||||
|
workspace_base: &Path,
|
||||||
|
default_intent: Option<ProfileScopeIntent>,
|
||||||
) -> ScopeConfig {
|
) -> ScopeConfig {
|
||||||
let intent = match scope {
|
let intent = match scope {
|
||||||
Some(ProfileScopeConfig::Intent { intent }) | Some(ProfileScopeConfig::String(intent)) => {
|
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 {
|
let permission = match intent {
|
||||||
ProfileScopeIntent::WorkspaceRead => Permission::Read,
|
ProfileScopeIntent::WorkspaceRead => Permission::Read,
|
||||||
|
|
@ -1419,6 +1447,7 @@ return profile {
|
||||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
||||||
|
assert!(resolved.manifest.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.scope.allow[0].permission,
|
resolved.manifest.scope.allow[0].permission,
|
||||||
Permission::Read
|
Permission::Read
|
||||||
|
|
@ -1446,6 +1475,7 @@ return yoi.profile {
|
||||||
slug = "main",
|
slug = "main",
|
||||||
model = shared.model,
|
model = shared.model,
|
||||||
scope = yoi.scope.workspace_write(),
|
scope = yoi.scope.workspace_write(),
|
||||||
|
delegation_scope = yoi.scope.workspace_write(),
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
@ -1464,6 +1494,14 @@ return yoi.profile {
|
||||||
resolved.manifest.scope.allow[0].permission,
|
resolved.manifest.scope.allow[0].permission,
|
||||||
Permission::Write
|
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]
|
#[test]
|
||||||
fn sandbox_denies_unsafe_libraries() {
|
fn sandbox_denies_unsafe_libraries() {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,127 @@ struct ResolvedRule {
|
||||||
recursive: bool,
|
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<ResolvedRule>,
|
||||||
|
deny: Vec<ResolvedRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DelegationScope {
|
||||||
|
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||||
|
let allow = config
|
||||||
|
.allow
|
||||||
|
.iter()
|
||||||
|
.map(resolve_rule)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let deny = config
|
||||||
|
.deny
|
||||||
|
.iter()
|
||||||
|
.map(resolve_rule)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(Self { allow, deny })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.allow.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_rule(&self, requested: &ScopeRule) -> Result<bool, ScopeError> {
|
||||||
|
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`].
|
/// Errors raised when constructing a [`Scope`] from a [`ScopeConfig`].
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ScopeError {
|
pub enum ScopeError {
|
||||||
|
|
@ -419,14 +540,18 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn allow_rule(target: &Path, permission: Permission) -> ScopeRule {
|
fn rule(target: &Path, permission: Permission, recursive: bool) -> ScopeRule {
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allow_rule(target: &Path, permission: Permission) -> ScopeRule {
|
||||||
|
rule(target, permission, true)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn writable_shortcut_permits_root() {
|
fn writable_shortcut_permits_root() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
@ -542,6 +667,80 @@ mod tests {
|
||||||
assert!(!scope.is_writable(&nested.join("deep.txt")));
|
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]
|
#[test]
|
||||||
fn empty_allow_rejected() {
|
fn empty_allow_rejected() {
|
||||||
let cfg = ScopeConfig {
|
let cfg = ScopeConfig {
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ use tracing::{info, warn};
|
||||||
use crate::segment_log_sink::SegmentLogSink;
|
use crate::segment_log_sink::SegmentLogSink;
|
||||||
|
|
||||||
use manifest::{
|
use manifest::{
|
||||||
Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError,
|
DelegationScope, Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig,
|
||||||
ScopeRule, SharedScope, WorkerManifest,
|
ScopeError, ScopeRule, SharedScope, WorkerManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
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
|
/// compact worker) so scope updates propagate to every consumer
|
||||||
/// at the next permission check.
|
/// at the next permission check.
|
||||||
scope: SharedScope,
|
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,
|
hook_builder: HookRegistryBuilder,
|
||||||
interceptor_installed: bool,
|
interceptor_installed: bool,
|
||||||
/// Shared compaction state (present when threshold is configured).
|
/// 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(),
|
segment_state: self.segment_state.clone(),
|
||||||
pwd: self.pwd.clone(),
|
pwd: self.pwd.clone(),
|
||||||
scope: self.scope.clone(),
|
scope: self.scope.clone(),
|
||||||
|
delegation_scope: self.delegation_scope.clone(),
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -585,6 +589,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let segment_id = session_store::new_segment_id();
|
let segment_id = session_store::new_segment_id();
|
||||||
let prompts = PromptCatalog::builtins_only()?;
|
let prompts = PromptCatalog::builtins_only()?;
|
||||||
|
let delegation_scope =
|
||||||
|
DelegationScope::from_config(&manifest.delegation_scope).map_err(PodError::Scope)?;
|
||||||
let mut pod = Self {
|
let mut pod = Self {
|
||||||
manifest,
|
manifest,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
|
|
@ -593,6 +599,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||||
pwd,
|
pwd,
|
||||||
scope: SharedScope::new(scope),
|
scope: SharedScope::new(scope),
|
||||||
|
delegation_scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -3724,6 +3731,7 @@ where
|
||||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: SharedScope::new(common.scope),
|
scope: SharedScope::new(common.scope),
|
||||||
|
delegation_scope: common.delegation_scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -3802,6 +3810,7 @@ where
|
||||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: SharedScope::new(common.scope),
|
scope: SharedScope::new(common.scope),
|
||||||
|
delegation_scope: common.delegation_scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -3979,6 +3988,7 @@ where
|
||||||
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
|
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
|
||||||
pwd: common.pwd,
|
pwd: common.pwd,
|
||||||
scope: SharedScope::new(common.scope),
|
scope: SharedScope::new(common.scope),
|
||||||
|
delegation_scope: common.delegation_scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
|
|
@ -4606,6 +4616,7 @@ pub enum PodError {
|
||||||
struct PodCommon {
|
struct PodCommon {
|
||||||
pwd: PathBuf,
|
pwd: PathBuf,
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
|
delegation_scope: DelegationScope,
|
||||||
client: Box<dyn LlmClient>,
|
client: Box<dyn LlmClient>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
workflow_registry: workflow_crate::WorkflowRegistry,
|
workflow_registry: workflow_crate::WorkflowRegistry,
|
||||||
|
|
@ -4719,6 +4730,8 @@ fn prepare_pod_common_from_scope(
|
||||||
if !scope.is_readable(&pwd) {
|
if !scope.is_readable(&pwd) {
|
||||||
return Err(PodError::PwdOutsideScope { 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 client = provider::build_client(&manifest.model)?;
|
||||||
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
|
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
|
||||||
|
|
@ -4744,6 +4757,7 @@ fn prepare_pod_common_from_scope(
|
||||||
Ok(PodCommon {
|
Ok(PodCommon {
|
||||||
pwd,
|
pwd,
|
||||||
scope,
|
scope,
|
||||||
|
delegation_scope,
|
||||||
client,
|
client,
|
||||||
prompts,
|
prompts,
|
||||||
workflow_registry,
|
workflow_registry,
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ use async_trait::async_trait;
|
||||||
use client::PodRuntimeCommand;
|
use client::PodRuntimeCommand;
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial,
|
CompactionConfigPartial, DelegationScope, FileUploadLimitsPartial, Permission,
|
||||||
PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry,
|
PermissionConfigPartial, PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery,
|
||||||
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
ProfileError, ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||||
ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig,
|
ProfileSelector, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope,
|
||||||
|
ToolOutputLimitsPartial, WorkerManifestConfig,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
@ -54,7 +55,8 @@ struct SpawnPodInput {
|
||||||
/// First message sent to the spawned Pod via `Method::Run`.
|
/// First message sent to the spawned Pod via `Method::Run`.
|
||||||
task: String,
|
task: String,
|
||||||
/// Allow rules delegated to the spawned Pod. Must be a subset of the
|
/// 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>,
|
scope: Vec<ScopeRuleInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,6 +250,10 @@ pub struct SpawnPodTool {
|
||||||
/// `effective_write` semantics: Write is the only permission
|
/// `effective_write` semantics: Write is the only permission
|
||||||
/// tracked across Pods, so revocation only touches Write.
|
/// tracked across Pods, so revocation only touches Write.
|
||||||
spawner_scope: SharedScope,
|
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 {
|
impl SpawnPodTool {
|
||||||
|
|
@ -261,6 +267,7 @@ impl SpawnPodTool {
|
||||||
spawner_manifest: PodManifest,
|
spawner_manifest: PodManifest,
|
||||||
available_profiles: AvailableProfiles,
|
available_profiles: AvailableProfiles,
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
|
delegation_scope: DelegationScope,
|
||||||
runtime_command: Option<PodRuntimeCommand>,
|
runtime_command: Option<PodRuntimeCommand>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -274,6 +281,7 @@ impl SpawnPodTool {
|
||||||
spawner_manifest,
|
spawner_manifest,
|
||||||
available_profiles,
|
available_profiles,
|
||||||
spawner_scope,
|
spawner_scope,
|
||||||
|
delegation_scope,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -295,6 +303,7 @@ impl Tool for SpawnPodTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
let scope_allow = parse_scope(&input.scope)?;
|
let scope_allow = parse_scope(&input.scope)?;
|
||||||
|
self.validate_delegation_scope(&scope_allow)?;
|
||||||
|
|
||||||
let spawn_selector =
|
let spawn_selector =
|
||||||
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
|
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) {
|
fn release_reservation(&self, lock_path: &Path, pod_name: &str) {
|
||||||
if let Ok(mut g) = LockFileGuard::open(lock_path) {
|
if let Ok(mut g) = LockFileGuard::open(lock_path) {
|
||||||
let _ = pod_registry::release_pod(&mut g, pod_name);
|
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(),
|
allow: manifest.scope.allow.clone(),
|
||||||
deny: manifest.scope.deny.clone(),
|
deny: manifest.scope.deny.clone(),
|
||||||
},
|
},
|
||||||
|
// `inherit` reuses behavioral configuration, not subdelegation authority.
|
||||||
|
delegation_scope: ScopeConfig::default(),
|
||||||
session: Some(SessionConfigPartial {
|
session: Some(SessionConfigPartial {
|
||||||
record_event_trace: Some(manifest.session.record_event_trace),
|
record_event_trace: Some(manifest.session.record_event_trace),
|
||||||
}),
|
}),
|
||||||
|
|
@ -857,6 +890,8 @@ fn spawn_pod_tool_impl(
|
||||||
spawner_manifest.clone(),
|
spawner_manifest.clone(),
|
||||||
available_profiles,
|
available_profiles,
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
|
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
||||||
|
.expect("resolved Pod manifest has a valid delegation scope"),
|
||||||
runtime_command.clone(),
|
runtime_command.clone(),
|
||||||
));
|
));
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
|
|
|
||||||
|
|
@ -175,20 +175,38 @@ fn dummy_model() -> ModelManifest {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dummy_manifest(allow_root: &Path) -> PodManifest {
|
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 {
|
PodManifestConfig {
|
||||||
pod: PodMetaConfig {
|
pod: PodMetaConfig {
|
||||||
name: Some("root".into()),
|
name: Some("root".into()),
|
||||||
prompt_pack: None,
|
prompt_pack: None,
|
||||||
},
|
},
|
||||||
model: dummy_model(),
|
model: dummy_model(),
|
||||||
scope: ScopeConfig {
|
scope: direct_scope,
|
||||||
allow: vec![ScopeRule {
|
delegation_scope,
|
||||||
target: allow_root.to_path_buf(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
deny: Vec::new(),
|
|
||||||
},
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|
@ -305,6 +323,125 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
clear_env();
|
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]
|
#[tokio::test]
|
||||||
async fn spawn_pod_rejects_scope_outside_spawner() {
|
async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
let _env = EnvGuard::acquire();
|
let _env = EnvGuard::acquire();
|
||||||
|
|
@ -346,8 +483,8 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
match err {
|
match err {
|
||||||
ToolError::InvalidArgument(msg) => {
|
ToolError::InvalidArgument(msg) => {
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("not within"),
|
msg.contains("outside this Pod's delegation scope grant"),
|
||||||
"expected NotSubset wording: {msg}"
|
"expected delegation-scope wording: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => panic!("expected InvalidArgument, got {other:?}"),
|
other => panic!("expected InvalidArgument, got {other:?}"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user