From 1d007e1d49b4d5aa0d0659f4f695ab54941c32fd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 30 Oct 2026 23:54:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E5=B7=A5=E5=85=B7=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E9=97=B8=E7=9C=9F=E7=9A=84=E6=8E=A5=E4=B8=8A=E4=BA=86?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=A0=B9=E5=9B=A0=E6=98=AF=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=9B=BE=E5=B0=91=E4=B8=80=E6=9D=A1=E8=BE=B9=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E5=B0=91=E4=B8=80=E8=A1=8C=E8=B5=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷 生产日志实证:`[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)。 --- internal/agent/core/agent.go | 32 ++- internal/agent/core/resident.go | 6 + internal/agent/core/rtchild.go | 9 +- internal/agent/core/security_profile.go | 38 ++- internal/kernelmod/agent/mod.go | 45 ++- .../kernelmod/all/security_wiring_test.go | 269 ++++++++++++++++++ 6 files changed, 384 insertions(+), 15 deletions(-) create mode 100644 internal/kernelmod/all/security_wiring_test.go diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 47e7c76c..6e4110fc 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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 diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 73926212..a624ea77 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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{ diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index e9ff1a0c..cd8b8bf8 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -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 对它是**反向**的。 diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index 1cf9adda..04f1146c 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -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 // diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index b32c53a6..78d7de16 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -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, }) // 图记忆读写钩子 → 事件总线。 diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go new file mode 100644 index 00000000..540c9711 --- /dev/null +++ b/internal/kernelmod/all/security_wiring_test.go @@ -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) + } +}