Files
HomeAgent/internal/kernelmod/all/security_wiring_test.go
JianFeeeee 5194a96560 refactor(security): 安全策略全面收敛到 security 模块 —— agent 只提供事实,不再做策略决策
## 架构定位(用户澄清)

security 是**外围内核到核心内核之间的防火墙**:自己提供接口、判定内聚在自己内部。
device 是设备抽象模块、持有 channel,两者不冲突。

现成的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
总线只交事件,guard 自己判,总线一行策略都没有。本次把同样的形状套到输入/主体上。

## 收敛前的问题

`agent/core/security_profile.go` 214 行里绝大部分是**策略逻辑**:

	name, _ := payload["security_profile"].(string)   // ① 解析 security 的协议
	a.security.ActivateProfile(name, 0)
	if a.parentID != "" {                             // ② 选哪条判定链
	    return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
	}
	a.security.DeclarePrincipalPolicy("id", id, PrincipalPolicy{...})  // ③ 拼策略结构

三个后果:

① **协议解析外泄**:`security_profile` 是 security 的概念,解析却在外人手里
② **策略决策外泄**:判断"父走 CheckTool / 子走 CheckToolFor"是策略语义
③ **提权口子**:`Kind: "resident"` 是调用方**声明自己的类别** —— 说自己是
  内部主体就享受内部豁免(不过 HardPrivateTools)。类别必须从事实推出

且三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效。

## 收敛后的形态

security 新增 `round.go`(自己提供接口):

	RoundInput{AgentID, IsChild, Payload, Source}  ← 只有事实,**无 Kind**
	rt.BeginRound(in)  → security 解析档位 + 判定主体类别
	round.CheckTool(t) → 本轮判定(调用方不需要知道任何组合规则)
	round.End()        → 归位(防跨轮泄漏)
	rt.DeclareChildPolicy / RevokeChildPolicy      ← 子策略生命周期

agent 侧只剩:`beginSecurityRound(payload, source)` + `defer End()`。
`security_profile.go` 从 214 行策略逻辑退化为转发层。

顺带全局收敛 `Principal.Kind`:裸 string → 固定枚举
(KindInternal / KindSubAgent / KindUser / KindGroup),并新增
`ParsePrincipalKind` —— 拼错返回 false 而不是静默回退到某类别。

## 轮次边界接到**全部三条**输入路径

	task.go      runInputTask   ← 主 agent + 驻留子(各有自己的 Agent 实例)
	rtchild.go   rtRunOnce      ← rtchild 不走调度器,是独立的一条

★ 收敛把**判定逻辑**收到了一处,但"一轮何时开始/结束"仍得各路径自己声明 ——
只有该路径知道边界。所以判据必须钉"每条路径都声明了"。

## 判据(5 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestSecurityProfileFromInputChangesToolDecision` | BeginRound 不解析 payload ⇒ 红 |
| `TestProfileRestoredAfterRoundEnd` | End 不归位 ⇒ 红 |
| `TestUnknownSecurityProfileFallsBackWithoutPanic` | 拼错沿用旧档 ⇒ 红 |
| `TestAgentCoreHoldsNoPolicySemantics` | 把 `Kind:` / `CheckToolFor(` 写回 agent ⇒ 红 |
| `TestPrincipalKindIsDerivedBySecurity` | 分类规则错位 / 枚举接受拼错值 ⇒ 红 |
| `TestEveryInputPathDeclaresRound` | rtchild 路径漏 beginSecurityRound ⇒ 红 |

`TestAgentCoreHoldsNoPolicySemantics` 逐行扫描(跳过注释),禁四类形态:
`payload["security_profile"]` / `Kind:` / `.CheckToolFor(` / `PrincipalPolicy{`。

## 实测(真实整机装配)

	① security 自己解析出的档位 = "qq.guest"
	② security 自己判定的主体     = {ID:main Kind:user}
	③ 本轮判定 cmd_run            = Allowed:false
	   reason="tool \"cmd_run\" is disabled by profile \"qq.guest\""

## 已知限制(已写进 round.go 与 future-plan)

`Round` 是 per-round 句柄,但 `Runtime.active` 仍是**进程级单例**。
多 agent 轮次**交错**时无法表达两个不同档位(串行场景完全正确)。
彻底修法是把档位挂到 TaskFrame 上按任务隔离,需先改 CheckTool 签名。

build / vet / go test ./...(62 ok / 0 fail)/ -race(security + agent/core +
kernelmod/all)全绿;make build 通过。
2026-10-31 07:14:09 +08:00

480 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
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"可能来自别处)。
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)
}
}
// ───────────────── 判据 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)
}
}
// ───────────────── 判据 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` 漏了,就是这么来的。
//
// 用源码断言:真销毁需要完整记忆栈/rt 通道,而"前置不满足就 skip"
// 的判据等于没有(本仓已有同型先例:TestHiddenDependencyOnnxDeclared)。
func TestChildTeardownRevokesPolicy(t *testing.T) {
cases := []struct{ file, fn, why string }{
{"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(",
"驻留子销毁必须撤销父为它声明的策略"},
{"../../agent/core/rtchild.go", "func (a *Agent) StopRTChild(",
"rtchild 单数停止必须撤销策略"},
{"../../agent/core/rtchild.go", "func (a *Agent) StopRTChildren(",
"rtchild 批量停止必须与单数路径对称地撤销策略"},
}
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, "revokeChildPolicy") {
t.Errorf("★ %s 里没有 revokeChildPolicy —— %s。\n"+
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+
" 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。",
c.fn, c.why)
}
}
}
// ★★★ 判据:创建路径必须在**子开始执行前**声明策略。
//
// 顺序错(比如在 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)
}
}
}