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 通过。
152 lines
5.7 KiB
Go
152 lines
5.7 KiB
Go
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)
|
||
}
|
||
}
|