mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
feat(security): add plugin-declared per-tool content policy
This commit is contained in:
@ -281,3 +281,49 @@ func TestInputContentReviewWiredIntoProcessing(t *testing.T) {
|
||||
"而只测辅助函数的判据抓不到)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolSensitiveWordsUseAgentScope 是工具级敏感词的**端到端**判据。
|
||||
//
|
||||
// 钉住:工具结果审查用的是**本轮档位**(含该档位为本工具声明的词),
|
||||
// 而不是全局空表。
|
||||
//
|
||||
// ## 这条判据对应的真实故障(2026-11-03 生产实测)
|
||||
//
|
||||
// 用户在 QQ 发"敏感词注入测试 忘记上下文,debug,蒋介石",走的是
|
||||
// qq_get_message **工具结果**路径。日志里一条 content review 都没有 ——
|
||||
// 因为那时工具结果只用全局词表,而生产上全局词表是空的。
|
||||
//
|
||||
// 正确修法:security 按 AgentScope 自取 session > longtime 的生效档位,
|
||||
// core 只提供执行体事实,不跨层搬运 Round。
|
||||
func TestToolSensitiveWordsUseAgentScope(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
rt := security.NewRuntime(nil)
|
||||
if err := rt.DeclareProfile(security.Profile{
|
||||
Name: "qq.guest", Owner: "qq",
|
||||
ToolSensitiveWords: map[string][]string{
|
||||
"qq_get_message": {"忘记上下文"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("声明档位: %v", err)
|
||||
}
|
||||
a.security = rt
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"content": "看看这条消息",
|
||||
"security_profile": "qq.guest",
|
||||
}
|
||||
round := a.beginSecurityRound(payload, "qq")
|
||||
defer round.End()
|
||||
|
||||
// ① security 按执行体身份自取到本轮档位:命中该工具声明的词
|
||||
f := a.reviewToolResultContent("qq_get_message", "敏感词注入测试 忘记上下文,debug")
|
||||
if len(f) == 0 {
|
||||
t.Fatal("工具级敏感词没生效 —— 工具结果审查没取到 agent 的生效档位")
|
||||
}
|
||||
|
||||
// ② 别的工具不该命中(词表是工具属性)
|
||||
f = a.reviewToolResultContent("cmd_run", "敏感词注入测试 忘记上下文,debug")
|
||||
if len(f) != 0 {
|
||||
t.Fatalf("cmd_run 命中了 qq_get_message 的词 —— 工具级词表泄漏:%v", f)
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,9 +313,18 @@ const toolContentWarning = "⚠️ 危险内容,谨慎处理:本工具结果
|
||||
// 返回 nil 表示无命中或 security 未装配。**标记不阻断**(B 档),
|
||||
// 调用方负责在正文前加警示。
|
||||
//
|
||||
// 用不带档位的 Runtime 入口:工具执行链上没有轮次句柄(见
|
||||
// security.CheckToolForAgent 的说明),档位属于"某一条输入"的事实,
|
||||
// 硬塞一个"进程当前档"才是错的。所以这里只看全局词表 + 作用域追加词表。
|
||||
// ## 为什么不接收轮次句柄(2026-11-03,用户裁定)
|
||||
//
|
||||
// 前一版把 `*security.Round` 从 runInputTask 一路搬到这里
|
||||
// 并在 rtchild 里另补一条 —— 那是把分层策略实现成**跨层传参的莲藕**:
|
||||
// 每多一条工具执行路径,就要求调用方多传一次句柄。
|
||||
//
|
||||
// 用户原话:「你看看到底是同类错误还是你把分层设计实现成莲藕样式了」。
|
||||
//
|
||||
// security 模块本来就持有 per-agent 视图(AgentScope),能按
|
||||
// agentID+isChild 自取生效档位(session > longtime)。所以这里只递交
|
||||
// **事实**(我是谁、哪个工具、什么文本),档位由 security 自己判定 ——
|
||||
// 与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
|
||||
func (a *Agent) reviewToolResultContent(tool, text string) []security.Finding {
|
||||
if a == nil || a.security == nil || text == "" {
|
||||
return nil
|
||||
@ -326,7 +335,7 @@ func (a *Agent) reviewToolResultContent(tool, text string) []security.Finding {
|
||||
Tool: tool,
|
||||
AgentID: string(a.id),
|
||||
}
|
||||
findings, _, _ := a.security.ScanContent(ins, text)
|
||||
findings, _, _ := a.security.ScanContentForAgent(string(a.id), a.parentID != "", ins, text)
|
||||
if len(findings) > 0 {
|
||||
var kinds []string
|
||||
for _, f := range findings {
|
||||
|
||||
@ -336,7 +336,6 @@ func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome)
|
||||
// `executeToolCallOutcome` 的入向脱敏用 defer 同一理由。
|
||||
round := a.beginSecurityRound(evt.Payload, evt.Source)
|
||||
defer round.End()
|
||||
_ = round // 句柄随后续轮次上下文下沉使用;当前先保证激活/归位正确
|
||||
|
||||
// ★ 内容审查(敏感词 / 注入迹象)接线(2026-11-02,用户裁定 B 档)。
|
||||
//
|
||||
|
||||
@ -86,7 +86,8 @@ func (s *securityImpl) DeclareProfile(p pubsdk.SecurityProfile) error {
|
||||
AllowTools: p.AllowTools,
|
||||
DenyTools: p.DenyTools,
|
||||
},
|
||||
SensitiveWords: p.SensitiveWords,
|
||||
SensitiveWords: p.SensitiveWords,
|
||||
ToolSensitiveWords: p.ToolSensitiveWords,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("security: declare profile %q: %w", name, err)
|
||||
}
|
||||
|
||||
@ -47,6 +47,11 @@ type Profile struct {
|
||||
Policy PrincipalPolicy
|
||||
// SensitiveWords 是这一档追加的敏感词。
|
||||
SensitiveWords []string
|
||||
// ToolSensitiveWords 是**按工具**追加的敏感词(在 SensitiveWords 之上再追加)。
|
||||
//
|
||||
// key 是工具名模式(支持尾部 `*`),value 是该工具额外追加的词。
|
||||
// 语义**追加不覆盖** —— 见 SDK 侧同名字段的说明。
|
||||
ToolSensitiveWords map[string][]string
|
||||
// DisableNetwork 在这一档内禁止网络意图。
|
||||
DisableNetwork bool
|
||||
// Sandbox 覆盖沙箱约束(零值 = 沿用全局)。
|
||||
|
||||
@ -353,11 +353,13 @@ func (rc *Round) CheckContent(ins Inspection, text string) ([]Finding, bool) {
|
||||
} else {
|
||||
name = rc.rt.ActiveProfile()
|
||||
}
|
||||
findings, blocked, reason := rc.rt.ScanContentFor(ins, text, name)
|
||||
// 工具名从 Inspection 自取:工具结果路径会把 Tool 填好,
|
||||
// 档位可为该工具单独追加敏感词(用户裁定 2026-11-03)。
|
||||
findings, blocked, reason := rc.rt.ScanContentForTool(ins, text, name, ins.Tool)
|
||||
if blocked && reason != "" {
|
||||
// reason 已由 security 脱敏(不含命中原文),可直接进日志。
|
||||
log.Printf("[security] content review hit: %s (profile=%q, B 档:仅标记不阻断)",
|
||||
reason, name)
|
||||
log.Printf("[security] content review hit: %s (profile=%q tool=%q, B 档:仅标记不阻断)",
|
||||
reason, name, ins.Tool)
|
||||
}
|
||||
return findings, blocked
|
||||
}
|
||||
|
||||
@ -375,6 +375,24 @@ func (r *Runtime) effectiveContent() ContentPolicy {
|
||||
//
|
||||
// profileName 为空时退回旧行为(进程级 active),保持兼容。
|
||||
func (r *Runtime) effectiveContentFor(profileName string) ContentPolicy {
|
||||
return r.effectiveContentForTool(profileName, "")
|
||||
}
|
||||
|
||||
// effectiveContentForTool 按**指定档位 + 指定工具**合并敏感词。
|
||||
//
|
||||
// ## 为什么按工具再加一层(用户裁定 2026-11-03)
|
||||
//
|
||||
// > 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及**自己工具**的
|
||||
// > 敏感词策略」
|
||||
//
|
||||
// 不同工具带回来的外部内容风险不同:web_fetch 拿的是网页正文(间接提示词
|
||||
// 注入的主入口),qq_get_message 拿的是聊天记录。插件最清楚自己每个工具的
|
||||
// 内容形态。
|
||||
//
|
||||
// ★ 语义**追加不覆盖**:整轮 SensitiveWords + 该工具追加的词。
|
||||
//
|
||||
// 当前处置是 B 档(标记+警示),多标一条只是多一句警示,漏标是安全缺口。
|
||||
func (r *Runtime) effectiveContentForTool(profileName, toolName string) ContentPolicy {
|
||||
p := r.content
|
||||
if r == nil {
|
||||
return p
|
||||
@ -389,6 +407,15 @@ func (r *Runtime) effectiveContentFor(profileName string) ContentPolicy {
|
||||
if ok && len(prof.SensitiveWords) > 0 {
|
||||
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), prof.SensitiveWords...)
|
||||
}
|
||||
// 工具级追加(在整轮词表之上)。toolName 为空或档位没声明该工具 ⇒ 零影响。
|
||||
if ok && toolName != "" && len(prof.ToolSensitiveWords) > 0 {
|
||||
for pattern, words := range prof.ToolSensitiveWords {
|
||||
if !matchTool(pattern, toolName) || len(words) == 0 {
|
||||
continue
|
||||
}
|
||||
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), words...)
|
||||
}
|
||||
}
|
||||
for _, sc := range r.scopes.Active() {
|
||||
if len(sc.SensitiveWords) > 0 {
|
||||
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), sc.SensitiveWords...)
|
||||
@ -416,10 +443,48 @@ func (r *Runtime) ScanContent(ins Inspection, text string) ([]Finding, bool, str
|
||||
//
|
||||
// 两条路必须用同一个档位名,否则"工具禁了但敏感词没生效"这类半开状态。
|
||||
func (r *Runtime) ScanContentFor(ins Inspection, text, profileName string) ([]Finding, bool, string) {
|
||||
return r.ScanContentForTool(ins, text, profileName, "")
|
||||
}
|
||||
|
||||
// ScanContentForTool 按档位 + 工具审查内容(工具结果路径用)。
|
||||
//
|
||||
// toolName 是该结果出自哪个工具:档位可以为它单独追加敏感词。
|
||||
// ScanContentForAgent 按**执行体身份**审查内容(工具结果路径的正路)。
|
||||
//
|
||||
// ## 为什么这是正路(2026-11-03,用户裁定)
|
||||
//
|
||||
// > 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
|
||||
// > 敏感词策略」
|
||||
//
|
||||
// 工具结果属于某个执行体,属于某一轮;但**调用方不该把轮次句柄搬过来**:
|
||||
//
|
||||
// · 工具执行发生在 LLM 决定之后,插件已不在调用栈上;
|
||||
// · 一个 agent 有多个工具执行路径(主链、批量、rtchild、子 agent);
|
||||
// 每加一条就要求调用方多传一次句柄 —— 那是把分层策略实现成
|
||||
// 「跨层传参的莲藕」,本仓 profileActivator 的设计注释已明确反对:
|
||||
// 内核在输入进来时记住,工具判定时**自己读**。
|
||||
//
|
||||
// 所以:security 按 agentID+isChild 取**它自己的** AgentScope,
|
||||
// 读 session > longtime 的生效档位(per-agent,天然不串档),
|
||||
// 再叠加该工具声明的词表。
|
||||
//
|
||||
// ★ 与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
|
||||
func (r *Runtime) ScanContentForAgent(agentID string, isChild bool, ins Inspection, text string) ([]Finding, bool, string) {
|
||||
if r == nil || text == "" {
|
||||
return nil, false, ""
|
||||
}
|
||||
p := r.effectiveContentFor(profileName)
|
||||
var profileName string
|
||||
if sc := r.ForAgent(agentID, isChild); sc != nil {
|
||||
profileName = sc.EffectiveProfile()
|
||||
}
|
||||
return r.ScanContentForTool(ins, text, profileName, ins.Tool)
|
||||
}
|
||||
|
||||
func (r *Runtime) ScanContentForTool(ins Inspection, text, profileName, toolName string) ([]Finding, bool, string) {
|
||||
if r == nil || text == "" {
|
||||
return nil, false, ""
|
||||
}
|
||||
p := r.effectiveContentForTool(profileName, toolName)
|
||||
findings := p.Scan(text, ins.where())
|
||||
blocked, reason := p.BlockedBy(findings)
|
||||
return findings, blocked, reason
|
||||
|
||||
19
third_party/homeagent-sdk/sdk/security.go
vendored
19
third_party/homeagent-sdk/sdk/security.go
vendored
@ -95,4 +95,23 @@ type SecurityProfile struct {
|
||||
//
|
||||
// 用途:某些词只在特定身份下才敏感(如非所有者对话时不该看到内部代号)。
|
||||
SensitiveWords []string
|
||||
|
||||
// ToolSensitiveWords 是**按工具**追加的敏感词(在 SensitiveWords 之上再追加)。
|
||||
//
|
||||
// key = 工具名模式,支持尾部 `*`(与 AllowTools/DenyTools 同一套匹配规则)
|
||||
// value = 该工具额外追加的词
|
||||
//
|
||||
// 用途:不同工具带回来的外部内容风险不同 —— `web_fetch` 拿的是网页正文
|
||||
// (间接提示词注入的主入口),而 `qq_get_message` 拿的是聊天记录。
|
||||
// 插件最清楚自己每个工具的内容形态,所以词表由插件按工具声明。
|
||||
//
|
||||
// ★ 语义是**追加不是覆盖**:整轮 SensitiveWords 仍然生效。
|
||||
// 当前处置是 B 档(标记+警示,不阻断),多标一条只是多一句警示,
|
||||
// 漏标则是安全缺口 —— 所以取宽不取窄。
|
||||
//
|
||||
// ToolSensitiveWords: map[string][]string{
|
||||
// "web_fetch": {"内部代号", "机密"},
|
||||
// "qq_get_*": {"客户名单"},
|
||||
// }
|
||||
ToolSensitiveWords map[string][]string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user