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
+5
View File
@@ -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");
+25 -7
View File
@@ -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)
}
// ========================================================================
+1 -1
View File
@@ -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,
+18 -11
View File
@@ -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"
)));
}