mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +00:00
## 架构定位(用户澄清)
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 通过。
480 lines
21 KiB
Go
480 lines
21 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"
|
||
"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)
|
||
}
|
||
}
|
||
}
|