feat: パターンベースのツール権限制御を追加
This commit is contained in:
parent
3337731222
commit
7bbc9afc7a
|
|
@ -52,6 +52,11 @@ pub enum PreToolAction {
|
||||||
Continue,
|
Continue,
|
||||||
/// Skip this tool call (do not execute).
|
/// Skip this tool call (do not execute).
|
||||||
Skip,
|
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 the entire run.
|
||||||
Abort(String),
|
Abort(String),
|
||||||
/// Pause execution (can be resumed later).
|
/// Pause execution (can be resumed later).
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ use crate::llm_client::{
|
||||||
|
|
||||||
use super::AnthropicScheme;
|
use super::AnthropicScheme;
|
||||||
|
|
||||||
|
fn is_false(value: &bool) -> bool {
|
||||||
|
!*value
|
||||||
|
}
|
||||||
|
|
||||||
/// Anthropic API request body
|
/// Anthropic API request body
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub(crate) struct AnthropicRequest {
|
pub(crate) struct AnthropicRequest {
|
||||||
|
|
@ -104,6 +108,8 @@ pub(crate) enum AnthropicContentPart {
|
||||||
ToolResult {
|
ToolResult {
|
||||||
tool_use_id: String,
|
tool_use_id: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
is_error: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
cache_control: Option<CacheControl>,
|
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 {
|
Self::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
content,
|
content,
|
||||||
|
is_error,
|
||||||
cache_control: None,
|
cache_control: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,6 +328,7 @@ impl AnthropicScheme {
|
||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
is_error,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
flush_pending(
|
flush_pending(
|
||||||
|
|
@ -333,8 +341,10 @@ impl AnthropicScheme {
|
||||||
Some(c) => format!("{summary}\n{c}"),
|
Some(c) => format!("{summary}\n{c}"),
|
||||||
None => summary.clone(),
|
None => summary.clone(),
|
||||||
};
|
};
|
||||||
pending_user
|
pending_user.push((
|
||||||
.push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
|
i,
|
||||||
|
AnthropicContentPart::tool_result(call_id.clone(), text, *is_error),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
|
|
@ -355,13 +365,10 @@ impl AnthropicScheme {
|
||||||
// 素の reasoning text。Anthropic に投げる意味も
|
// 素の reasoning text。Anthropic に投げる意味も
|
||||||
// round-trip の根拠も無いので drop。
|
// round-trip の根拠も無いので drop。
|
||||||
if let Some(sig) = signature.clone() {
|
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
|
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 は無視。
|
// どちらも None なら何も pend せず、本 item は無視。
|
||||||
}
|
}
|
||||||
|
|
@ -828,7 +835,9 @@ mod tests {
|
||||||
assert_eq!(thinking_parts.len(), 1);
|
assert_eq!(thinking_parts.len(), 1);
|
||||||
match thinking_parts[0] {
|
match thinking_parts[0] {
|
||||||
AnthropicContentPart::Thinking {
|
AnthropicContentPart::Thinking {
|
||||||
thinking, signature, ..
|
thinking,
|
||||||
|
signature,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(thinking, "step-by-step");
|
assert_eq!(thinking, "step-by-step");
|
||||||
assert_eq!(signature, "SIG-A");
|
assert_eq!(signature, "SIG-A");
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
fn is_false(value: &bool) -> bool {
|
||||||
|
!*value
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Item - The core unit of conversation
|
// Item - The core unit of conversation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -79,6 +83,9 @@ pub enum Item {
|
||||||
/// Detailed output (removed by pruning when old enough)
|
/// Detailed output (removed by pruning when old enough)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
/// Whether the tool result represents an execution error.
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
is_error: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Reasoning/thinking item
|
/// Reasoning/thinking item
|
||||||
|
|
@ -198,11 +205,27 @@ impl Item {
|
||||||
|
|
||||||
/// Create a tool result item with summary only (no content).
|
/// Create a tool result item with summary only (no content).
|
||||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
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 {
|
Self::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id: call_id.into(),
|
call_id: call_id.into(),
|
||||||
summary: summary.into(),
|
summary: summary.into(),
|
||||||
content: None,
|
content,
|
||||||
|
is_error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,12 +235,7 @@ impl Item {
|
||||||
summary: impl Into<String>,
|
summary: impl Into<String>,
|
||||||
content: impl Into<String>,
|
content: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::ToolResult {
|
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||||
id: None,
|
|
||||||
call_id: call_id.into(),
|
|
||||||
summary: summary.into(),
|
|
||||||
content: Some(content.into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ pub struct ToolCall {
|
||||||
///
|
///
|
||||||
/// Intermediate representation between tool execution and history.
|
/// Intermediate representation between tool execution and history.
|
||||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
/// Corresponding tool call ID
|
/// Corresponding tool call ID
|
||||||
pub tool_use_id: String,
|
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)
|
// Map from tool call ID to (ToolCall, Meta, Tool)
|
||||||
// Retained because it's needed for PostToolCall hooks
|
// Retained because it's needed for PostToolCall hooks
|
||||||
let mut call_info_map = HashMap::new();
|
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();
|
let mut approved_calls = Vec::new();
|
||||||
for mut tool_call in tool_calls {
|
for mut tool_call in tool_calls {
|
||||||
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
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 => {
|
PreToolAction::Skip => {
|
||||||
continue;
|
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) => {
|
PreToolAction::Abort(reason) => {
|
||||||
self.last_run_interrupted = true;
|
self.last_run_interrupted = true;
|
||||||
return Err(WorkerError::Aborted(reason));
|
return Err(WorkerError::Aborted(reason));
|
||||||
|
|
@ -809,6 +819,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
return Err(WorkerError::Cancelled);
|
return Err(WorkerError::Cancelled);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
results.extend(synthetic_results);
|
||||||
|
|
||||||
// Phase 3: Apply post_tool_call interceptor
|
// Phase 3: Apply post_tool_call interceptor
|
||||||
for tool_result in &mut results {
|
for tool_result in &mut results {
|
||||||
|
|
@ -1124,16 +1135,12 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
}
|
}
|
||||||
Ok(ToolExecutionResult::Completed(results)) => {
|
Ok(ToolExecutionResult::Completed(results)) => {
|
||||||
for result in results {
|
for result in results {
|
||||||
if let Some(ref content) = result.content {
|
self.history.push(Item::tool_result_item(
|
||||||
self.history.push(Item::tool_result_with_content(
|
&result.tool_use_id,
|
||||||
&result.tool_use_id,
|
&result.summary,
|
||||||
&result.summary,
|
result.content,
|
||||||
content,
|
result.is_error,
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
self.history
|
|
||||||
.push(Item::tool_result(&result.tool_use_id, &result.summary));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use llm_worker::interceptor::{
|
||||||
Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
||||||
};
|
};
|
||||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
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;
|
mod common;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
@ -268,3 +268,59 @@ async fn test_post_tool_call_modification() {
|
||||||
"Result should be modified"
|
"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::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig,
|
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig,
|
||||||
ToolOutputLimits, WorkerManifest,
|
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WorkerManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
/// Partial-form Pod manifest. Every field is optional; one or more
|
||||||
|
|
@ -36,6 +36,10 @@ pub struct PodManifestConfig {
|
||||||
pub worker: WorkerManifestConfig,
|
pub worker: WorkerManifestConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope: ScopeConfig,
|
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)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfigPartial>,
|
pub compaction: Option<CompactionConfigPartial>,
|
||||||
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
||||||
|
|
@ -87,6 +91,14 @@ pub struct ToolOutputLimitsPartial {
|
||||||
pub per_tool: HashMap<String, usize>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct CompactionConfigPartial {
|
pub struct CompactionConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -204,6 +216,11 @@ impl PodManifestConfig {
|
||||||
model: self.model.merge(upper.model),
|
model: self.model.merge(upper.model),
|
||||||
worker: self.worker.merge(upper.worker),
|
worker: self.worker.merge(upper.worker),
|
||||||
scope: merge_scope(self.scope, upper.scope),
|
scope: merge_scope(self.scope, upper.scope),
|
||||||
|
permissions: merge_option(
|
||||||
|
self.permissions,
|
||||||
|
upper.permissions,
|
||||||
|
PermissionConfigPartial::merge,
|
||||||
|
),
|
||||||
compaction: merge_option(
|
compaction: merge_option(
|
||||||
self.compaction,
|
self.compaction,
|
||||||
upper.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 {
|
impl CompactionConfigPartial {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -400,6 +427,18 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
ensure_absolute("scope.deny.target", &rule.target)?;
|
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
|
let compaction = cfg
|
||||||
.compaction
|
.compaction
|
||||||
.map(|c| -> Result<CompactionConfig, ResolveError> {
|
.map(|c| -> Result<CompactionConfig, ResolveError> {
|
||||||
|
|
@ -438,6 +477,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
model: cfg.model,
|
model: cfg.model,
|
||||||
worker,
|
worker,
|
||||||
scope: cfg.scope,
|
scope: cfg.scope,
|
||||||
|
permissions,
|
||||||
compaction,
|
compaction,
|
||||||
memory: cfg.memory,
|
memory: cfg.memory,
|
||||||
skills: cfg.skills,
|
skills: cfg.skills,
|
||||||
|
|
@ -482,6 +522,7 @@ mod tests {
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
|
permissions: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
memory: None,
|
memory: None,
|
||||||
skills: None,
|
skills: None,
|
||||||
|
|
@ -493,6 +534,51 @@ mod tests {
|
||||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||||
assert_eq!(manifest.pod.name, "test");
|
assert_eq!(manifest.pod.name, "test");
|
||||||
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
|
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]
|
#[test]
|
||||||
|
|
@ -694,6 +780,42 @@ mod tests {
|
||||||
assert_eq!(merged.scope.deny.len(), 1);
|
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]
|
#[test]
|
||||||
fn merge_tool_output_per_tool_keywise() {
|
fn merge_tool_output_per_tool_keywise() {
|
||||||
let lower = PodManifestConfig {
|
let lower = PodManifestConfig {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ mod scope;
|
||||||
|
|
||||||
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
|
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError,
|
CompactionConfigPartial, PermissionConfigPartial, PodManifestConfig, PodMetaConfig,
|
||||||
ToolOutputLimitsPartial, WorkerManifestConfig,
|
ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig,
|
||||||
};
|
};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||||
|
|
@ -35,6 +35,10 @@ pub struct PodManifest {
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
pub worker: WorkerManifest,
|
pub worker: WorkerManifest,
|
||||||
pub scope: ScopeConfig,
|
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)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfig>,
|
pub compaction: Option<CompactionConfig>,
|
||||||
/// Memory subsystem opt-in. Presence of `[memory]` in TOML enables
|
/// Memory subsystem opt-in. Presence of `[memory]` in TOML enables
|
||||||
|
|
@ -239,6 +243,38 @@ pub struct ScopeConfig {
|
||||||
pub deny: Vec<ScopeRule>,
|
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.
|
/// Context compaction configuration.
|
||||||
///
|
///
|
||||||
/// Controls Prune (content removal from old tool results) and Compact
|
/// Controls Prune (content removal from old tool results) and Compact
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub mod workflow;
|
||||||
|
|
||||||
mod factory;
|
mod factory;
|
||||||
mod interrupt_and_run;
|
mod interrupt_and_run;
|
||||||
|
mod permission;
|
||||||
mod pod;
|
mod pod;
|
||||||
|
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
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,
|
memory_task: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
};
|
};
|
||||||
|
pod.apply_permissions_from_manifest();
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
}
|
}
|
||||||
|
|
@ -2291,6 +2292,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
memory_task: None,
|
memory_task: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
};
|
};
|
||||||
|
pod.apply_permissions_from_manifest();
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
drain_skill_shadows(&pod, skill_shadows);
|
drain_skill_shadows(&pod, skill_shadows);
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -2361,6 +2363,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
memory_task: None,
|
memory_task: None,
|
||||||
user_segments: Vec::new(),
|
user_segments: Vec::new(),
|
||||||
};
|
};
|
||||||
|
pod.apply_permissions_from_manifest();
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
drain_skill_shadows(&pod, skill_shadows);
|
drain_skill_shadows(&pod, skill_shadows);
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -2497,6 +2500,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
memory_task: None,
|
memory_task: None,
|
||||||
user_segments: state.user_segments,
|
user_segments: state.user_segments,
|
||||||
};
|
};
|
||||||
|
pod.apply_permissions_from_manifest();
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
drain_skill_shadows(&pod, skill_shadows);
|
drain_skill_shadows(&pod, skill_shadows);
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
fn is_false(value: &bool) -> bool {
|
||||||
|
!*value
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum LoggedItem {
|
pub enum LoggedItem {
|
||||||
|
|
@ -32,6 +36,8 @@ pub enum LoggedItem {
|
||||||
summary: String,
|
summary: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
is_error: bool,
|
||||||
},
|
},
|
||||||
Reasoning {
|
Reasoning {
|
||||||
text: String,
|
text: String,
|
||||||
|
|
@ -86,11 +92,13 @@ impl From<&Item> for LoggedItem {
|
||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
is_error,
|
||||||
..
|
..
|
||||||
} => Self::ToolResult {
|
} => Self::ToolResult {
|
||||||
call_id: call_id.clone(),
|
call_id: call_id.clone(),
|
||||||
summary: summary.clone(),
|
summary: summary.clone(),
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
|
is_error: *is_error,
|
||||||
},
|
},
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
text,
|
text,
|
||||||
|
|
@ -138,11 +146,13 @@ impl From<LoggedItem> for Item {
|
||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
is_error,
|
||||||
} => Item::ToolResult {
|
} => Item::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
is_error,
|
||||||
},
|
},
|
||||||
LoggedItem::Reasoning {
|
LoggedItem::Reasoning {
|
||||||
text,
|
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]
|
#[test]
|
||||||
fn message_serialization_uses_kind_tag() {
|
fn message_serialization_uses_kind_tag() {
|
||||||
let logged: LoggedItem = (&Item::assistant_message("hi")).into();
|
let logged: LoggedItem = (&Item::assistant_message("hi")).into();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ overlay をマージして、検証済みの `PodManifest` と `PromptLoader`
|
||||||
| 配列スカラー(`worker.stop_sequences` 等) | 上層に値があれば配列ごと置換。追記マージはしない |
|
| 配列スカラー(`worker.stop_sequences` 等) | 上層に値があれば配列ごと置換。追記マージはしない |
|
||||||
| マップ(`tool_output.per_tool` 等) | キー単位でマージ、同一キーは上層優先 |
|
| マップ(`tool_output.per_tool` 等) | キー単位でマージ、同一キーは上層優先 |
|
||||||
| `scope.allow` / `scope.deny` | **union**(各層から全部足す)。上位層は `deny` で下位層の `allow` を必ず削れる |
|
| `scope.allow` / `scope.deny` | **union**(各層から全部足す)。上位層は `deny` で下位層の `allow` を必ず削れる |
|
||||||
|
| `permissions.rule` | **union**(下位層の rule → 上位層の rule の順に評価)。`permissions.default_action` は上位層があれば上書き |
|
||||||
|
|
||||||
各層をマージした結果(`PodManifestConfig`)を `TryFrom<PodManifestConfig>
|
各層をマージした結果(`PodManifestConfig`)を `TryFrom<PodManifestConfig>
|
||||||
for PodManifest` が必須フィールド検証と絶対パス検証をかけて `PodManifest`
|
for PodManifest` が必須フィールド検証と絶対パス検証をかけて `PodManifest`
|
||||||
|
|
@ -161,6 +162,19 @@ recursive = false
|
||||||
target = "/abs/path/to/project/secrets"
|
target = "/abs/path/to/project/secrets"
|
||||||
permission = "write"
|
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]
|
[compaction]
|
||||||
prune_protected_turns = 3
|
prune_protected_turns = 3
|
||||||
prune_min_savings = 4096
|
prune_min_savings = 4096
|
||||||
|
|
@ -201,6 +215,24 @@ scheme 側が吸収する。
|
||||||
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
|
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
|
||||||
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
|
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 とプロンプト資産
|
## instruction とプロンプト資産
|
||||||
|
|
||||||
### `worker.instruction` フィールド
|
### `worker.instruction` フィールド
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,8 @@ action = "deny"
|
||||||
## 依存チケット
|
## 依存チケット
|
||||||
|
|
||||||
- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。PreToolCall は Pod 層の `hook::Hook<PreToolCall>` として利用可能
|
- ~~[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