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

@ -369,6 +369,31 @@ type AgentConfig struct {
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
// 父 agent 创建驻留子时用它收窄子的输出能力。
AllowedOutputs []string
// Security 是安全防火墙运行时(security 模块产出,可 nil)。
//
// ## 为什么它是**装配期字段**而不是事后 Set
//
// 这个字段曾经只能靠 `SetSecurity()` 注入,而生产装配链里**没有任何
// 地方调用它** —— 唯一的调用方是 4 条测试。于是 `securityDecisionFor`
// 在生产中恒走 nil 分支、恒放行,而策略表本身完全正确:这是
// 「机制 ≠ 接线」的教科书样本(GUI 无拒绝记录,看起来像"没人违规")。
//
// ★ 根因在**依赖图**,不在这行赋值:`agent.Requires()` 没声明
// `FacSecurity`,图就没有理由把 security 的产物交给 agent(只有声明了
// 的 device 拿到了)。所以修复必须同时改三处:
// ① kernelmod/agent 的 Requires() 加 FacSecurity
// ② 这里加字段 + New() 赋值 + buildAgent 传 k.Security
// ③ 装配级判据(删 ① 或 ② 必须红)
// 只做 ②不做 ① 会让代码读 `k.Security` 而图不保证它已就绪 ——
// 今天恰好对(security 在第 1 层)是运气,不是设计。
//
// ## 子 agent 必须继承
//
// 驻留子 / rtchild 各自 `New(AgentConfig{...})`,不继承就等于子 agent
// 完全没有闸门(而它们恰是"能指挥设备、能写记忆"的高风险执行体)。
// 见 resident.go / rtchild.go 里 `Security: a.security`。
Security *security.Runtime
// IsRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
//
// 置 true 后:
@ -539,8 +564,11 @@ func New(cfg AgentConfig) *Agent {
embeddingProvider: cfg.EmbeddingProvider,
embeddingError: cfg.EmbeddingError,
fusionCfg: cfg.FusionCfg,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
// ★ 安全运行时在**构造期**落位(不再依赖事后 SetSecurity)。
// 为 nil 表示调用方显式不启用(独立工具/单测),不是"忘了接线"。
security: cfg.Security,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent

View File

@ -199,6 +199,12 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
// ★ 安全运行时必须继承(2026-10-30 补)。
//
// 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、
// 长时间不被人工审阅。它是本仓**最需要闸门**的一类 agent,
// 而原先它是唯一**完全没有闸门**的(子从 New 起 a.security 就是 nil)。
Security: a.security,
})
rc := &residentChild{

View File

@ -488,7 +488,14 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
AllowedOutputs: []string{opts.Output},
KernelSource: string(a.id),
ParentID: string(a.id),
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
// ★ 安全运行时**必须继承父的**(2026-10-30 补)。
//
// rtchild 是高频、低延迟的执行体,且它照样能调工具(R3:rt 流不禁工具)。
// 不继承的后果与主 agent 同型:`securityDecisionFor` 恒走 nil ⇒ 恒放行。
// 而它比主 agent 更危险 —— 通话场景下它一边听用户说话一边执行工具,
// 没有任何人工复核的窗口。
Security: a.security,
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
})
// ★ 不调 carrier.Start():那会起 schedulerLoop / interceptLoop / 3 个记忆
// 整理循环。rt 要的是"不吃队列、无轮询",这些 goroutine 对它是**反向**的。

View File

@ -50,10 +50,27 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型
// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。
//
// ★ 2026-10-29:本函数此前是**死代码**(`a.security` 从未被赋值 ⇒ 永远
// 走 nil 分支 ⇒ 永远放行),`Policy.CheckTool` 因此对工具执行链完全不生效。
// 修复分两步:① 补 `SetSecurity` 注入通道;② 在 `executeToolCallInner`
// 的**所有实际执行分支之前**调用本函数。
// ## 历史(两轮都错,值得记住)
//
// ★ 第一轮(2026-10-29)的诊断是「`a.security` 从未被赋值」,于是补了
// `SetSecurity()`。但**生产装配链从来没调它** —— 唯一的调用方是 4 条测试。
// 那 4 条判据全绿,因为它们在测试里**手动**调了 `a.SetSecurity(...)`:
// 判据自己把缺失的那根线补上了,所以永远绿。
//
// ★ 第二轮(2026-10-30)才看清根因:`kernelmod/agent` 的 `Requires()`
// **没有声明 `FacSecurity`**,依赖图就没有理由把 security 的产物交给
// agent(只有声明了的 device 拿到了)。所以这不是"少一行赋值",
// 而是"模块边界没连上"。
//
// 现在的链路:
//
// security.Init → ks.Security
// → buildAgent 传 AgentConfig.Security
// → New() 写 a.security
// → 子 agent(resident/rtchild)继承 a.security
// → 本函数 → CheckTool
//
// 判据钉的是**装配**(kernelmod/all 的整机判据),不是本函数被手动调用后的行为。
func (a *Agent) securityDecisionFor(tool string) security.Decision {
if a == nil || a.security == nil {
return security.Decision{Allowed: true}
@ -61,13 +78,16 @@ func (a *Agent) securityDecisionFor(tool string) security.Decision {
return a.security.CheckTool(tool)
}
// SetSecurity 注入安全运行时(装配期调用,幂等)。
// SetSecurity 注入安全运行时(**仅限测试与独立工具**)。
//
// ## 为什么需要显式注入点
// ## 不要在生产装配里调它
//
// `Agent.security` 曾经是**只有测试赋过值**的字段:生产装配链里没有任何
// 地方写它,于是 `securityDecisionFor` 恒定放行。这不是"策略写错了",
// 而是"策略根本没接上",而两种故障在日志上看起来一样(都是没有拒绝记录)。
// 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图
// 保证 `ks.Security` 已就绪。用 SetSecurity 事后补会造出一个图看不见的
// 隐式依赖 —— 正是本仓反复踩的那个坑。
//
// 保留它是因为 4 条进程内判据需要一个"只换安全运行时"的开关,
// 而它们不跑整机装配。
//
// ## 为什么允许 nil
//