Files
HomeAgent/internal/kernelmod/all/security_wiring_test.go
JianFeeeee e78a38b3d8 fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪

上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。

那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。

区别是实质的:

	错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
	对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大

驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。

## 这不是新机制,是没接线的旧机制

`security.Runtime` 里早就有:

	CheckToolFor(p Principal, tool)              // 按主体判定
	PrincipalPolicy{AllowTools, DenyTools,       // 主体策略
	                HardPrivateTools}
	DeclarePrincipalPolicy(scope, key, policy)   // 声明入口

而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。

## 改动

① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
   **撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
   子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
   这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
   全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
   等于任何执行体销毁时都能抹掉全站策略。

② `agent/core`:`securityDecisionFor` 分两条路径

	父(parentID == "")    → CheckTool(tool)                          // 内核自身
	子(parentID != "")    → CheckToolFor(Principal{self, "resident"},
	                                        tool)                      // 叠加父的声明

   CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
   主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
   (那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。

③ 创建点声明、销毁点撤销(生命周期对称)

	SpawnResident      → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
	SpawnRTChild       → 同上
	teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy

   声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
   「子已跑工具、策略还没进内核」的窗口。
   声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。

④ 工具面:`resident_agents create` 与 `rt_children create` 新增
   `allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
   因为模型很容易把白名单误读成额外授予。

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

| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |

`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。

`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。

## 实测(真实整机装配 + 真执行)

	① 父 ExecuteTool(cmd_run)            → 走到分派(未被拦)
	② 父声明 deny=[cmd_run] 后,子的判定  → Allowed=false
	   reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
	③ 父自己的判定                        → 仍 Allowed(主体隔离)
	④ 撤销后子的判定                      → 恢复 Allowed

build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。

## 仍未接线

- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
2026-10-31 06:35:32 +08:00

353 lines
15 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)
}
}
}