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:
JianFeeeee
2026-10-30 23:54:08 +08:00
parent fbccfe17ed
commit 1d007e1d49
6 changed files with 384 additions and 15 deletions

View File

@ -36,7 +36,25 @@ func (Module) Name() string { return "agent" }
func (Module) Requires() []string {
return []string{kernelmod.FacLLM, kernelmod.FacMemory, kernelmod.FacTracker,
kernelmod.FacDocStore, kernelmod.FacKnowledge, kernelmod.FacTextMem,
kernelmod.FacStageHost}
kernelmod.FacStageHost,
// ★ FacSecurity(2026-10-30 补):这是**真实存在**的依赖,
// 之前只是没在图上声明。
//
// 为什么它必须在这里而不是“反正 security 在第 1 层、agent 在第 4 层”:
//
// 1. 图是**唯一**保证顺序的地方。不声明 => `buildAgent` 读 `ks.Security`
// 就是读一个“恰好已被别人填好”的字段,那是隐式顺序依赖,
// 正是本模块化设计要消灭的东西(见 kernelboot.Module 的 Requires 注释)。
// 2. 如果将来 security 因为新依赖被推到第 5 层,本模块的
// `ks.Security == nil` 检查会**在运行期**报错 —— 而声明了依赖,
// 图会直接把它排后,运行期不会发生。
// 3. 判据 TestAgentDeclaresSecurityDependency 钉住它(删掉必红)。
//
// 是否应该反过来把工具执行完全迁进 device,让 agent 不持有 security?
// 那是 device 第二阶段的事(见 device/mod.go 包注释)。当前阶段 toolcall
// 仍在 core,所以依赖是真实的,就该如实声明。
kernelmod.FacSecurity,
}
}
func (Module) Provides() []string { return []string{kernelmod.FacAgent} }
@ -50,6 +68,18 @@ func (Module) Init(k *kernelboot.KernelState) error {
if k.PluginRegistry == nil {
return errMissing("agent", kernelmod.FacStageHost)
}
// ★ FacSecurity 已声明在 Requires 里,到这里它必须非 nil。
//
// 为何在这里“响亮失败”而不让 nil 传给 Agent:nil 会让
// `securityDecisionFor` 恒放行,而策略表本身完全正确 —— 结果是
// “配置里写了黑名单、日志里一条拒绝都没有”,看起来像没人违规。
// 这种静默降级比启动失败难查得多。
//
// 注意区分:**配置显式关闭**时 security 模块装的是 Disabled Runtime
// (非 nil,零成本透传),所以这里报错只可能是装配顺序错了。
if k.Security == nil {
return errMissing("agent", kernelmod.FacSecurity)
}
k.Agent = buildAgent(k)
// 插件 SDK 接线(必须在 loadPlugins 之前)。
@ -89,6 +119,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
mmErr := k.MultimodalErr
stageHost := k.StageHost
evBus := k.EventBus
secRT := k.Security
personality := loadPersonality(cfg, cfgReg)
@ -165,8 +196,16 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
EmbeddingError: mmErr,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
InputProcessing: cfg.InputProcessing,
// ★ 安全运行时必须在**构造期**传入(2026-10-30)。
//
// 依赖链:security.Init → ks.Security → 这里 → AgentConfig.Security
// → New() 写 a.security → securityDecisionFor → CheckTool。
//
// 判据 TestAgentAssemblyWiresSecurity 走真实登记表装配 + 真工具执行,
// 删掉本行或 Requires 里的 FacSecurity 都会红。
Security: secRT,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
InputProcessing: cfg.InputProcessing,
})
// 图记忆读写钩子 → 事件总线。