mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
544 lines
24 KiB
Go
544 lines
24 KiB
Go
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"
|
||
|
||
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||
"testing"
|
||
|
||
agentcore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||
)
|
||
|
||
// ───────────────── 判据 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"可能来自别处)。
|
||
// ks.Agent 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环)。
|
||
// 本判据要真实执行工具,断言回具体类型。
|
||
agentImpl, _ := ks.Agent.(*agentCore.Agent)
|
||
if agentImpl == nil {
|
||
t.Fatal("ks.Agent 不是 *agentCore.Agent")
|
||
}
|
||
out, _ := agentImpl.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, _ = agentImpl.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, _ := agentImpl.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") {
|
||
t.Fatalf("未命中黑名单的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", out)
|
||
}
|
||
}
|
||
|
||
// ───────────────── 判据 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 kstate.State
|
||
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)
|
||
}
|
||
}
|
||
|
||
// ───────────────── 判据 4:父为子**声明**策略,而不是子继承父的权限 ─────────────────
|
||
|
||
// ★★★ 判据:父创建子 agent 时,父声明的工具策略必须**真的限制住那个子**,
|
||
// 且**只影响那个子**(不是全局)。
|
||
//
|
||
// ## 为什么这版取代了上一版(用户裁定 2026-10-30)
|
||
//
|
||
// 上一版的判据断言「子继承父的 `a.security`」—— 那只保证了**机制**到位,
|
||
// 却把权限模型写错成了「子 = 父的完整权限」。
|
||
//
|
||
// 正确模型是 **父为子声明策略**:
|
||
//
|
||
// 父:能调全部工具(受全局策略约束)
|
||
// 子:父在创建时决定它能调什么 —— 内核持有并执行,子不能自己扩大
|
||
//
|
||
// 这不是新机制:`security.CheckToolFor(Principal, tool)` 与
|
||
// `PrincipalPolicy{AllowTools, DenyTools}` 早就在树里,只是**零生产调用方**
|
||
// (与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
|
||
//
|
||
// ## 本判据钉三件事
|
||
//
|
||
// ① 父声明的 deny 真的拦住**子**
|
||
// ② 同一条策略**不影响父**(不是全局策略)
|
||
// ③ 子销毁后策略被撤销(同名重建不能继承上一代父的约束)
|
||
func TestParentDeclaresChildPolicy(t *testing.T) {
|
||
rt := security.NewRuntime(nil)
|
||
|
||
// 父:parentID 为空 ⇒ 走 CheckTool(只受全局策略约束)。
|
||
const forbidden = "resident_denied_tool"
|
||
parent := agentcore.New(agentcore.AgentConfig{
|
||
ID: "main",
|
||
Provider: nil,
|
||
Security: rt,
|
||
})
|
||
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||
t.Fatalf("父不应受限(它是内核自身,此工具不受任何全局策略限制): %s", d.Reason)
|
||
}
|
||
|
||
// 父为子声明策略:允许 probe_ok_*,禁止 forbidden。
|
||
childID := "policy-probe-child"
|
||
if err := parent.ProbeDeclareChildPolicy(childID, []string{"probe_ok_*"}, []string{forbidden}); err != nil {
|
||
t.Fatalf("父为子声明策略失败: %v", err)
|
||
}
|
||
|
||
// ① 子被判住:子 parentID 非空 ⇒ 走 CheckToolFor(Principal{self})
|
||
child := agentcore.New(agentcore.AgentConfig{
|
||
ID: types.AgentID(childID),
|
||
ParentID: "main",
|
||
Provider: nil,
|
||
Security: rt, // 同一个 Runtime 实例(它只是机制,权限靠主体声明)
|
||
})
|
||
if d := child.ProbeSecurityDecision(forbidden); d.Allowed {
|
||
t.Fatalf("★ 父已为子 %q 声明禁止 %q,子的判定却仍是 Allowed —— "+
|
||
"主体策略没有被工具执行链读到(CheckToolFor 未接线)", childID, forbidden)
|
||
}
|
||
if d := child.ProbeSecurityDecision("other_tool"); d.Allowed {
|
||
t.Fatal("★ 父给了 probe_ok_* 白名单,other_tool 却仍被放行 —— 白名单未生效")
|
||
}
|
||
if d := child.ProbeSecurityDecision("probe_ok_1"); !d.Allowed {
|
||
t.Fatalf("白名单内的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", d.Reason)
|
||
}
|
||
|
||
// ② 同一条策略不影响父(主体隔离)。
|
||
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||
t.Fatal("★ 子策略泄漏成了全局策略 —— 父也被禁了。主体隔离失效")
|
||
}
|
||
|
||
// ③ 撤销后子恢复(同名重建不能继承上一代父的约束)。
|
||
parent.ProbeRevokeChildPolicy(childID)
|
||
if d := child.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||
t.Fatal("★ 策略已撤销,子却仍被禁 —— 主体表以调用方给的名字为键," +
|
||
"不清理就会让同名重建的新一代继承上一代父的约束(与 rtchild 留档串线同型)")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:**销毁路径必须撤销策略**(两处对称:单数与复数)。
|
||
//
|
||
// 这条单列,因为本仓反复吃亏在"单数路径改了、复数路径漏了":
|
||
// `StopRTChild` 注销入站登记而 `StopRTChildren` 漏了,就是这么来的。
|
||
//
|
||
// ## 2026-11-04 适配:撤销动作已移入骨架
|
||
//
|
||
// R2/R3 把"撤销策略"从两个停止函数搬进了 `child.Registry.Cleanup`,
|
||
// 由各类型注入的 `StopDeps.RevokePolicy` 触发。于是判据需要钉**两个环节**:
|
||
//
|
||
// ① 骨架真的会调用注入的 RevokePolicy(否则注入等于没接)
|
||
// ② 各类型真的注入了它(否则骨架有地方调、但没人填)
|
||
//
|
||
// 只钉 ①:注入漏填时永远绿。
|
||
// 只钉 ②:骨架不调时永远绿。
|
||
//
|
||
// 两个环节都缺失才是真实缺口,所以必须**成对**断言。
|
||
//
|
||
// 行为版判据放在 `internal/agent/core`(那里有真实脚手架),
|
||
// 本处保留源码级断言,因为它能跨包检查"装配是否被人改掉"。
|
||
func TestChildTeardownRevokesPolicy(t *testing.T) {
|
||
// ① 骨架:Cleanup 必须调用 deps.RevokePolicy。
|
||
//
|
||
// 变红条件:删掉 registry.go 里那两行 ⇒ 红。
|
||
reg := readMod(t, "../../agent/core/child/registry.go")
|
||
ci := strings.Index(reg, "func (r *Registry) Cleanup(")
|
||
if ci < 0 {
|
||
t.Fatal("找不到 Registry.Cleanup —— 符号改名了?判据需要跟着改")
|
||
}
|
||
cleanupBody := reg[ci:]
|
||
if end := strings.Index(cleanupBody, "\n}\n"); end > 0 {
|
||
cleanupBody = cleanupBody[:end]
|
||
}
|
||
if !strings.Contains(cleanupBody, "deps.RevokePolicy") {
|
||
t.Error("★ Cleanup 没有调用 deps.RevokePolicy —— 子策略永远不会被撤销。\n" +
|
||
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n" +
|
||
" 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。")
|
||
}
|
||
|
||
// ② 各类型:必须往 StopDeps 里填 RevokePolicy。
|
||
//
|
||
// rt 走 children.StopOne/StopAll ⇒ 由 rtStopDeps 提供。
|
||
// 驻留子走自己的 teardownResidentWith ⇒ 那里直接调。
|
||
cases := []struct{ file, anchor, needle, why string }{
|
||
{"../../agent/core/rtchild.go", "func (a *Agent) rtStopDeps(",
|
||
"RevokePolicy: a.revokeChildPolicy",
|
||
"rtchild 的 StopDeps 必须注入撤销动作"},
|
||
{"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(",
|
||
"revokeChildPolicy",
|
||
"驻留子销毁必须撤销父为它声明的策略"},
|
||
}
|
||
for _, c := range cases {
|
||
src := readMod(t, c.file)
|
||
i := strings.Index(src, c.anchor)
|
||
if i < 0 {
|
||
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.anchor)
|
||
}
|
||
body := src[i:]
|
||
if end := strings.Index(body, "\n}\n"); end > 0 {
|
||
body = body[:end]
|
||
}
|
||
if !strings.Contains(body, c.needle) {
|
||
t.Errorf("★ %s 里没有 %q —— %s。\n"+
|
||
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+
|
||
" 上一代父留下的约束,且表单调增长。", c.anchor, c.needle, c.why)
|
||
}
|
||
}
|
||
|
||
// ③ 单数与复数必须**共用同一条骨架路径**(这是"对称"的结构保证)。
|
||
//
|
||
// 变红条件:StopRTChildren 改回自己写一遍循环 + 撤销 ⇒ 红。
|
||
rt := readMod(t, "../../agent/core/rtchild.go")
|
||
for _, fn := range []string{
|
||
"func (a *Agent) StopRTChild(", "func (a *Agent) StopRTChildren(",
|
||
} {
|
||
i := strings.Index(rt, fn)
|
||
if i < 0 {
|
||
t.Fatalf("找不到 %s", fn)
|
||
}
|
||
body := rt[i:]
|
||
if end := strings.Index(body, "\n}\n"); end > 0 {
|
||
body = body[:end]
|
||
}
|
||
if !strings.Contains(body, "a.children.Stop") {
|
||
t.Errorf("★ %s 没走骨架的 StopOne/StopAll —— 单数与复数各写一遍时,"+
|
||
"必然出现\"改一处漏一处\"(engineering-lessons §2.3)", fn)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:创建路径必须在**子开始执行前**声明策略。
|
||
//
|
||
// 顺序错(比如在 go carrier.rtLoop 之后声明)会留下一个"无约束执行"窗口:
|
||
// 子一接到输入就会跑工具,那时策略可能还没进内核。
|
||
func TestPolicyDeclaredBeforeChildStarts(t *testing.T) {
|
||
cases := []struct{ file, declAnchor, startAnchor, name string }{
|
||
{"../../agent/core/resident.go", "declareChildPolicy(opts.ID", "child.Start()", "驻留子"},
|
||
{"../../agent/core/rtchild.go", "declareChildPolicy(opts.ID", "go carrier.rtLoop(rc)", "rtchild"},
|
||
}
|
||
for _, c := range cases {
|
||
src := readMod(t, c.file)
|
||
di := strings.Index(src, c.declAnchor)
|
||
si := strings.Index(src, c.startAnchor)
|
||
if di < 0 || si < 0 {
|
||
t.Fatalf("%s: 找不到锚点(decl=%v start=%v)", c.file, di >= 0, si >= 0)
|
||
}
|
||
if di > si {
|
||
t.Errorf("★ %s:策略声明在 `%s` **之后** —— 存在一个「子已开始执行、策略尚未生效」的窗口",
|
||
c.name, c.startAnchor)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────────── 判据 5:策略语义只能在 security 内部 ─────────────────
|
||
|
||
// ★★★ 判据:`agent/core` **不得**再做策略决策。
|
||
//
|
||
// ## 收敛前它做了什么(2026-10-31)
|
||
//
|
||
// security_profile.go 里 214 行,其中绝大部分是策略逻辑:
|
||
// · 自己从 payload 抠 `security_profile` 字段
|
||
// · 自己判断 "parentID != "" → 走 CheckToolFor,否则 CheckTool"
|
||
// · 自己拼 `PrincipalPolicy{AllowTools:…, DenyTools:…}` 字面量
|
||
// · 自己写 `Kind: "resident"` **声明自己的主体类别**
|
||
//
|
||
// 最后一条是提权口子:调用方说自己是内部主体,就享受内部豁免
|
||
// (不过 HardPrivateTools)。类别必须由 security 从**事实**推出。
|
||
//
|
||
// ## 本判据用什么钉
|
||
//
|
||
// 源码断言(跨包行为断言做不到"禁止某种写法")。三类越界各一条正则:
|
||
//
|
||
// ① `payload["security_profile"]` 出现在 agent/core ⇒ 解析回落调用方
|
||
// ② `Kind:` 字面量出现在 agent/core 的安全文件 ⇒ 调用方声明类别
|
||
// ③ `CheckTool(` / `CheckToolFor(` 直接出现在 agent/core ⇒ 越级选链
|
||
//
|
||
// ★ 例外:`security_profile.go` 的**注释**里会提到这些名字(解释历史)。
|
||
// 所以逐行判断,跳过纯注释行。
|
||
func TestAgentCoreHoldsNoPolicySemantics(t *testing.T) {
|
||
src := readMod(t, "../../agent/core/security_profile.go")
|
||
|
||
// 会被禁止的代码形态(不含注释)。
|
||
banned := []struct{ pattern, why string }{
|
||
{`payload["security_profile"]`,
|
||
"档位字段名是 security 的协议,解析必须留在 security.BeginRound"},
|
||
{`Kind:`,
|
||
"主体类别必须由 security 从事实(有无父)推出,调用方不得声明"},
|
||
{`.CheckToolFor(`,
|
||
"选哪条判定链是策略决策,调用方只该调 Round.CheckTool"},
|
||
{`PrincipalPolicy{`,
|
||
"策略结构由 security 组装,调用方只该传 allow/deny 列表"},
|
||
}
|
||
for _, b := range banned {
|
||
for i, line := range strings.Split(src, "\n") {
|
||
trimmed := strings.TrimSpace(line)
|
||
if strings.HasPrefix(trimmed, "//") {
|
||
continue // 注释里解释历史是允许的
|
||
}
|
||
if strings.Contains(line, b.pattern) {
|
||
t.Errorf("★ agent/core/security_profile.go:%d 出现 %q —— %s\n\t%s",
|
||
i+1, b.pattern, b.why, trimmed)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:主体类别由 security 从**事实**推出,且**不信**调用方声明。
|
||
//
|
||
// 收敛前 `Kind: "resident"` 由 agent 写死 —— 它同时意味着"我是内部"
|
||
// 与"我不受收窄",把两件不同的事绑在一个字符串上。现在分类规则只有一处
|
||
// (`classifyPrincipal`),本判据钉住三条映射。
|
||
func TestPrincipalKindIsDerivedBySecurity(t *testing.T) {
|
||
rt := security.NewRuntime(nil)
|
||
|
||
// ① 有父 ⇒ 子执行体(内部豁免,但可被父收窄)
|
||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "kid", IsChild: true}); p.Kind != security.KindSubAgent {
|
||
t.Fatalf("有父的执行体应归为 KindSubAgent,实际 %q", p.Kind)
|
||
} else if !p.IsInternal() || !p.IsSubAgent() {
|
||
t.Fatal("KindSubAgent 必须同时满足 IsInternal(豁免 HardPrivate)与 IsSubAgent(可被收窄)")
|
||
}
|
||
|
||
// ② 无 id ⇒ 内核自身
|
||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{}); p.Kind != security.KindInternal {
|
||
t.Fatalf("无 id 应归为 KindInternal,实际 %q", p.Kind)
|
||
}
|
||
|
||
// ③ 有 id 无父 ⇒ 用户
|
||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "qq-user-1"}); p.Kind != security.KindUser {
|
||
t.Fatalf("经插件通道进来的应归为 KindUser,实际 %q", p.Kind)
|
||
}
|
||
|
||
// ④ 枚举不接受自由字符串:拼错必须能被识别(而不是静默变成用户主体)
|
||
if _, ok := security.ParsePrincipalKind("residnet"); ok {
|
||
t.Fatal("★ 拼错的主体类别被当成合法值 —— 拼写错误会静默漂移权限语义")
|
||
}
|
||
if k, ok := security.ParsePrincipalKind("resident"); !ok || k != security.KindSubAgent {
|
||
t.Fatal("旧值 resident 应兼容映射为 KindSubAgent(不破坏既有调用方)")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:轮次边界在**全部输入路径**上都被声明。
|
||
//
|
||
// 收敛把判定逻辑收到了一处,但"一轮何时开始/结束"只有各路径自己知道。
|
||
// 本仓已有三条独立输入路径,漏一条就是"那类 agent 永远没有档位":
|
||
//
|
||
// 主 agent + 驻留子 → runInputTask(驻留子有自己的 Agent 实例)
|
||
// rtchild → rtRunOnce(不走调度器,单独一条)
|
||
//
|
||
// 用源码断言:真跑三条路径需要完整记忆栈/rt 通道,
|
||
// 而"前置不满足就 skip"的判据等于没有(与 TestChildTeardownRevokesPolicy 同一理由)。
|
||
func TestEveryInputPathDeclaresRound(t *testing.T) {
|
||
cases := []struct{ file, fn, why string }{
|
||
{"../../agent/core/task.go", "func (a *Agent) runInputTask(",
|
||
"主 agent 与驻留子共用的输入入口"},
|
||
{"../../agent/core/rtchild.go", "func (a *Agent) rtRunOnce(",
|
||
"rtchild 不走调度器、不经过 runInputTask,是独立的一条输入路径"},
|
||
}
|
||
for _, c := range cases {
|
||
src := readMod(t, c.file)
|
||
i := strings.Index(src, c.fn)
|
||
if i < 0 {
|
||
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn)
|
||
}
|
||
body := src[i:]
|
||
if end := strings.Index(body, "\n}\n"); end > 0 {
|
||
body = body[:end]
|
||
}
|
||
if !strings.Contains(body, "beginSecurityRound(") {
|
||
t.Errorf("★ %s 里没有 beginSecurityRound —— %s 缺少安全轮次边界。\n"+
|
||
" 后果:这条路径上的输入永远不会激活插件声明的档位,\n"+
|
||
" 而它在**其他**路径上是生效的 ⇒ 表现为「时灵时不灵」,最难查。",
|
||
c.fn, c.why)
|
||
}
|
||
if !strings.Contains(body, "End()") {
|
||
t.Errorf("★ %s 里没有 End() —— 档位会在轮次结束后泄漏到下一轮,"+
|
||
"guest 的收窄误伤后续输入(方向相反就是提权)", c.fn)
|
||
}
|
||
}
|
||
}
|