Files
HomeAgent/internal/agent/core/security_profile_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

152 lines
5.7 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 core
// 输入携带 `security_profile` 时的**激活语义**判据。
//
// 用户模型(2026-10-26):
//
// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数
// > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
//
// > 这是可选参数,不声明安全策略则使用内核默认安全策略。
//
// ## 2026-10-31 重写(收敛后的形态)
//
// 本文件原先调用 `a.activateSecurityProfile(payload)` —— 一个**在 agent 里
// 解析 security 协议**的函数。收敛后该函数已删除:档位解析归 security
// (`security.BeginRound` 自己读 `payload["security_profile"]`)。
//
// 所以这里改用 `a.beginSecurityRound(payload, source)`,它走真实的生产入口,
// **不由判据手工解析字段**。这样判据钉的是「security 自己会解析吗」,
// 而不是「agent 有没有帮它解析」。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// ① 没有携带 security_profile ⇒ 不激活任何档位(零行为变化)。
func TestNoSecurityProfileKeepsDefault(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.security = security.NewRuntime(nil)
round := a.beginSecurityRound(map[string]interface{}{"content": "hi"}, "probe")
defer round.End()
if got := round.Profile(); got != "" {
t.Fatalf("未携带档位时不应激活,实际 %q", got)
}
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未使用档位时应沿用默认策略: %s", d.Reason)
}
}
// ② 声明档位 + 输入携带档位名 ⇒ 该轮工具判定按档位生效(owner/非 owner 差别)。
func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
rt := security.NewRuntime(nil)
if err := rt.DeclareProfile(security.Profile{
Name: "qq.guest", Owner: "qq",
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
}); err != nil {
t.Fatal(err)
}
a.security = rt
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("激活前应放行: %s", d.Reason)
}
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
defer round.End()
if got := round.Profile(); got != "qq.guest" {
t.Fatalf("★ security 应从 payload 自己解析出档位并激活 qq.guest,实际 %q —— "+
"若这里是空,说明 BeginRound 没读 security_profile(解析又落回调用方了)", got)
}
// 判定走本轮句柄(生产上工具执行链用的是同一条判定语义)。
if d := round.CheckTool("cmd_run"); d.Allowed {
t.Fatal("guest 档激活后 cmd_* 必须被拒 —— 这是 owner 与非 owner 的差别")
}
}
// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且**不沿用上一轮的档**。
//
// 两种失败都必须咬住:
//
// · 静默沿用上一轮的 guest 档 ⇒ 拼错名却仍收窄(用户被无辜禁工具)
// · 静默当作"无档"却继续用旧档 ⇒ 同上
func TestUnknownSecurityProfileFallsBackWithoutPanic(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
rt := security.NewRuntime(nil)
if err := rt.DeclareProfile(security.Profile{
Name: "qq.guest", Owner: "qq",
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
}); err != nil {
t.Fatal(err)
}
a.security = rt
// 先开一个合法档位并**不结束**,模拟"进程里还挂着 guest 档"。
prev := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
if prev.Profile() != "qq.guest" {
t.Fatalf("前置失败:合法档位未激活,实际 %q", prev.Profile())
}
// 再来一条拼错的。
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.typo"}, "probe")
defer round.End()
if got := round.Profile(); got != "" {
t.Fatalf("未声明的档位不应被激活,实际 %q", got)
}
if got := rt.ActiveProfile(); got != "" {
t.Fatalf("★ 拼错的档位名必须在降级时**清掉**上一轮的档,实际仍是 %q —— "+
"否则“配件名写错”会静默沿用旧档,用户被无辜收窄", got)
}
prev.End()
}
// ④ 未装配安全模块(a.security == nil)⇒ 全部放行,且不 panic。
func TestSecurityDecisionWithoutRuntime(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未装配安全模块时应放行: %s", d.Reason)
}
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.owner"}, "probe")
defer round.End()
if got := round.Profile(); got != "" {
t.Fatalf("未装配时不应激活,实际 %q", got)
}
}
// ⑤ ★ 档位必须**随轮次结束归位**,不泄漏到下一条输入。
//
// 这是收敛的附带收益:`End()` 恢复进入前的档位。没有它,
// 进程级 active 会让 guest 的收窄误伤后续输入(方向相反就是提权)。
func TestProfileRestoredAfterRoundEnd(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
rt := security.NewRuntime(nil)
if err := rt.DeclareProfile(security.Profile{
Name: "qq.guest", Owner: "qq",
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
}); err != nil {
t.Fatal(err)
}
a.security = rt
func() {
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
defer round.End()
if round.Profile() != "qq.guest" {
t.Fatalf("前置:应激活 guest,实际 %q", round.Profile())
}
}()
if got := rt.ActiveProfile(); got != "" {
t.Fatalf("★ 轮次结束后档位应归位为空,实际 %q —— "+
"进程级 active 泄漏到下一条输入,guest 的收窄会误伤后续输入", got)
}
}