mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 08:17:32 +00:00
fix(security): 工具策略闸真的接上了 —— 根因是依赖图少一条边,不是少一行赋值
## 缺陷
生产日志实证:`[security] module enabled (31 variables)`、`[device] ready:
34 tools, security=true`,691 个日志文件里 `denied by policy` 命中 **0 次**。
`executeToolCallInner` 里的策略闸读 `a.security`,而它**永远是 nil**:
codegraph_callers("SetSecurity") → 4 个调用方,全是 security_toolgate_test.go
全仓唯一给 a.security 赋值的行:security_profile.go(就在 SetSecurity 体内)
buildAgent 完全不读 k.Security;AgentConfig 里也没有 Security 字段
## 为什么判据全绿而生产是坏的
`security_toolgate_test.go` 的 4 条判据**自己调了 a.SetSecurity(...)** ——
它们把缺失的那根线补上了,所以永远绿。测的是「接线之后行为对」,
不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。
## 根因:模块边界没连上,不是少一行
`kernelmod/agent` 的 Requires() **没有声明 FacSecurity**。依赖图因此没有
理由把 security 的产物交给 agent —— 只有声明了的 device 拿到了。
即使不声明,security 也在第 1 层、agent 在第 4 层,`ks.Security` 恰好已被
填好,运行期照样能读到。**这正是它藏了整轮发布的原因**:那个"恰好"是偶然
(security 只依赖 config),不是设计。一旦 security 将来加了自己的依赖,
图不会拦,只会静默给出 nil。
所以只补 `agent.SetSecurity(k.Security)` 是错的:那会让代码读一个图不保证的
字段。必须同时改三处。
## 改动
① kernelmod/agent: Requires 加 FacSecurity(让图知道这条边)
② AgentConfig 加 Security 字段;New() 写 a.security;buildAgent 传 k.Security
③ Init 里 ks.Security == nil 时**响亮失败**(与 device 模块对 ks.Security
的检查同型)—— 不能把 nil 一路传下去变成静默放行
④ 子 agent 继承:resident / rtchild 各自 New(AgentConfig{...}),原先从构造起
a.security 就是 nil。它们是**最需要闸门**的执行体(驻留子长期无人盯、
rtchild 通话中一边听一边执行工具),却恰恰完全没有闸门。
## 判据(全部通过变异验证,删掉修复必红)
新增 internal/kernelmod/all/security_wiring_test.go(钉**装配**,不是钉注入):
| 判据 | 变异 |
|---|---|
| TestAgentDeclaresSecurityDependency | 从 Requires 删 FacSecurity ⇒ 红 |
| TestAgentAssemblyWiresSecurity | buildAgent 传 nil ⇒ 红;New() 不写 a.security ⇒ 红 |
| TestSubAgentsInheritSecurity | 删 resident/rtchild 的 Security ⇒ 红 |
| TestAgentAssemblyRefusesNilSecurity | 删 Init 的 nil 检查 ⇒ 红 |
`TestAgentAssemblyWiresSecurity` 走**真实登记表装配**(真 SQLite / 真配置库 /
真 20 模块 6 层),并对一个**未注册**的工具名配黑名单后调真实 `ExecuteTool`:
闸门生效 ⇒ "not permitted";闸门失效 ⇒ "does not exist"。两者文本不同,
无从混淆,因此不需要注册探针工具。
## 实测
生产形态探针(配置写入 `core.security.tool_deny=cmd_run` 后启动真实二进制):
[security] global policy applied: deny=[cmd_run] allow=[] sandbox=false
Agent.ExecuteTool("cmd_run") → denied (Tool cmd_run is not permitted:
tool "cmd_run" is blocked by security policy (denylist entry "cmd_run"))
分层未变(3+10+3+1+2+1 = 20 模块 6 层,digest 14b4f6f7),
build / vet / go test ./... / -race(kernelmod/all + agent/core + security) 全绿。
## 顺带
gofmt 漂移清零(原 1 个 rtchild_route_test.go)。
This commit is contained in:
269
internal/kernelmod/all/security_wiring_test.go
Normal file
269
internal/kernelmod/all/security_wiring_test.go
Normal file
@ -0,0 +1,269 @@
|
||||
package all
|
||||
|
||||
// 装配级安全判据(2026-10-30)。
|
||||
//
|
||||
// ## 为什么必须新开一个文件、而不是加在 security_toolgate_test.go 里
|
||||
//
|
||||
// 那边 4 条判据全绿了**整整一轮发布**,而生产里的闸门根本没接上。原因很简单:
|
||||
//
|
||||
// 它们自己调了 a.SetSecurity(...)。
|
||||
//
|
||||
// 判据把缺失的那根线补上了,所以永远绿 —— 测的是「接线之后行为对」,
|
||||
// 而不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。
|
||||
//
|
||||
// 本文件钉的是**装配**:
|
||||
//
|
||||
// kernelmod.Registered() → kernelboot.Build → kernelboot.Run
|
||||
// → security.Init 产出 ks.Security
|
||||
// → agent.Init 把 ks.Security 装进 Agent
|
||||
// → 真实工具执行链读到它(securityDecisionFor → CheckTool)
|
||||
//
|
||||
// ★ 变异要求(每条都在实现上验过,见文件末尾的变异表):
|
||||
// ① 从 agent.Requires() 删掉 FacSecurity ⇒ 判据 1 红
|
||||
// ② 从 buildAgent 的 AgentConfig 删 Security ⇒ 判据 2 红
|
||||
// ③ 从 resident/rtchild 的 AgentConfig 删 Security ⇒ 判据 3 红
|
||||
// ④ 从 New() 删掉 `security: cfg.Security` ⇒ 判据 2 红
|
||||
// ⑤ 从 agent.Init 删掉 nil 检查 ⇒ 判据 4 红
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// ───────────────── 判据 1:agent 模块**声明**了 security 依赖 ─────────────────
|
||||
|
||||
// ★★★ 判据:`agent` 模块必须在 Requires() 里声明 FacSecurity。
|
||||
//
|
||||
// ## 为什么不能靠"层号碰巧对"来代替这条判据
|
||||
//
|
||||
// 即使不声明,security 也在第 1 层、agent 在第 4 层 ⇒ `ks.Security` 恰好
|
||||
// 已被填好,运行期照样能读到。**这正是这个缺陷藏了整轮的原因。**
|
||||
//
|
||||
// 但那个"恰好"是偶然的:security 只依赖 config,所以天然在前面。一旦
|
||||
// security 将来加了自己的依赖(比如判某些策略要先读 llm 配置),它就会被推到
|
||||
// agent 之后 —— 那时 agent 读到一个 nil 的 ks.Security,而**图不会拦**,
|
||||
// 因为图根本不知道 agent 需要它。
|
||||
//
|
||||
// 所以这条判据断言的是**声明**,不是顺序。与 TestHiddenDependencyOnnxDeclared
|
||||
// 同型(那条也明确写了"不能用层号断言")。
|
||||
func TestAgentDeclaresSecurityDependency(t *testing.T) {
|
||||
var matched *kernelboot.Module
|
||||
var requires []string
|
||||
for _, m := range kernelmod.Registered() {
|
||||
if m.Name() != "agent" {
|
||||
continue
|
||||
}
|
||||
requires = m.Requires()
|
||||
matched = &m
|
||||
}
|
||||
if matched == nil {
|
||||
t.Fatal("★ agent 模块没注册")
|
||||
}
|
||||
for _, r := range requires {
|
||||
if r == kernelmod.FacSecurity {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("★ agent 模块未声明对 %q 的依赖(当前 Requires=%v)。\n"+
|
||||
" 后果:buildAgent 读 ks.Security 就成了**隐式顺序依赖** ——\n"+
|
||||
" 今天恰好成立(security 在第 1 层),但那是运气不是设计,\n"+
|
||||
" 而隐式顺序依赖正是本仓模块化设计要消灭的形态。\n"+
|
||||
" 修法:kernelmod/agent 的 Requires() 加 kernelmod.FacSecurity。",
|
||||
kernelmod.FacSecurity, requires)
|
||||
}
|
||||
|
||||
// ───────────────── 判据 2:装配后 Agent 的执行链真的读到它 ─────────────────
|
||||
|
||||
// ★★★ 判据:走真实登记表装配整机后,给 `ks.Security` 配的黑名单
|
||||
// **必须**能拦住 Agent 的工具执行链。
|
||||
//
|
||||
// ## 与前一代判据的区别(本文件存在的全部理由)
|
||||
//
|
||||
// 旧判据:`a := newAgent(); a.SetSecurity(rt); assert(a.securityDecisionFor(...))`
|
||||
//
|
||||
// ⇒ 自己补线,永远绿。
|
||||
//
|
||||
// 本判据:不碰 SetSecurity,只用**模块装配**产出的 ks.Agent 与 ks.Security,
|
||||
//
|
||||
// 并且走**真实执行入口** `ExecuteTool`(即 api.ToolHost,生产里
|
||||
// device 与插件工具面都通过它进来)。
|
||||
//
|
||||
// ## 为什么用"黑名单里的**不存在**的工具"
|
||||
//
|
||||
// 闸门在 `executeToolCallInner` 里位于**分派之前**(参数校验之后)。
|
||||
// 所以拿一个未注册的工具名去试:
|
||||
//
|
||||
// · 闸门生效 ⇒ 得到 "not permitted"
|
||||
// · 闸门失效 ⇒ 落到分派,得到 "does not exist"
|
||||
//
|
||||
// 两者文本完全不同,无从混淆。这样判据不需要注册探针工具、不需要造插件,
|
||||
// 也不会因为工具不存在而"碰巧"通过。
|
||||
func TestAgentAssemblyWiresSecurity(t *testing.T) {
|
||||
m := bootMachine(t)
|
||||
ks := m.ks
|
||||
|
||||
if ks.Security == nil {
|
||||
t.Fatal("★ security 模块没产出 ks.Security(装配链断了)")
|
||||
}
|
||||
if ks.Agent == nil {
|
||||
t.Fatal("★ agent 模块没产出 ks.Agent")
|
||||
}
|
||||
|
||||
const deniedTool = "boot_probe_denied_tool"
|
||||
|
||||
// ① 先证明这个工具名**没被注册**(否则下面"not permitted"可能来自别处)。
|
||||
out, _ := ks.Agent.ExecuteTool(deniedTool, nil)
|
||||
if strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("前置不成立:未配黑名单时就已被拒(%q)—— 检查环境里的实际策略", out)
|
||||
}
|
||||
|
||||
// ② 关键一步:给**模块化产出**的 ks.Security 装黑名单。
|
||||
// 如果 Agent 里拿的是另一个实例(或 nil),它看不到这条 ⇒ 判据红。
|
||||
ks.Security.SetPolicy(security.Policy{ToolDeny: []string{deniedTool}})
|
||||
|
||||
out, _ = ks.Agent.ExecuteTool(deniedTool, nil)
|
||||
if !strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("★ 已给 ks.Security 配 %q 黑名单,Agent 的执行链却没拦住(实际返回 %q)。\n"+
|
||||
" 可能原因(按概率):\n"+
|
||||
" ① buildAgent 没把 ks.Security 传进 AgentConfig.Security\n"+
|
||||
" ② New() 没把 cfg.Security 写进 a.security\n"+
|
||||
" ③ 执行链上少了 securityDecisionFor 这道闸\n"+
|
||||
" 三者任一都会让策略表正确而完全无效,且日志里一条拒绝都没有。",
|
||||
deniedTool, out)
|
||||
}
|
||||
|
||||
// ③ 反向:未命中的工具必须放行。
|
||||
// 否则是"全拒"型假绿(闸门写死了),而不是接线正确。
|
||||
if out, _ := ks.Agent.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("未命中黑名单的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 判据 3:子 agent 继承闸门 ─────────────────
|
||||
|
||||
// ★★★ 判据:驻留子 / rtchild 的 AgentConfig **必须**带 Security。
|
||||
//
|
||||
// ## 为什么单列一条
|
||||
//
|
||||
// 子 agent 各自 `New(AgentConfig{...})`,不走 buildAgent 那条路。
|
||||
// 只修主 agent 的话,两类子 agent 会**静默失去闸门**,而它们恰恰是
|
||||
// 最需要闸门的执行体:
|
||||
//
|
||||
// 驻留子:长期挂着、自己跑调度器、长时间无人审阅
|
||||
// rtchild:通话中一边听一边执行工具,没有人工复核窗口
|
||||
//
|
||||
// 这类"父修好了、子还是坏的"在本仓已有先例:`StopRTChildren` 漏注销
|
||||
// 入站登记(与单数路径不对称)、resident 与 rtchild 各拼一次通道名。
|
||||
// 不对称是这套代码的主要缺陷形态。
|
||||
//
|
||||
// ## 为什么用源码断言而不是行为断言
|
||||
//
|
||||
// 创建真驻留子需要完整记忆栈 + temp 图库;创建 rtchild 需要真实 rt 通道。
|
||||
// 两者都会把本判据变成"前置不满足就 skip",而 skip 的判据等于没有。
|
||||
// 本仓已有同型先例:TestHiddenDependencyOnnxDeclared 就是读 textmem/mod.go
|
||||
// 源码断言 FacONNX 声明 —— 因为"层号断言"在那里会假绿。
|
||||
//
|
||||
// ★ 这条判据**能被变异咬住**:删掉任一 `Security: a.security` 行即红。
|
||||
func TestSubAgentsInheritSecurity(t *testing.T) {
|
||||
cases := []struct {
|
||||
file string
|
||||
why string
|
||||
}{
|
||||
{"../../agent/core/resident.go",
|
||||
"驻留子是长期挂着、无人盯的执行体,自己跑调度器、接自己的输入 —— 无闸门等于策略对它完全无效"},
|
||||
{"../../agent/core/rtchild.go",
|
||||
"rtchild 在通话中一边听用户说话一边执行工具,没有任何人工复核窗口"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
src := readMod(t, c.file)
|
||||
if !strings.Contains(src, "Security:") {
|
||||
t.Errorf("★ %s 里没有 `Security:` 字段 —— %s。\n"+
|
||||
" 修法:该处 New(AgentConfig{...}) 加 `Security: a.security`(继承父的)。",
|
||||
c.file, c.why)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(src, "Security: a.security") &&
|
||||
!strings.Contains(src, "Security: a.security") {
|
||||
t.Errorf("★ %s 里有 `Security:` 但它不是 `a.security` —— "+
|
||||
"子 agent 必须继承**父的那一个实例**,另建一个会让策略在两处分叉",
|
||||
c.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 判据 4:装配失败时响亮,而不是静默放行 ─────────────────
|
||||
|
||||
// ★★★ 判据:`ks.Security == nil` 时 agent 模块必须**拒绝装配**。
|
||||
//
|
||||
// 防的失败方式:把 nil 一路传下去 —— Agent 装好了、日志写着 "kernel ready"、
|
||||
// 工具照常执行,只是策略永远不生效。本项目已经踩过一次这个坑
|
||||
// ("策略表存在、判定函数正确、日志里一条拒绝都没有")。
|
||||
//
|
||||
// 注意区分:配置显式关闭安全模块时装的是 **Disabled Runtime(非 nil)**,
|
||||
// 所以本判据不会误伤"用户选择不用安全模块"这个合法场景。
|
||||
//
|
||||
// 实现要点:用**真实模块的 Init**,不是一个复制品。bootMachine 已把整机跑过
|
||||
// 一遍(所有依赖真实就绪),这里借它的 KernelState 复制一份、只破坏一个前提。
|
||||
// agent.Init 的 nil 检查在所有副作用之前(第一个 if),所以本判据不产生资源。
|
||||
func TestAgentAssemblyRefusesNilSecurity(t *testing.T) {
|
||||
m := bootMachine(t)
|
||||
base := m.ks
|
||||
if base.Security == nil {
|
||||
t.Fatal("前置失败:整机装配没产出 Security")
|
||||
}
|
||||
|
||||
var agentMod kernelboot.Module
|
||||
for _, mod := range kernelmod.Registered() {
|
||||
if mod.Name() == "agent" {
|
||||
agentMod = mod
|
||||
}
|
||||
}
|
||||
if agentMod == nil {
|
||||
t.Fatal("★ agent 模块没注册")
|
||||
}
|
||||
|
||||
// ★ 不能复制 KernelState(它含 sync.Mutex,复制即 vet 报错,且真的不安全)。
|
||||
// 改为新建一个:把**真实状态里已就绪的全部依赖**逐字段搬过去,
|
||||
// 唯独 Security 留 nil —— 这样 Init 只会因为这一个前提失败。
|
||||
//
|
||||
// 为什么这样做而不是直接用 base:Init 成功时会覆盖 base.Agent,
|
||||
// 破坏后续判据的上下文。新建一份是干净的。
|
||||
var probe kernelboot.KernelState
|
||||
probe.Cfg = base.Cfg
|
||||
probe.CfgReg = base.CfgReg
|
||||
probe.DataDir = base.DataDir
|
||||
probe.AgentWorkDir = base.AgentWorkDir
|
||||
probe.CLISocket = base.CLISocket
|
||||
probe.HTTPAddr = base.HTTPAddr
|
||||
probe.Ctx = base.Ctx
|
||||
probe.Memory = base.Memory
|
||||
probe.LLMProvider = base.LLMProvider
|
||||
probe.LLMManager = base.LLMManager
|
||||
probe.Tracker = base.Tracker
|
||||
probe.DocStore = base.DocStore
|
||||
probe.Knowledge = base.Knowledge
|
||||
probe.TextMemory = base.TextMemory
|
||||
probe.StageHost = base.StageHost
|
||||
probe.PluginRegistry = base.PluginRegistry
|
||||
probe.EventBus = base.EventBus
|
||||
probe.Embedder = base.Embedder
|
||||
probe.MultimodalSpace = base.MultimodalSpace
|
||||
probe.MultimodalProviderName = base.MultimodalProviderName
|
||||
probe.MultimodalErr = base.MultimodalErr
|
||||
probe.BaseAPIKey = base.BaseAPIKey
|
||||
probe.Security = nil // ← 唯一被破坏的前提
|
||||
|
||||
err := agentMod.Init(&probe)
|
||||
if err == nil {
|
||||
t.Fatal("★ ks.Security 为 nil 时 agent 模块居然装配成功了 —— " +
|
||||
"nil 会被一路传到 securityDecisionFor,那里恒放行且不留任何日志。\n" +
|
||||
" 必须在 Init 里响亮失败(与 device 模块对 ks.Security 的检查同型)。")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "security") {
|
||||
t.Fatalf("失败信息必须点名缺失的设施(便于运维一眼定位),实际: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user