feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层

## 用户裁定

> 每个 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` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
This commit is contained in:
JianFeeeee
2026-10-31 07:24:03 +08:00
parent 5194a96560
commit 82932821c0
8 changed files with 599 additions and 45 deletions

View File

@ -0,0 +1,166 @@
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)
}
}