mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 用户裁定 > 每个 agent 一个,分为 sessionProfile 和 longtimeProfile ## 收敛前的缺陷 档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档: 主 agent 轮次 A:注入 qq.guest → active = "qq.guest" 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖 A 主 agent 轮次 A 继续执行工具 → 判定按 owner 走 ← 串档 这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。 ## 切分原则:共享注册表 + per-agent 执行视图 **不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件 声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位, B 看不见」,那是最难查的一类缺陷。 共享(进程级 1 份) per-agent(N 份) ──────────────────────── ─────────────────── gate / policy / content sessionProfile ← 轮次级 profiles / principals longtimeProfile ← agent 生命期 scopes / eventPolicy ## 两层档位语义(用户裁定) sessionProfile —— 属于**这一轮**。输入携带它,End() 即清。 严格 per-round:同一用户下一条消息要重新声明。 longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留, 直到显式改掉或 agent 销毁。 **优先级 session > longtime**,这个顺序本身是安全语义: session 是"这一轮的事实",必须能盖住 long-time 默认值 —— 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。 反向(longtime 盖 session)= 提权:长期 owner 的 agent 上, 访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。 ## 改动 ① 新增 `internal/security/agentscope.go`: rt.ForAgent(agentID, isChild) *AgentScope // 幂等 sc.BeginRound(RoundInput) // security 自己解析档位 sc.SetLongtimeProfile(name) / LongtimeProfile() sc.EffectiveProfile() // session > longtime sc.CheckTool(tool) // 全局基线 + 档位 + 主体策略 rt.ReleaseAgent(agentID) // 含 longtime 档一起清 ② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)` 与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**, 不再一律读全局 `r.active`(否则 per-agent 视图白做)。 ③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图; `Stop()` 里 `ReleaseSecurityScope()`。 ④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime; 旧进程级路径仍恢复进入前的值。 ## 判据(3 个变异全部实测咬住) | 判据 | 变异 | |---|---| | `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 | | `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 | | `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 | | `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 | 第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、 子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。 ## 实测 - 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发, `-race` 干净,**零串档** - build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿 ## 未变的约束 档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略, 所以父的黑名单对子依然有效,子无法提权。
167 lines
5.9 KiB
Go
167 lines
5.9 KiB
Go
package security
|
||
|
||
import "testing"
|
||
|
||
// ★★★ 判据:**两个 agent 的 session 档互不干扰**(2026-10-31 用户裁定)。
|
||
//
|
||
// ## 防的失败方式
|
||
//
|
||
// 收敛前档位挂在 `Runtime.active`(进程级单例)上,多 agent 并发时串档:
|
||
//
|
||
// 主 agent 轮次 A:注入 qq.guest → active = "qq.guest"
|
||
// 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖 A
|
||
// 主 agent 轮次 A 继续执行工具 → 判定按 owner 走 ← 串档
|
||
//
|
||
// 这不是理论风险:驻留子与 rtchild 与主 agent 同进程并发。
|
||
func TestAgentScopesDoNotShareSessionProfile(t *testing.T) {
|
||
rt := NewRuntime(nil)
|
||
for _, p := range []Profile{
|
||
{Name: "probe.owner", Owner: "probe"},
|
||
{Name: "probe.guest", Owner: "probe",
|
||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}}},
|
||
} {
|
||
if err := rt.DeclareProfile(p); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
main := rt.ForAgent("main", false)
|
||
child := rt.ForAgent("kid", true)
|
||
|
||
rm, err := main.BeginRound(RoundInput{AgentID: "main", Payload: map[string]interface{}{
|
||
"security_profile": "probe.guest",
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer rm.End()
|
||
|
||
rc, err := child.BeginRound(RoundInput{AgentID: "kid", IsChild: true, Payload: map[string]interface{}{
|
||
"security_profile": "probe.owner",
|
||
}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer rc.End()
|
||
|
||
// ① 各自的生效档位必须是自己的。
|
||
if got := main.EffectiveProfile(); got != "probe.guest" {
|
||
t.Fatalf("主 agent 的档位应是 probe.guest,实际 %q —— 被别的 agent 覆盖了", got)
|
||
}
|
||
if got := child.EffectiveProfile(); got != "probe.owner" {
|
||
t.Fatalf("子 agent 的档位应是 probe.owner,实际 %q —— 被别的 agent 覆盖了", got)
|
||
}
|
||
|
||
// ② 行为上:主 agent 仍被 guest 收窄,子 agent 不受影响。
|
||
if d := main.CheckTool("cmd_run"); d.Allowed {
|
||
t.Fatal("★ 主 agent 的 guest 档没生效 —— 它被别的 agent 的 owner 档盖掉了")
|
||
}
|
||
if d := child.CheckTool("cmd_run"); !d.Allowed {
|
||
t.Fatalf("子 agent 是 owner 档,不该被 guest 的收窄影响: %s", d.Reason)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:**longtimeProfile 跨轮保留**,sessionProfile 随轮结束清除。
|
||
//
|
||
// 这是用户裁定的两层语义:
|
||
//
|
||
// sessionProfile —— 属于这一轮,End 即清
|
||
// longtimeProfile —— 属于 agent 的生命,跨轮保留
|
||
//
|
||
// 优先级:session > longtime(session 是"这一轮的事实",必须能收窄)。
|
||
func TestLongtimeProfileSurvivesRounds(t *testing.T) {
|
||
rt := NewRuntime(nil)
|
||
for _, p := range []Profile{
|
||
{Name: "probe.guest", Owner: "probe",
|
||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}}},
|
||
{Name: "probe.owner", Owner: "probe"},
|
||
} {
|
||
if err := rt.DeclareProfile(p); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
sc := rt.ForAgent("bot1", true)
|
||
if err := sc.SetLongtimeProfile("probe.guest"); err != nil {
|
||
t.Fatalf("设置 long-time 档失败: %v", err)
|
||
}
|
||
|
||
// 第一轮:没带 session 档 ⇒ 用 long-time。
|
||
r1, _ := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true})
|
||
if got := r1.EffectiveProfile(); got != "probe.guest" {
|
||
t.Fatalf("无 session 档时应落到 long-time 档,实际 %q", got)
|
||
}
|
||
r1.End()
|
||
|
||
// 第二轮:仍然有 long-time(不能因为 End 就丢了)。
|
||
r2, _ := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true})
|
||
defer r2.End()
|
||
if got := r2.EffectiveProfile(); got != "probe.guest" {
|
||
t.Fatalf("★ long-time 档在轮次结束后丢了(%q)—— 它应跨轮保留", got)
|
||
}
|
||
|
||
// 第三轮:带 session 档 ⇒ session 优先(必须能收窄或放宽)。
|
||
r3, err := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true,
|
||
Payload: map[string]interface{}{"security_profile": "probe.owner"}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := r3.EffectiveProfile(); got != "probe.owner" {
|
||
t.Fatalf("session 档应优先于 long-time 档,实际 %q", got)
|
||
}
|
||
// ★ 反向最危险:若 longtime 优先,长期 owner 的 agent 上访客声明的
|
||
// guest 会被吞掉 ⇒ 访客拿到 owner 权限。
|
||
if d := r3.CheckTool("cmd_run"); !d.Allowed {
|
||
t.Fatalf("★ session=owner 时 cmd_* 应放行;若被 long-time 的 guest 卡住,"+
|
||
"说明优先级写反了(反之则更危险): %s", d.Reason)
|
||
}
|
||
r3.End()
|
||
|
||
// 归位:session 清了,long-time 还在。
|
||
if got := sc.EffectiveProfile(); got != "probe.guest" {
|
||
t.Fatalf("session 结束后应回落到 long-time 档,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:agent 释放后 long-time 档**不得残留**。
|
||
//
|
||
// agent/子 agent 回收后同名重建是常规操作(`create id="bot1"`)。
|
||
// 残留会让新一代继承上一代父留的约束 —— 与主体策略撤销同一纪律。
|
||
func TestReleaseAgentDropsLongtimeProfile(t *testing.T) {
|
||
rt := NewRuntime(nil)
|
||
if err := rt.DeclareProfile(Profile{
|
||
Name: "probe.guest", Owner: "probe",
|
||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
sc := rt.ForAgent("bot1", true)
|
||
if err := sc.SetLongtimeProfile("probe.guest"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rt.ReleaseAgent("bot1")
|
||
|
||
// 同名重建:必须是干净的视图。
|
||
fresh := rt.ForAgent("bot1", true)
|
||
if got := fresh.LongtimeProfile(); got != "" {
|
||
t.Fatalf("★ 同名重建的 agent 继承了上一代的 long-time 档 %q —— "+
|
||
"新一代用上一代父留的约束运行", got)
|
||
}
|
||
if d := fresh.CheckTool("cmd_run"); !d.Allowed {
|
||
t.Fatalf("重建后的 agent 应无收窄,实际被拒: %s", d.Reason)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:未声明的档位名必须报错,且**不沿用**上一个档。
|
||
func TestLongtimeProfileRejectsUndeclared(t *testing.T) {
|
||
rt := NewRuntime(nil)
|
||
sc := rt.ForAgent("bot1", false)
|
||
if err := sc.SetLongtimeProfile("probe.typo"); err == nil {
|
||
t.Fatal("★ 未声明的档位名被接受了 —— 拼写错误会静默变成'无档位'")
|
||
}
|
||
if got := sc.LongtimeProfile(); got != "" {
|
||
t.Fatalf("设置失败后不应留下任何档位,实际 %q", got)
|
||
}
|
||
}
|