feat: パターンベースのツール権限制御を追加
This commit is contained in:
parent
aa9409869e
commit
805be47128
|
|
@ -52,6 +52,11 @@ pub enum PreToolAction {
|
|||
Continue,
|
||||
/// Skip this tool call (do not execute).
|
||||
Skip,
|
||||
/// Do not execute the tool call; commit this synthetic result instead.
|
||||
///
|
||||
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||
/// without aborting the whole turn.
|
||||
SyntheticResult(ToolResult),
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
/// Pause execution (can be resumed later).
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ use crate::llm_client::{
|
|||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// Anthropic API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
|
|
@ -104,6 +108,8 @@ pub(crate) enum AnthropicContentPart {
|
|||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
|
|
@ -141,10 +147,11 @@ impl AnthropicContentPart {
|
|||
}
|
||||
}
|
||||
|
||||
fn tool_result(tool_use_id: String, content: String) -> Self {
|
||||
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
|
||||
Self::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -321,6 +328,7 @@ impl AnthropicScheme {
|
|||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
flush_pending(
|
||||
|
|
@ -333,8 +341,10 @@ impl AnthropicScheme {
|
|||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user
|
||||
.push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
|
||||
pending_user.push((
|
||||
i,
|
||||
AnthropicContentPart::tool_result(call_id.clone(), text, *is_error),
|
||||
));
|
||||
}
|
||||
|
||||
Item::Reasoning {
|
||||
|
|
@ -355,13 +365,10 @@ impl AnthropicScheme {
|
|||
// 素の reasoning text。Anthropic に投げる意味も
|
||||
// round-trip の根拠も無いので drop。
|
||||
if let Some(sig) = signature.clone() {
|
||||
pending_assistant.push((
|
||||
i,
|
||||
AnthropicContentPart::thinking(text.clone(), sig),
|
||||
));
|
||||
} else if let Some(data) = encrypted_content.clone() {
|
||||
pending_assistant
|
||||
.push((i, AnthropicContentPart::redacted_thinking(data)));
|
||||
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
|
||||
} else if let Some(data) = encrypted_content.clone() {
|
||||
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
|
||||
}
|
||||
// どちらも None なら何も pend せず、本 item は無視。
|
||||
}
|
||||
|
|
@ -828,7 +835,9 @@ mod tests {
|
|||
assert_eq!(thinking_parts.len(), 1);
|
||||
match thinking_parts[0] {
|
||||
AnthropicContentPart::Thinking {
|
||||
thinking, signature, ..
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "step-by-step");
|
||||
assert_eq!(signature, "SIG-A");
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item - The core unit of conversation
|
||||
// ============================================================================
|
||||
|
|
@ -79,6 +83,9 @@ pub enum Item {
|
|||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
|
|
@ -198,11 +205,27 @@ impl Item {
|
|||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
}
|
||||
|
||||
/// Create an error tool result item with summary only (no content).
|
||||
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, true)
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary, optional content, and error flag.
|
||||
pub fn tool_result_item(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content: None,
|
||||
content,
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,12 +235,7 @@ impl Item {
|
|||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content: Some(content.into()),
|
||||
}
|
||||
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ pub struct ToolCall {
|
|||
///
|
||||
/// Intermediate representation between tool execution and history.
|
||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
|
|
|
|||
|
|
@ -742,8 +742,9 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
// Map from tool call ID to (ToolCall, Meta, Tool)
|
||||
// Retained because it's needed for PostToolCall hooks
|
||||
let mut call_info_map = HashMap::new();
|
||||
let mut synthetic_results = Vec::new();
|
||||
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort)
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for mut tool_call in tool_calls {
|
||||
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
||||
|
|
@ -758,6 +759,15 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
PreToolAction::Skip => {
|
||||
continue;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
let tool_call = info.call;
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call, info.meta.clone(), info.tool.clone()),
|
||||
);
|
||||
synthetic_results.push(result);
|
||||
continue;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(WorkerError::Aborted(reason));
|
||||
|
|
@ -809,6 +819,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
return Err(WorkerError::Cancelled);
|
||||
}
|
||||
};
|
||||
results.extend(synthetic_results);
|
||||
|
||||
// Phase 3: Apply post_tool_call interceptor
|
||||
for tool_result in &mut results {
|
||||
|
|
@ -1124,16 +1135,12 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
}
|
||||
Ok(ToolExecutionResult::Completed(results)) => {
|
||||
for result in results {
|
||||
if let Some(ref content) = result.content {
|
||||
self.history.push(Item::tool_result_with_content(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
content,
|
||||
));
|
||||
} else {
|
||||
self.history
|
||||
.push(Item::tool_result(&result.tool_use_id, &result.summary));
|
||||
}
|
||||
self.history.push(Item::tool_result_item(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
result.content,
|
||||
result.is_error,
|
||||
));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use llm_worker::interceptor::{
|
|||
Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
||||
};
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||
|
||||
mod common;
|
||||
use common::MockLlmClient;
|
||||
|
|
@ -268,3 +268,59 @@ async fn test_post_tool_call_modification() {
|
|||
"Result should be modified"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
||||
#[tokio::test]
|
||||
async fn test_before_tool_call_synthetic_result_committed() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "blocked_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
events,
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Denied."),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut worker = Worker::new(client);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
worker.register_tool(blocked_tool.definition());
|
||||
|
||||
struct SyntheticPolicy;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for SyntheticPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::SyntheticResult(ToolResult::error(
|
||||
info.call.id.clone(),
|
||||
"permission denied",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
worker.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let result = worker.run("Test synthetic result").await.unwrap();
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(result.worker.history().iter().any(|item| matches!(
|
||||
item,
|
||||
llm_worker::Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
is_error: true,
|
||||
..
|
||||
} if call_id == "call_1" && summary == "permission denied"
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod workflow;
|
|||
|
||||
mod factory;
|
||||
mod interrupt_and_run;
|
||||
mod permission;
|
||||
mod pod;
|
||||
|
||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
|
|
|
|||
189
crates/pod/src/permission.rs
Normal file
189
crates/pod/src/permission.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use async_trait::async_trait;
|
||||
use llm_worker::interceptor::PreToolAction;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::tool::ToolResult;
|
||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::Value;
|
||||
use session_store::Store;
|
||||
|
||||
use crate::Pod;
|
||||
use crate::hook::{Hook, PreToolCall, ToolCallSummary};
|
||||
|
||||
/// Built-in manifest permission policy for `PreToolCall`.
|
||||
///
|
||||
/// This hook is registered by Pod before user hooks, so manifest-level deny
|
||||
/// rules fail closed before user extension code can approve a call.
|
||||
pub(crate) struct PermissionHook {
|
||||
config: ToolPermissionConfig,
|
||||
}
|
||||
|
||||
impl PermissionHook {
|
||||
pub(crate) fn new(config: ToolPermissionConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn action_for(&self, input: &ToolCallSummary) -> ToolPermissionAction {
|
||||
let target = permission_target(&input.arguments);
|
||||
self.config
|
||||
.rules
|
||||
.iter()
|
||||
.find(|rule| {
|
||||
rule.tool.eq_ignore_ascii_case(&input.tool_name)
|
||||
&& wildcard_match(&rule.pattern, &target)
|
||||
})
|
||||
.map(|rule| rule.action)
|
||||
.unwrap_or(self.config.default_action)
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
pub(crate) fn apply_permissions_from_manifest(&mut self) {
|
||||
let Some(permissions) = self.manifest().permissions.clone() else {
|
||||
return;
|
||||
};
|
||||
self.add_pre_tool_call_hook(PermissionHook::new(permissions));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreToolCall> for PermissionHook {
|
||||
async fn call(&self, input: &ToolCallSummary) -> PreToolAction {
|
||||
match self.action_for(input) {
|
||||
ToolPermissionAction::Allow => PreToolAction::Continue,
|
||||
ToolPermissionAction::Deny => PreToolAction::SyntheticResult(permission_denied(input)),
|
||||
ToolPermissionAction::Ask => {
|
||||
PreToolAction::SyntheticResult(permission_ask_unsupported(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_denied(input: &ToolCallSummary) -> ToolResult {
|
||||
ToolResult::error(
|
||||
input.call_id.clone(),
|
||||
format!(
|
||||
"permission denied: tool `{}` arguments matched the manifest permission policy",
|
||||
input.tool_name
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
|
||||
ToolResult::error(
|
||||
input.call_id.clone(),
|
||||
format!(
|
||||
"permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed",
|
||||
input.tool_name
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn permission_target(arguments: &Value) -> String {
|
||||
if let Value::Object(map) = arguments {
|
||||
for key in ["command", "file_path", "path", "pattern"] {
|
||||
if let Some(value) = map.get(key).and_then(Value::as_str) {
|
||||
return value.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::to_string(arguments).unwrap_or_else(|_| arguments.to_string())
|
||||
}
|
||||
|
||||
fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
let pattern = pattern.as_bytes();
|
||||
let text = text.as_bytes();
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<usize> = None;
|
||||
let mut star_text = 0usize;
|
||||
|
||||
while ti < text.len() {
|
||||
if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < pattern.len() && pattern[pi] == b'*' {
|
||||
star = Some(pi);
|
||||
pi += 1;
|
||||
star_text = ti;
|
||||
} else if let Some(star_pi) = star {
|
||||
pi = star_pi + 1;
|
||||
star_text += 1;
|
||||
ti = star_text;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while pi < pattern.len() && pattern[pi] == b'*' {
|
||||
pi += 1;
|
||||
}
|
||||
|
||||
pi == pattern.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::ToolPermissionRule;
|
||||
|
||||
fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary {
|
||||
ToolCallSummary {
|
||||
call_id: "call_1".into(),
|
||||
tool_name: tool_name.into(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_matching_rule_wins_by_declaration_order() {
|
||||
let hook = PermissionHook::new(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: vec![
|
||||
ToolPermissionRule {
|
||||
tool: "bash".into(),
|
||||
pattern: "git *".into(),
|
||||
action: ToolPermissionAction::Allow,
|
||||
},
|
||||
ToolPermissionRule {
|
||||
tool: "Bash".into(),
|
||||
pattern: "git reset *".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let input = summary("Bash", serde_json::json!({ "command": "git reset --hard" }));
|
||||
|
||||
assert_eq!(hook.action_for(&input), ToolPermissionAction::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_action_applies_when_no_rule_matches() {
|
||||
let hook = PermissionHook::new(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
});
|
||||
|
||||
let input = summary("Read", serde_json::json!({ "file_path": "/tmp/a.txt" }));
|
||||
|
||||
assert_eq!(hook.action_for(&input), ToolPermissionAction::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_prefers_known_builtin_argument_fields() {
|
||||
assert_eq!(
|
||||
permission_target(&serde_json::json!({ "command": "rm -rf target" })),
|
||||
"rm -rf target"
|
||||
);
|
||||
assert_eq!(
|
||||
permission_target(&serde_json::json!({ "file_path": "/tmp/.env" })),
|
||||
"/tmp/.env"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_supports_star_and_question() {
|
||||
assert!(wildcard_match("rm *", "rm -rf target"));
|
||||
assert!(wildcard_match("file?.rs", "file1.rs"));
|
||||
assert!(!wildcard_match("rm *", "git status"));
|
||||
}
|
||||
}
|
||||
|
|
@ -333,6 +333,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
memory_task: None,
|
||||
user_segments: Vec::new(),
|
||||
};
|
||||
pod.apply_permissions_from_manifest();
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
}
|
||||
|
|
@ -2291,6 +2292,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
memory_task: None,
|
||||
user_segments: Vec::new(),
|
||||
};
|
||||
pod.apply_permissions_from_manifest();
|
||||
pod.apply_prune_from_manifest();
|
||||
drain_skill_shadows(&pod, skill_shadows);
|
||||
Ok(pod)
|
||||
|
|
@ -2361,6 +2363,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
memory_task: None,
|
||||
user_segments: Vec::new(),
|
||||
};
|
||||
pod.apply_permissions_from_manifest();
|
||||
pod.apply_prune_from_manifest();
|
||||
drain_skill_shadows(&pod, skill_shadows);
|
||||
Ok(pod)
|
||||
|
|
@ -2497,6 +2500,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
memory_task: None,
|
||||
user_segments: state.user_segments,
|
||||
};
|
||||
pod.apply_permissions_from_manifest();
|
||||
pod.apply_prune_from_manifest();
|
||||
drain_skill_shadows(&pod, skill_shadows);
|
||||
Ok(pod)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LoggedItem {
|
||||
|
|
@ -32,6 +36,8 @@ pub enum LoggedItem {
|
|||
summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
Reasoning {
|
||||
text: String,
|
||||
|
|
@ -86,11 +92,13 @@ impl From<&Item> for LoggedItem {
|
|||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => Self::ToolResult {
|
||||
call_id: call_id.clone(),
|
||||
summary: summary.clone(),
|
||||
content: content.clone(),
|
||||
is_error: *is_error,
|
||||
},
|
||||
Item::Reasoning {
|
||||
text,
|
||||
|
|
@ -138,11 +146,13 @@ impl From<LoggedItem> for Item {
|
|||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
} => Item::ToolResult {
|
||||
id: None,
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
},
|
||||
LoggedItem::Reasoning {
|
||||
text,
|
||||
|
|
@ -347,6 +357,18 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_tool_result_error_flag() {
|
||||
let original = Item::tool_result_error("call_1", "permission denied");
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let value = serde_json::to_value(&logged).unwrap();
|
||||
assert_eq!(value["is_error"], true);
|
||||
match Item::from(logged) {
|
||||
Item::ToolResult { is_error, .. } => assert!(is_error),
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_serialization_uses_kind_tag() {
|
||||
let logged: LoggedItem = (&Item::assistant_message("hi")).into();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ overlay をマージして、検証済みの `PodManifest` と `PromptLoader`
|
|||
| 配列スカラー(`worker.stop_sequences` 等) | 上層に値があれば配列ごと置換。追記マージはしない |
|
||||
| マップ(`tool_output.per_tool` 等) | キー単位でマージ、同一キーは上層優先 |
|
||||
| `scope.allow` / `scope.deny` | **union**(各層から全部足す)。上位層は `deny` で下位層の `allow` を必ず削れる |
|
||||
| `permissions.rule` | **union**(下位層の rule → 上位層の rule の順に評価)。`permissions.default_action` は上位層があれば上書き |
|
||||
|
||||
各層をマージした結果(`PodManifestConfig`)を `TryFrom<PodManifestConfig>
|
||||
for PodManifest` が必須フィールド検証と絶対パス検証をかけて `PodManifest`
|
||||
|
|
@ -161,6 +162,19 @@ recursive = false
|
|||
target = "/abs/path/to/project/secrets"
|
||||
permission = "write"
|
||||
|
||||
[permissions]
|
||||
default_action = "allow" # allow | deny | ask
|
||||
|
||||
[[permissions.rule]]
|
||||
tool = "Bash"
|
||||
pattern = "rm *"
|
||||
action = "deny"
|
||||
|
||||
[[permissions.rule]]
|
||||
tool = "Write"
|
||||
pattern = "*.env"
|
||||
action = "deny"
|
||||
|
||||
[compaction]
|
||||
prune_protected_turns = 3
|
||||
prune_min_savings = 4096
|
||||
|
|
@ -201,6 +215,24 @@ scheme 側が吸収する。
|
|||
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
|
||||
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
|
||||
|
||||
## `[permissions]` 設定
|
||||
|
||||
`[permissions]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
|
||||
|
||||
```toml
|
||||
[permissions]
|
||||
default_action = "allow"
|
||||
|
||||
[[permissions.rule]]
|
||||
tool = "Bash"
|
||||
pattern = "rm *"
|
||||
action = "deny"
|
||||
```
|
||||
|
||||
`tool` は実行時に登録されているツール名(`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep` 等)に対して大小文字を無視して照合する。`pattern` は built-in tool では主に `command` / `file_path` / `path` / `pattern` 引数に対する `*` / `?` ワイルドカードとして評価される。
|
||||
|
||||
`allow` は通常実行、`deny` はその tool call を実行せず `is_error = true` の synthetic tool result を履歴へ追加してターンを継続する。`ask` は型として受け付けるが、承認 protocol は未実装のため現在は headless に待機せず fail-closed(synthetic error result)になる。
|
||||
|
||||
## instruction とプロンプト資産
|
||||
|
||||
### `worker.instruction` フィールド
|
||||
|
|
|
|||
|
|
@ -103,3 +103,8 @@ action = "deny"
|
|||
## 依存チケット
|
||||
|
||||
- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。PreToolCall は Pod 層の `hook::Hook<PreToolCall>` として利用可能
|
||||
|
||||
## Review
|
||||
- 状態: Approve with follow-up
|
||||
- レビュー詳細: [./permission-extension-point.review.md](./permission-extension-point.review.md)
|
||||
- 日付: 2026-05-05
|
||||
|
|
|
|||
27
tickets/permission-extension-point.review.md
Normal file
27
tickets/permission-extension-point.review.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Review: パーミッション: パターンベースのツール実行制御
|
||||
|
||||
## 前提・要件の確認
|
||||
- `[permissions]` が無い場合は無効、ある場合は `default_action` 必須: 満たされている。`PodManifestConfig.permissions` は `Option` で、resolve 時に `permissions.default_action` 不在を `MissingField` にしている (`crates/manifest/src/config.rs:39-42`, `crates/manifest/src/config.rs:430-440`)。
|
||||
- `[[permissions.rule]]` と first-match / fallback 評価: 満たされている。rule は `rename = "rule"` で TOML 形に対応し、`PermissionHook::action_for` が宣言順 `find` + `default_action` fallback を行う (`crates/manifest/src/lib.rs:246-276`, `crates/pod/src/permission.rs:25-35`)。
|
||||
- built-in `PreToolCall` Hook として、ユーザー Hook より先に評価: 満たされている。Pod 構築直後に `apply_permissions_from_manifest` が呼ばれ、permission hook は通常の user hook 登録前に `HookRegistryBuilder` へ追加される (`crates/pod/src/pod.rs:336-337`, `crates/pod/src/pod.rs:2295-2296`, `crates/pod/src/permission.rs:39-45`)。
|
||||
- `allow`: 満たされている。permission action `Allow` は `PreToolAction::Continue` に変換される (`crates/pod/src/permission.rs:49-56`)。
|
||||
- `deny` は turn Abort ではなく synthetic error tool result: 満たされている。`PreToolAction::SyntheticResult` が追加され、Worker は tool を実行せず result を commit する (`crates/llm-worker/src/interceptor.rs:48-64`, `crates/llm-worker/src/worker.rs:757-769`, `crates/llm-worker/src/worker.rs:1136-1144`)。
|
||||
- provider-visible `is_error` の保持: 満たされている。`ToolResult` / `Item::ToolResult` / session replay に `is_error` が通り、Anthropic request へ `tool_result.is_error` が投影される (`crates/llm-worker/src/tool.rs:281-310`, `crates/llm-worker/src/llm_client/types.rs:74-89`, `crates/session-store/src/logged_item.rs:34-40`, `crates/llm-worker/src/llm_client/scheme/anthropic/request.rs:327-347`)。
|
||||
- `ask`: 現時点のスコープとしては満たされている。型として受け付け、承認 protocol 未実装環境では待機せず fail-closed synthetic error result にしている (`crates/pod/src/permission.rs:53-56`, `docs/pod-factory.md:218-235`)。
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
- `llm-worker` には汎用的な `SyntheticResult` action だけを追加し、manifest policy 本体は Pod 層の `permission.rs` に置かれているため、層の分離は保たれている。
|
||||
- 新しい trait は追加されておらず、既存の Hook / Interceptor 境界上で実装されている。
|
||||
- `Scope` の物理境界には手を入れておらず、permission は tool 実行ポリシーとして分離されている。
|
||||
- `ask` の protocol/UI 実装に踏み込まず fail-closed として明示している点は、今回の段階的実装として妥当。
|
||||
|
||||
## 指摘事項
|
||||
### Non-blocking / Follow-up
|
||||
- mixed allow/deny 時の tool result 順序が tool call 順とずれる可能性がある — synthetic result は `synthetic_results` に集めて実行済み result の末尾へ `extend` されるため、先頭 call が deny で後続 call が allow の場合、履歴上の result 順が逆転する (`crates/llm-worker/src/worker.rs:745-769`, `crates/llm-worker/src/worker.rs:821-823`)。provider が call id で対応を取れる限り致命的ではないが、会話の時系列としては call 順を保つ方が安全。
|
||||
- manifest から Pod へ permission が実際に効く統合テストが薄い — manifest resolve と `PermissionHook` 単体、および Worker の synthetic result はカバーされているが、`[permissions]` を含む Pod 構築から tool deny までの結合テストがあると回帰検出しやすい。
|
||||
|
||||
## 判断
|
||||
Approve with follow-up — 要件は満たしており設計上の逸脱もないが、result 順序と Pod 統合テストは後続で固める価値がある。
|
||||
|
||||
## 確認コマンド
|
||||
- `cargo test -p pod permission -- --nocapture`
|
||||
Loading…
Reference in New Issue
Block a user