feat(capability): 扩展 CapabilityPolicy 支持子代理黑白名单

- domain: CapabilityPolicy 新增 allowed_subagents/denied_subagents 字段及 check_subagent_allowed 方法
- experts: 专家 frontmatter 解析/渲染支持子代理策略字段
- task/runtime: spawn/resume 双路径校验父代理子代理策略;新增 update_subagent 写回 SUBAGENT.md(与 update_expert 对称);新增 SubagentPromptProvider 按专家策略过滤子代理索引
- task/runtime: 子代理自身 capability 作为孙代理的 parent_capability 透传(ToolContext),保持解耦
- traits: ToolContext 新增 parent_capability 字段
- agent_factory: 主 agent 注入 expert_capability 到 ToolContext

安全:策略不通过即拒绝(与 def 不可用即拒绝范式一致),防止 LLM 通过选择被禁子代理绕过限制;max_nesting_depth 兜底防递归不可被 def 覆盖。
This commit is contained in:
oudecheng 2026-07-30 17:37:29 +08:00
parent 9381ed5dd4
commit c27efedb6c
5 changed files with 179 additions and 20 deletions

View File

@ -3,12 +3,14 @@ pub mod tools;
use serde::{Deserialize, Serialize};
/// 角色能力策略:工具与技能的白/黑名单。全为空表示沿用默认(不过滤)。
/// 角色能力策略:工具、技能、子代理的白/黑名单。全为空表示沿用默认(不过滤)。
///
/// 生效顺序:先白名单取交集,再黑名单扣除。专家与子代理共用此结构,
/// 确保语义一致。MCP 工具注册在 `ToolRegistry` 中(名 `mcp_*`),与内置
/// 工具同源,因此 `allowed_tools`/`denied_tools` 覆盖内置 + MCP 工具;
/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能。
/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能;
/// `allowed_subagents`/`denied_subagents` 覆盖子代理加载(通过 ToolContext
/// 传递给 TaskTool在 spawn/resume 时强制校验)。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityPolicy {
/// 技能白名单:`None` = 不限;`Some(vec)` = 仅这些 SKILL.md 技能可见。
@ -24,6 +26,13 @@ pub struct CapabilityPolicy {
/// 工具黑名单(含 `mcp_*` 工具)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_tools: Vec<String>,
/// 子代理白名单:`None` = 不限;`Some(vec)` = 仅这些子代理可被加载。
/// 通过 ToolContext 传递给 TaskTool在 spawn/resume 时强制校验。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_subagents: Option<Vec<String>>,
/// 子代理黑名单:禁止加载这些子代理。空表示不禁。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_subagents: Vec<String>,
}
impl CapabilityPolicy {
@ -33,6 +42,8 @@ impl CapabilityPolicy {
&& self.denied_skills.is_empty()
&& self.allowed_tools.is_none()
&& self.denied_tools.is_empty()
&& self.allowed_subagents.is_none()
&& self.denied_subagents.is_empty()
}
/// 是否声明了任何技能策略。
@ -44,4 +55,28 @@ impl CapabilityPolicy {
pub fn has_tool_policy(&self) -> bool {
self.allowed_tools.is_some() || !self.denied_tools.is_empty()
}
/// 是否声明了任何子代理策略。
pub fn has_subagent_policy(&self) -> bool {
self.allowed_subagents.is_some() || !self.denied_subagents.is_empty()
}
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
if let Some(list) = &self.allowed_subagents {
if !list.iter().any(|s| s == name) {
return Err(format!(
"subagent '{}' is not in the allowed_subagents whitelist",
name
));
}
}
if self.denied_subagents.iter().any(|s| s == name) {
return Err(format!(
"subagent '{}' is in the denied_subagents blacklist",
name
));
}
Ok(())
}
}

View File

@ -738,6 +738,10 @@ struct ExpertFrontmatter {
allowed_tools: Option<Vec<String>>,
#[serde(default)]
denied_tools: Vec<String>,
#[serde(default)]
allowed_subagents: Option<Vec<String>>,
#[serde(default)]
denied_subagents: Vec<String>,
}
/// 规范化字符串列表:去除空白项与首尾空格。
@ -775,6 +779,10 @@ fn render_expert_file(
allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_subagents: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_subagents: Vec<String>,
}
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
@ -784,6 +792,8 @@ fn render_expert_file(
denied_skills: capability.denied_skills.clone(),
allowed_tools: capability.allowed_tools.clone(),
denied_tools: capability.denied_tools.clone(),
allowed_subagents: capability.allowed_subagents.clone(),
denied_subagents: capability.denied_subagents.clone(),
})
.map_err(|err| format!("failed to render expert frontmatter: {}", err))?;
@ -876,6 +886,8 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
denied_skills: normalize_string_list(frontmatter.denied_skills),
allowed_tools: normalize_optional_list(frontmatter.allowed_tools),
denied_tools: normalize_string_list(frontmatter.denied_tools),
allowed_subagents: normalize_optional_list(frontmatter.allowed_subagents),
denied_subagents: normalize_string_list(frontmatter.denied_subagents),
};
Ok(Expert {

View File

@ -34,8 +34,8 @@ pub(crate) fn build_system_prompt_provider(
prompt_repository,
)),
Box::new(SkillPromptProvider::new(skills, experts.clone())),
Box::new(ExpertPromptProvider::new(experts)),
Box::new(SubagentPromptProvider::new(subagent_runtime)),
Box::new(ExpertPromptProvider::new(experts.clone())),
Box::new(SubagentPromptProvider::new(subagent_runtime, experts)),
Box::new(ToolPromptProvider::new()),
]))
}
@ -116,17 +116,22 @@ impl AgentFactory {
self.subagent_runtime.clone(),
);
// 读取所选专家的 capability用于工具过滤 + 子代理策略注入 ToolContext
let expert_capability = self
.experts
.selected_expert_for(&session_id)
.map(|e| e.capability);
// 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。
// 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。
let base_tool_count = self.tools.tool_names().len();
let tools: Arc<ToolRegistry> = match self.experts.selected_expert_for(&session_id) {
Some(expert) if expert.capability.has_tool_policy() => {
let filtered = self.build_filtered_registry(&expert.capability);
let tools: Arc<ToolRegistry> = match &expert_capability {
Some(cap) if cap.has_tool_policy() => {
let filtered = self.build_filtered_registry(cap);
let filtered_count = filtered.tool_names().len();
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
expert = %expert.name,
base_tool_count,
filtered_tool_count = filtered_count,
"AgentFactory: applied expert tool policy"
@ -160,6 +165,8 @@ impl AgentFactory {
task_id: None,
parent_task_id: None,
tool_call_id: None,
// 注入专家 capabilityTaskTool 据此强制校验子代理白/黑名单
parent_capability: expert_capability.clone(),
});
// 如果有取消信号接收端,注入 Agent
if let Some(token) = request.cancel_token {

View File

@ -14,6 +14,7 @@ use crate::bus::MessageBus;
use crate::domain::CapabilityPolicy;
use crate::providers::StreamDelta;
use crate::config::{LLMProviderConfig, SubagentsConfig};
use crate::experts::ExpertRuntime;
use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, SessionStore};
use crate::tools::{ToolContext, ToolRegistry};
@ -487,6 +488,9 @@ impl DefaultSubAgentRuntime {
task_id: Some(session.id.clone()),
parent_task_id,
tool_call_id: None,
// 子代理自身的 capability 作为孙代理的 parent_capability
// 使孙代理的 TaskTool 能按此策略校验(与主 agent 注入专家 capability 同构)
parent_capability: def.map(|d| d.capability.clone()),
});
// 如果有 MessageBus附加实时广播 emitter
@ -627,12 +631,21 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
.clone()
.ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?;
// 2. 查找子代理定义
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
// 防止 LLM 通过选择被禁子代理绕过限制。
if let Some(cap) = &parent_context.parent_capability {
if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) {
return Err(TaskError::InvalidArguments(msg));
}
}
// 3. 查找子代理定义
let def = self
.find_subagent_def(task.subagent_type.as_str())
.map_err(TaskError::InvalidArguments)?;
// 3. 创建任务会话
// 4. 创建任务会话
let topic_id = parent_context.topic_id.clone();
let session = TaskSession::new(
session_id,
@ -807,7 +820,16 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
&additional_prompt,
);
// 4.1 重新解析 def 以应用工具过滤。
// 4.1 校验父智能体的子代理策略(白/黑名单)。
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
// 该子代理的专家resume 应失败(与 def 被删除即失败的安全语义一致)。
if let Some(cap) = &parent_context.parent_capability {
if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) {
return Err(TaskError::InvalidArguments(msg));
}
}
// 4.2 重新解析 def 以应用工具过滤。
// 安全要求def 被删除/禁用时必须失败恢复,而不是降级为完整工具集——
// 否则一个受限子代理(如 allowed_tools: [read])在 def 失踪后会获得全部工具,
// 构成权限提升。与 spawn 保持一致def 不可用即拒绝执行。
@ -1243,6 +1265,53 @@ impl SubagentRuntime {
Some(prompt)
}
/// 生成按 capability 过滤后的系统索引提示词。
/// 在禁用项过滤之上,再按 `allowed_subagents`(白名单取交集)和
/// `denied_subagents`(黑名单扣除)过滤。用于专家/子代理的子代理策略。
pub fn system_index_prompt_filtered_with_policy(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
let available_defs: Vec<&SubagentDef> = catalog
.all()
.into_iter()
.filter(|def| !state.is_disabled(&def.name))
.filter(|def| {
if let Some(list) = allowed {
list.iter().any(|s| s == &def.name)
} else {
true
}
})
.filter(|def| !denied.iter().any(|s| s == &def.name))
.collect();
if available_defs.is_empty() {
return None;
}
let mut prompt = String::from(
"# 子代理系统\n\n\
\n\
\n\n\
<available_subagents>\n",
);
for def in available_defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
/// 禁用子代理
pub fn disable_subagent(
&self,
@ -1383,24 +1452,42 @@ impl SubagentRuntime {
/// 为子代理系统提供索引提示词
///
/// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。
/// 当会话选中了带子代理策略的专家时,按专家 `CapabilityPolicy` 过滤子代理索引
/// (与 `SkillPromptProvider` 过滤技能索引的模式同构)。
pub struct SubagentPromptProvider {
runtime: Arc<SubagentRuntime>,
experts: Arc<ExpertRuntime>,
}
impl SubagentPromptProvider {
pub fn new(runtime: Arc<SubagentRuntime>) -> Self {
Self { runtime }
pub fn new(runtime: Arc<SubagentRuntime>, experts: Arc<ExpertRuntime>) -> Self {
Self { runtime, experts }
}
}
impl SystemPromptProvider for SubagentPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
self.runtime
.system_index_prompt_filtered()
.map(|content| SystemPrompt {
content,
context: Some("subagents".to_string()),
})
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
// 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认)
let content = match context.session_id.as_deref() {
Some(sid) => {
let policy = self
.experts
.selected_expert_for(sid)
.map(|e| e.capability);
match policy {
Some(p) if p.has_subagent_policy() => self.runtime.system_index_prompt_filtered_with_policy(
p.allowed_subagents.as_deref(),
&p.denied_subagents,
),
_ => self.runtime.system_index_prompt_filtered(),
}
}
None => self.runtime.system_index_prompt_filtered(),
};
content.map(|c| SystemPrompt {
content: c,
context: Some("subagents".to_string()),
})
}
}
@ -1473,6 +1560,10 @@ struct SubagentFrontmatter {
#[serde(default)]
denied_tools: Vec<String>,
#[serde(default)]
allowed_subagents: Option<Vec<String>>,
#[serde(default)]
denied_subagents: Vec<String>,
#[serde(default)]
max_execution_secs: Option<u64>,
}
@ -1562,6 +1653,8 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
denied_skills: frontmatter.denied_skills,
allowed_tools: frontmatter.allowed_tools,
denied_tools: frontmatter.denied_tools,
allowed_subagents: frontmatter.allowed_subagents,
denied_subagents: frontmatter.denied_subagents,
};
Ok(SubagentDef {
@ -1605,6 +1698,10 @@ fn render_subagent_file(
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_subagents: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_subagents: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_execution_secs: Option<u64>,
}
@ -1620,6 +1717,8 @@ fn render_subagent_file(
denied_skills: capability.denied_skills.clone(),
allowed_tools: capability.allowed_tools.clone(),
denied_tools: capability.denied_tools.clone(),
allowed_subagents: capability.allowed_subagents.clone(),
denied_subagents: capability.denied_subagents.clone(),
max_execution_secs,
};

View File

@ -1,5 +1,7 @@
use async_trait::async_trait;
use crate::domain::CapabilityPolicy;
#[derive(Debug, Clone)]
pub struct ToolResult {
pub success: bool,
@ -26,6 +28,10 @@ pub struct ToolContext {
pub parent_task_id: Option<String>,
/// 当前工具调用的 ID由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
pub tool_call_id: Option<String>,
/// 父智能体(主 agent 所选专家或上级子代理)的 capability 策略快照。
/// TaskTool 据此强制校验子代理加载(白/黑名单),与 spawn/resume 安全范式一致。
/// 以数据形式传递,避免 task 模块反向依赖 experts 模块。
pub parent_capability: Option<CapabilityPolicy>,
}
#[async_trait]