feat: パターンベースのツール権限制御を追加

This commit is contained in:
2026-05-09 03:20:02 +09:00
parent 2df9de73c7
commit 60144c550a
15 changed files with 566 additions and 33 deletions
+123 -1
View File
@@ -16,7 +16,7 @@ use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig,
ToolOutputLimits, WorkerManifest,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@@ -36,6 +36,10 @@ pub struct PodManifestConfig {
pub worker: WorkerManifestConfig,
#[serde(default)]
pub scope: ScopeConfig,
/// Optional `[permissions]` section. `None` means the permission layer
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
@@ -87,6 +91,14 @@ pub struct ToolOutputLimitsPartial {
pub per_tool: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionConfigPartial {
#[serde(default)]
pub default_action: Option<crate::ToolPermissionAction>,
#[serde(default, rename = "rule")]
pub rules: Vec<ToolPermissionRule>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
#[serde(default)]
@@ -204,6 +216,11 @@ impl PodManifestConfig {
model: self.model.merge(upper.model),
worker: self.worker.merge(upper.worker),
scope: merge_scope(self.scope, upper.scope),
permissions: merge_option(
self.permissions,
upper.permissions,
PermissionConfigPartial::merge,
),
compaction: merge_option(
self.compaction,
upper.compaction,
@@ -280,6 +297,16 @@ impl ToolOutputLimitsPartial {
}
}
impl PermissionConfigPartial {
fn merge(mut self, upper: Self) -> Self {
self.rules.extend(upper.rules);
Self {
default_action: upper.default_action.or(self.default_action),
rules: self.rules,
}
}
}
impl CompactionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
@@ -400,6 +427,18 @@ impl TryFrom<PodManifestConfig> for PodManifest {
ensure_absolute("scope.deny.target", &rule.target)?;
}
let permissions = cfg
.permissions
.map(|p| {
Ok(ToolPermissionConfig {
default_action: p
.default_action
.ok_or(ResolveError::MissingField("permissions.default_action"))?,
rules: p.rules,
})
})
.transpose()?;
let compaction = cfg
.compaction
.map(|c| -> Result<CompactionConfig, ResolveError> {
@@ -438,6 +477,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
model: cfg.model,
worker,
scope: cfg.scope,
permissions,
compaction,
memory: cfg.memory,
skills: cfg.skills,
@@ -482,6 +522,7 @@ mod tests {
}],
deny: Vec::new(),
},
permissions: None,
compaction: None,
memory: None,
skills: None,
@@ -493,6 +534,51 @@ mod tests {
let manifest: PodManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.pod.name, "test");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert!(manifest.permissions.is_none());
}
#[test]
fn resolve_permissions_requires_default_action_when_present() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: None,
rules: Vec::new(),
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::MissingField("permissions.default_action")
));
}
#[test]
fn resolve_permissions_preserves_actions_and_rule_order() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Ask),
rules: vec![
ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
},
ToolPermissionRule {
tool: "Read".into(),
pattern: "*".into(),
action: crate::ToolPermissionAction::Allow,
},
],
});
let manifest: PodManifest = cfg.try_into().unwrap();
let permissions = manifest.permissions.unwrap();
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
assert_eq!(permissions.rules.len(), 2);
assert_eq!(permissions.rules[0].tool, "Bash");
assert_eq!(permissions.rules[1].tool, "Read");
}
#[test]
@@ -694,6 +780,42 @@ mod tests {
assert_eq!(merged.scope.deny.len(), 1);
}
#[test]
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
let lower = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Allow),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "git *".into(),
action: crate::ToolPermissionAction::Allow,
}],
}),
..Default::default()
};
let upper = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Deny),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
}],
}),
..Default::default()
};
let merged = lower.merge(upper).permissions.unwrap();
assert_eq!(
merged.default_action,
Some(crate::ToolPermissionAction::Deny)
);
assert_eq!(merged.rules.len(), 2);
assert_eq!(merged.rules[0].pattern, "git *");
assert_eq!(merged.rules[1].pattern, "rm *");
}
#[test]
fn merge_tool_output_per_tool_keywise() {
let lower = PodManifestConfig {
+38 -2
View File
@@ -7,8 +7,8 @@ mod scope;
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
pub use config::{
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError,
ToolOutputLimitsPartial, WorkerManifestConfig,
CompactionConfigPartial, PermissionConfigPartial, PodManifestConfig, PodMetaConfig,
ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig,
};
pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
@@ -35,6 +35,10 @@ pub struct PodManifest {
pub model: ModelManifest,
pub worker: WorkerManifest,
pub scope: ScopeConfig,
/// Optional manifest-level tool permission policy. Absent means the
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem opt-in. Presence of `[memory]` in TOML enables
@@ -239,6 +243,38 @@ pub struct ScopeConfig {
pub deny: Vec<ScopeRule>,
}
/// Manifest-level pattern-based tool permission policy.
///
/// Presence of `[permissions]` enables this layer. Rules are evaluated
/// in declaration order; if none match, [`Self::default_action`] is used.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolPermissionConfig {
pub default_action: ToolPermissionAction,
#[serde(default, rename = "rule")]
pub rules: Vec<ToolPermissionRule>,
}
/// One `[[permissions.rule]]` entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolPermissionRule {
/// Tool registration name. Matching is case-insensitive at runtime so
/// manifests may use either `Bash` or `bash`.
pub tool: String,
/// Glob-like pattern matched against the tool's permission target
/// (for built-in tools, commonly `command`, `file_path`, or `pattern`).
pub pattern: String,
pub action: ToolPermissionAction,
}
/// Tool permission decision.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionAction {
Allow,
Deny,
Ask,
}
/// Context compaction configuration.
///
/// Controls Prune (content removal from old tool results) and Compact