feat: split direct and delegation scope authority
This commit is contained in:
@@ -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<SessionConfigPartial>,
|
||||
/// 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<PodManifestConfig> 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<PodManifestConfig> 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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ProfileScopeConfig>,
|
||||
#[serde(default)]
|
||||
delegation_scope: Option<ProfileScopeConfig>,
|
||||
#[serde(default)]
|
||||
session: Option<SessionConfigPartial>,
|
||||
#[serde(default)]
|
||||
permissions: Option<PermissionConfigPartial>,
|
||||
@@ -1133,12 +1139,34 @@ fn reject_absolute_auth_file(
|
||||
fn profile_scope_to_config(
|
||||
scope: Option<ProfileScopeConfig>,
|
||||
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 {
|
||||
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() {
|
||||
|
||||
@@ -32,6 +32,104 @@ 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<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 {
|
||||
if !permission_covers(available.permission, requested.permission) {
|
||||
return false;
|
||||
}
|
||||
if available.recursive {
|
||||
return requested.target.starts_with(&available.target);
|
||||
}
|
||||
!requested.recursive
|
||||
&& (requested.target == available.target
|
||||
|| direct_child(&requested.target, &available.target))
|
||||
}
|
||||
|
||||
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||
if !permission_denies_requested(deny.permission, requested.permission) {
|
||||
return false;
|
||||
}
|
||||
match (deny.recursive, requested.recursive) {
|
||||
(true, true) => {
|
||||
deny.target.starts_with(&requested.target) || requested.target.starts_with(&deny.target)
|
||||
}
|
||||
(true, false) => requested.target.starts_with(&deny.target),
|
||||
(false, true) => deny.target.starts_with(&requested.target),
|
||||
(false, false) => {
|
||||
deny.target == requested.target
|
||||
|| direct_child(&deny.target, &requested.target)
|
||||
|| direct_child(&requested.target, &deny.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 {
|
||||
|
||||
Reference in New Issue
Block a user