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

This commit is contained in:
Keisuke Hirata 2026-05-09 03:19:22 +09:00
parent 2df9de73c7
commit 60144c550a
15 changed files with 566 additions and 33 deletions

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).

View File

@ -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");

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)
}
// ========================================================================

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,

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)
}

View File

@ -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"
)));
}

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 {

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

View File

@ -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};

View 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"));
}
}

View File

@ -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)

View File

@ -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();

View File

@ -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-closedsynthetic error resultになる。
## instruction とプロンプト資産
### `worker.instruction` フィールド

View File

@ -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

View 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`