mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -282,6 +282,15 @@ type Agent struct {
|
||||
// builtinHandlers 是交给 device 路由层的族处理器适配器(装配期构造一次)。
|
||||
builtinHandlers agentAPI.BuiltinToolHandlers
|
||||
|
||||
// securityScopeView / securityScopeOnce 是本 agent 的**策略执行视图**
|
||||
// (security 按 agentID 持有,见 security_profile.go 的 securityScope)。
|
||||
//
|
||||
// ★ 为什么是 per-agent 而不是全局:轮次档位(sessionProfile)是该 agent
|
||||
// 独有的状态。放在共享单例上,主 agent 的 guest 会被驻留子的 owner 覆盖
|
||||
// —— 实测可复现(两者同进程并发)。
|
||||
securityScopeView *security.AgentScope
|
||||
securityScopeOnce sync.Once
|
||||
|
||||
// security 是安全防火墙运行时(security 模块产出,可 nil)。
|
||||
//
|
||||
// ★ 可 nil:测试与独立工具(memgc 等)不需要安全模块。
|
||||
@ -599,6 +608,13 @@ func (a *Agent) Start() {
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// ★ 释放本 agent 的策略执行视图(2026-10-31)。
|
||||
//
|
||||
// 视图里存着 **longtimeProfile**(跨轮保留的档位)。不释放的话,
|
||||
// 同名 agent 重建会继承上一代的 long-time 档 —— 那时可能是
|
||||
// "上一个任务留下的访客权限"被新一代默默沿用。
|
||||
// 与主体策略撤销(revokeChildPolicy)同一纪律。
|
||||
a.ReleaseSecurityScope()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
|
||||
@ -98,7 +98,37 @@ func (a *Agent) securityDecisionFor(tool string) security.Decision {
|
||||
if a == nil || a.security == nil {
|
||||
return security.Decision{Allowed: true}
|
||||
}
|
||||
return a.security.CheckToolForAgent(string(a.id), a.parentID != "", tool)
|
||||
// ★ 走本 agent 的**执行视图**(session 档 > longtime 档 > 全局基线)。
|
||||
//
|
||||
// 这是 2026-10-31「每个 agent 一个实例」的落地:视图由 security 按
|
||||
// agentID 持有,本 agent 的 sessionProfile 不会污染别的 agent。
|
||||
return a.securityScope().CheckTool(tool)
|
||||
}
|
||||
|
||||
// securityScope 返回(并缓存)本 agent 的策略执行视图。
|
||||
//
|
||||
// 为什么要缓存:`ForAgent` 是幂等的,但每条输入/每次工具调用都取一次会
|
||||
// 反复加锁;而视图本身在整个 agent 生命周期内不变。
|
||||
func (a *Agent) securityScope() *security.AgentScope {
|
||||
if a == nil || a.security == nil {
|
||||
return nil
|
||||
}
|
||||
a.securityScopeOnce.Do(func() {
|
||||
a.securityScopeView = a.security.ForAgent(string(a.id), a.parentID != "")
|
||||
})
|
||||
return a.securityScopeView
|
||||
}
|
||||
|
||||
// ReleaseSecurityScope 在 agent 销毁时释放它的策略视图。
|
||||
//
|
||||
// ★ 必须调:视图里存着 **longtimeProfile**(跨轮保留的档位)。
|
||||
// agent/子 agent 回收后同名重建是常规操作,残留的 long-time 档会让
|
||||
// 新一代继承上一代父留的约束 —— 与 `RevokePrincipalPolicy` 同一纪律。
|
||||
func (a *Agent) ReleaseSecurityScope() {
|
||||
if a == nil || a.security == nil {
|
||||
return
|
||||
}
|
||||
a.security.ReleaseAgent(string(a.id))
|
||||
}
|
||||
|
||||
// declareChildPolicy 让父为刚创建的子执行体声明主体策略。
|
||||
|
||||
263
internal/security/agentscope.go
Normal file
263
internal/security/agentscope.go
Normal file
@ -0,0 +1,263 @@
|
||||
package security
|
||||
|
||||
// per-agent 策略作用域:**每个 agent 一个实例**。
|
||||
//
|
||||
// ## 为什么必须 per-agent(2026-10-31 用户裁定)
|
||||
//
|
||||
// 收敛前 `Runtime` 是进程级单例,而「当前活跃档位」(`active`)挂在它上面。
|
||||
// 多 agent 并发时档位会串:
|
||||
//
|
||||
// 主 agent 轮次 A:注入 qq.guest → active = "qq.guest"
|
||||
// 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖
|
||||
// 主 agent 轮次 A 继续执行工具 → 判定按 qq.owner 走 ← 串档
|
||||
//
|
||||
// 这不是理论风险:驻留子与 rtchild 与主 agent **同进程并发**(见 plan.md 十一)。
|
||||
//
|
||||
// ## 但**不能**整份复制 Runtime
|
||||
//
|
||||
// `profiles` / `principals` / `scopes` 是**插件声明出来的注册表**。
|
||||
// 若每个 agent 各持一份:
|
||||
//
|
||||
// 插件往 A 声明了 qq.guest ⇒ B 看不见 ⇒ B 上注入 qq.guest 报"未声明"
|
||||
//
|
||||
// 那是最难查的一类缺陷("时灵时不灵")。所以切分必须是:
|
||||
//
|
||||
// 共享(进程级 1 份) per-agent(N 份)
|
||||
// ───────────────────── ─────────────────────
|
||||
// gate / policy / content sessionProfile ← 轮次级,End 即清
|
||||
// profiles / principals longtimeProfile ← 跨轮保留
|
||||
// scopes / eventPolicy roundActive ← 本轮正在生效的那个
|
||||
//
|
||||
// ## 两层档位(用户裁定的语义)
|
||||
//
|
||||
// sessionProfile —— 属于**这一轮**。输入携带它,`End()` 就清掉。
|
||||
// 典型:一条 QQ 消息来自访客 ⇒ 这一轮按 guest 判定。
|
||||
// ★ 严格 per-round:同一用户下一条消息要重新声明。
|
||||
//
|
||||
// longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
|
||||
// 直到显式改掉或 agent 销毁。
|
||||
// 典型:把这个驻留子长期派驻为"访客权限"。
|
||||
//
|
||||
// 优先级(`roundActive` 的计算):session > longtime > 无。
|
||||
//
|
||||
// session 是"这一轮的事实",它必须能盖住 long-term 的默认值 ——
|
||||
// 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。
|
||||
//
|
||||
// 反向(longtime 盖 session)会造成**提权**:长期档是 owner,
|
||||
// 访客这一轮声明的 guest 被忽略 ⇒ 访客拿到 owner 权限。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentScope 是**一个 agent 的策略执行视图**。
|
||||
//
|
||||
// 它由 `Runtime.ForAgent` 创建,持有该 agent 独有的轮次状态,
|
||||
// 同时共享 `Runtime` 上的全部注册表与全局基线。
|
||||
//
|
||||
// ★ 生命周期跟随 agent:创建时 `ForAgent`,销毁时 `ReleaseAgent`。
|
||||
// 不释放不会泄漏内存(视图只持有几个字符串),但**残留的 longtime 档位**
|
||||
// 会让同名重建的 agent 继承上一代的约束 —— 与主体策略的撤销同一形态。
|
||||
type AgentScope struct {
|
||||
rt *Runtime
|
||||
agentID string
|
||||
isChild bool
|
||||
|
||||
mu sync.RWMutex
|
||||
// sessionProfile 是本轮输入声明的档位(End 清空)。
|
||||
sessionProfile string
|
||||
// longtimeProfile 是跨轮保留的档位(显式设置/清除)。
|
||||
longtimeProfile string
|
||||
// sessionExpires 是 session 档位的 TTL 到期时刻(0 = 不过期)。
|
||||
//
|
||||
// 为什么 session 也要 TTL:输入到达后若轮次异常终止(panic/取消),
|
||||
// `End()` 可能没跑到。TTL 是兜底 —— 避免档位永远卡在某一轮的值上。
|
||||
sessionExpires time.Time
|
||||
}
|
||||
|
||||
// ForAgent 为一个 agent 创建策略执行视图(幂等:同一 id 返回同一个)。
|
||||
//
|
||||
// 幂等很重要:`BeginRound` 在每条输入上都会走一遍,若每次新建视图,
|
||||
// `longtimeProfile` 会在下一轮凭空消失。
|
||||
func (r *Runtime) ForAgent(agentID string, isChild bool) *AgentScope {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.scopeMu.Lock()
|
||||
defer r.scopeMu.Unlock()
|
||||
if r.scopesByAgent == nil {
|
||||
r.scopesByAgent = map[string]*AgentScope{}
|
||||
}
|
||||
if sc, ok := r.scopesByAgent[agentID]; ok {
|
||||
return sc
|
||||
}
|
||||
sc := &AgentScope{rt: r, agentID: agentID, isChild: isChild}
|
||||
r.scopesByAgent[agentID] = sc
|
||||
return sc
|
||||
}
|
||||
|
||||
// ReleaseAgent 销毁一个 agent 的视图(含它的 longtime 档位)。
|
||||
//
|
||||
// ★ 必须调:agent/子 agent 回收后同名重建是常规操作(`create id="bot1"`),
|
||||
// 残留的 longtime 档会让新一代继承上一代父留的约束。
|
||||
// 与 `RevokePrincipalPolicy` 同一纪律。
|
||||
func (r *Runtime) ReleaseAgent(agentID string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.scopeMu.Lock()
|
||||
delete(r.scopesByAgent, agentID)
|
||||
r.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
// BeginRound 开始一轮:由 security 自己从原始载荷解析 session 档位。
|
||||
//
|
||||
// 与 `Runtime.BeginRound` 的区别:这个版本的状态落在 **per-agent 视图**上,
|
||||
// 不污染其他 agent。
|
||||
func (sc *AgentScope) BeginRound(in RoundInput) (*Round, error) {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return &Round{}, nil
|
||||
}
|
||||
round := &Round{
|
||||
rt: sc.rt,
|
||||
scope: sc,
|
||||
principal: sc.rt.classifyPrincipal(in),
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(roundProfileName(in.Payload))
|
||||
if name == "" {
|
||||
// 不带档位 = 这一轮显式不使用 session 档。
|
||||
// ★ 只清 session,**不动** longtime —— 后者是 agent 的生命状态。
|
||||
// 所以这里 effective 仍可能是 longtime 档。
|
||||
sc.clearSession()
|
||||
round.profile = sc.EffectiveProfile()
|
||||
return round, nil
|
||||
}
|
||||
|
||||
if err := sc.setSession(name); err != nil {
|
||||
// 响亮但不断流;同时**清掉**上一轮的 session(不能沿用旧档)。
|
||||
sc.clearSession()
|
||||
log.Printf("[security] agent %q round profile %q rejected: %v "+
|
||||
"(falling back to long-term profile %q / default policy)",
|
||||
sc.agentID, name, err, sc.LongtimeProfile())
|
||||
round.profile = sc.EffectiveProfile() // 降级后可能是 longtime 档
|
||||
return round, err
|
||||
}
|
||||
// Round.profile 报告**本轮生效的档位**(session 优先,其次 longtime)。
|
||||
round.profile = sc.EffectiveProfile()
|
||||
return round, nil
|
||||
}
|
||||
|
||||
// setSession 设置本轮档位(校验档位已声明)。
|
||||
func (sc *AgentScope) setSession(name string) error {
|
||||
if _, ok := sc.rt.profiles.Get(name); !ok {
|
||||
return &policyErr{fmt.Sprintf(
|
||||
"security: 未声明的安全档位 %q(已声明: %v)—— 拼错不会退化,直接报错",
|
||||
name, sc.rt.profiles.Names())}
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.sessionProfile = name
|
||||
sc.sessionExpires = time.Now().Add(scopeTTLDefault)
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSession 清除本轮档位(不影响 longtime)。
|
||||
func (sc *AgentScope) clearSession() {
|
||||
if sc == nil {
|
||||
return
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.sessionProfile = ""
|
||||
sc.sessionExpires = time.Time{}
|
||||
sc.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetLongtimeProfile 设置跨轮保留的档位(空串 = 清除)。
|
||||
//
|
||||
// 用途:把一个驻留子**长期**派驻为某个权限档(如"访客权限的数字助手"),
|
||||
// 不必每条输入都声明。
|
||||
//
|
||||
// 校验与 session 一致:未声明的档位名返回错误,不静默接受。
|
||||
func (sc *AgentScope) SetLongtimeProfile(name string) error {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return nil
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
sc.mu.Lock()
|
||||
sc.longtimeProfile = ""
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if _, ok := sc.rt.profiles.Get(name); !ok {
|
||||
return &policyErr{fmt.Sprintf(
|
||||
"security: 未声明的安全档位 %q(已声明: %v)", name, sc.rt.profiles.Names())}
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.longtimeProfile = name
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LongtimeProfile 返回跨轮档位名(空 = 未设置)。
|
||||
func (sc *AgentScope) LongtimeProfile() string {
|
||||
if sc == nil {
|
||||
return ""
|
||||
}
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.longtimeProfile
|
||||
}
|
||||
|
||||
// SessionProfile 返回本轮档位名(空 = 本轮未声明;已过期视为空)。
|
||||
func (sc *AgentScope) SessionProfile() string {
|
||||
if sc == nil {
|
||||
return ""
|
||||
}
|
||||
sc.mu.RLock()
|
||||
name, exp := sc.sessionProfile, sc.sessionExpires
|
||||
sc.mu.RUnlock()
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if !exp.IsZero() && time.Now().After(exp) {
|
||||
return "" // TTL 兜底:End 没跑到也不至于永远卡住
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// EffectiveProfile 返回本轮**实际生效**的档位:session > longtime > 空。
|
||||
//
|
||||
// ★ 这个优先级本身是安全语义,写在这里而不是调用方:
|
||||
//
|
||||
// session 是"这一轮的事实",必须能盖住 long-term 默认值 ——
|
||||
// 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。
|
||||
//
|
||||
// 反向(longtime 盖 session)就是提权:长期档 owner 会把
|
||||
// 访客这一轮声明的 guest 吞掉 ⇒ 访客拿到 owner 权限。
|
||||
func (sc *AgentScope) EffectiveProfile() string {
|
||||
if s := sc.SessionProfile(); s != "" {
|
||||
return s
|
||||
}
|
||||
return sc.LongtimeProfile()
|
||||
}
|
||||
|
||||
// CheckTool 判定某工具在**本 agent 当前状态**下是否可用。
|
||||
//
|
||||
// 它把三件事合成一次调用,调用方不需要知道任何组合规则:
|
||||
//
|
||||
// ① 全局基线(policy)
|
||||
// ② 生效档位(session 优先,其次 longtime)
|
||||
// ③ 主体策略(父为子声明的 Allow/Deny)
|
||||
func (sc *AgentScope) CheckTool(tool string) Decision {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return allowed()
|
||||
}
|
||||
return sc.rt.checkToolForWithProfile(sc.rt.classifyScopePrincipal(sc.agentID, sc.isChild),
|
||||
sc.EffectiveProfile(), tool)
|
||||
}
|
||||
166
internal/security/agentscope_test.go
Normal file
166
internal/security/agentscope_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -250,8 +250,22 @@ func (r *Runtime) CheckToolFor(p Principal, tool string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
// ① 全局策略(deny 优先)。
|
||||
if d := r.CheckTool(tool); !d.Allowed {
|
||||
// 用进程级档位(旧路径)。per-agent 视图走 checkToolForWithProfile。
|
||||
return r.checkToolForWithProfile(p, r.ActiveProfile(), tool)
|
||||
}
|
||||
|
||||
// checkToolForWithProfile 是**主体判定 + 指定档位**的组合实现。
|
||||
//
|
||||
// ★ 为什么必须与档位一起传(2026-10-31):
|
||||
// per-agent 视图的档位是**它自己的**(session/longtime)。若这里回头
|
||||
// 调 `r.CheckTool(tool)`(读进程级 active),per-agent 的档位就被丢了 ——
|
||||
// 表现为「档位设了但不生效」,而全局 active 恰好为空时看不出问题。
|
||||
func (r *Runtime) checkToolForWithProfile(p Principal, profileName, tool string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
// ① 全局策略 + 档位(deny 优先)。
|
||||
if d := r.checkToolWithProfile(p, profileName, tool); !d.Allowed {
|
||||
return d
|
||||
}
|
||||
pols := r.principals.lookup(p)
|
||||
|
||||
@ -242,8 +242,20 @@ func TTLSeconds(n int) time.Duration {
|
||||
|
||||
// profilePolicy 取当前活跃档位的策略(无档位时返回零值 + false)。
|
||||
func (r *Runtime) profilePolicy() (Profile, bool) {
|
||||
name := r.ActiveProfile()
|
||||
if name == "" || r.profiles == nil {
|
||||
return r.profilePolicyByName(r.ActiveProfile())
|
||||
}
|
||||
|
||||
// profilePolicyByName 按**指定**档位名取策略(per-agent 视图用)。
|
||||
//
|
||||
// ★ 与 profilePolicy 的分工:
|
||||
//
|
||||
// profilePolicy —— 读进程级 active(旧路径,单执行体场景)
|
||||
// profilePolicyByName —— 由调用方给出档位(per-agent 视图必须走这条)
|
||||
//
|
||||
// 之所以要拆:per-agent 视图的档位是**它自己的** session/longtime,
|
||||
// 不能去读 `Runtime.active` —— 那样多 agent 并发时仍会串档。
|
||||
func (r *Runtime) profilePolicyByName(name string) (Profile, bool) {
|
||||
if name == "" || r == nil || r.profiles == nil {
|
||||
return Profile{}, false
|
||||
}
|
||||
return r.profiles.Get(name)
|
||||
|
||||
@ -43,7 +43,6 @@ package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -83,14 +82,19 @@ const profileKey = "security_profile"
|
||||
// 一次调用,调用方不再需要知道任何组合规则。
|
||||
type Round struct {
|
||||
rt *Runtime
|
||||
// scope 是**该 agent 独有的**策略执行视图(2026-10-31 起的正路)。
|
||||
//
|
||||
// 为 nil 表示走旧的进程级单例路径(Round.End 会恢复 Runtime.active)。
|
||||
scope *AgentScope
|
||||
// principal 是 security **自己判定**出来的主体(不是调用方声明的)。
|
||||
principal Principal
|
||||
// profile 是本轮激活的档位名(空 = 未使用档位)。
|
||||
// profile 是本轮生效的档位名(空 = 未使用档位)。
|
||||
profile string
|
||||
// restored 记录 BeginRound 之前的档位,End 时恢复。
|
||||
// restored 仅在 scope == nil 时使用:记录进程级 active 的进入值。
|
||||
//
|
||||
// 为什么恢复而不是清空:进程内可能有多条输入在跑(主 agent + 驻留子),
|
||||
// 清空会把别处的档位抹掉。恢复到进入前的值是最小惊讶行为。
|
||||
// ★ 为什么旧的进程级路径要"恢复"而不是"清空":那时多个 agent
|
||||
// 共享一个 active,清空会把别处正在用的档抹掉。per-agent 视图
|
||||
// 没有这个问题(每个 agent 自己一份),所以走 scope 时直接清 session。
|
||||
restored string
|
||||
ended bool
|
||||
}
|
||||
@ -137,36 +141,13 @@ func (r *Runtime) BeginRound(in RoundInput) (*Round, error) {
|
||||
if r == nil {
|
||||
return &Round{}, nil // 未装配 security:零成本句柄
|
||||
}
|
||||
|
||||
rc := &Round{
|
||||
rt: r,
|
||||
principal: r.classifyPrincipal(in),
|
||||
restored: r.ActiveProfile(),
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(roundProfileName(in.Payload))
|
||||
if name == "" {
|
||||
// 不带档位 = 显式不使用(沿用全局策略)。但**必须归位**:
|
||||
// 上一条输入若激活了 guest 档,不清掉就会误伤这一条。
|
||||
if r.active != nil {
|
||||
r.active.Clear()
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
if err := r.ActivateProfile(name, 0); err != nil {
|
||||
// 响亮但不断流:注入是 fire-and-forget,插件看不到内核错误。
|
||||
// 但这里必须**清掉上一轮的档**,否则拼错档位名会静默沿用旧档 ——
|
||||
// “拼错却仍收窄”与“拼错却仍放行”都是安全事故形态。
|
||||
if r.active != nil {
|
||||
r.active.Clear()
|
||||
}
|
||||
log.Printf("[security] round profile %q rejected for agent %q: %v "+
|
||||
"(falling back to default policy, NOT to the previous round's profile)", name, in.AgentID, err)
|
||||
return rc, err
|
||||
}
|
||||
rc.profile = name
|
||||
return rc, nil
|
||||
// ★ 正路:per-agent 视图(每个 agent 一份 session 档,互不干扰)。
|
||||
//
|
||||
// 2026-10-31 用户裁定「每个 agent 一个,分为 sessionProfile 和
|
||||
// longtimeProfile」后,这里的档位状态落在 `AgentScope` 上,
|
||||
// 不再写进程级 `Runtime.active`。
|
||||
sc := r.ForAgent(in.AgentID, in.IsChild)
|
||||
return sc.BeginRound(in)
|
||||
}
|
||||
|
||||
// roundProfileName 从原始载荷里取档位名。
|
||||
@ -215,6 +196,20 @@ func (rc *Round) Principal() Principal {
|
||||
return rc.principal
|
||||
}
|
||||
|
||||
// EffectiveProfile 返回本轮**实际生效**的档位名。
|
||||
//
|
||||
// per-agent 视图下它是 `session > longtime > 空` 的结果;
|
||||
// 旧路径下等于 Round.Profile()。
|
||||
func (rc *Round) EffectiveProfile() string {
|
||||
if rc == nil {
|
||||
return ""
|
||||
}
|
||||
if rc.scope != nil {
|
||||
return rc.scope.EffectiveProfile()
|
||||
}
|
||||
return rc.profile
|
||||
}
|
||||
|
||||
// Profile 返回本轮生效的档位名(空 = 未使用档位)。
|
||||
func (rc *Round) Profile() string {
|
||||
if rc == nil {
|
||||
@ -231,22 +226,35 @@ func (rc *Round) CheckTool(name string) Decision {
|
||||
if rc == nil || rc.rt == nil {
|
||||
return allowed()
|
||||
}
|
||||
return rc.rt.CheckToolFor(rc.principal, name)
|
||||
// per-agent 视图:用**本 agent 的**生效档位(session > longtime)。
|
||||
if rc.scope != nil {
|
||||
return rc.rt.checkToolWithProfile(rc.principal, rc.scope.EffectiveProfile(), name)
|
||||
}
|
||||
// 旧路径:进程级档位。
|
||||
return rc.rt.checkToolWithProfile(rc.principal, rc.rt.ActiveProfile(), name)
|
||||
}
|
||||
|
||||
// End 结束本轮:恢复进入前的档位。
|
||||
//
|
||||
// 幂等:重复调用无副作用(defer + 显式调用并存是安全的)。
|
||||
func (rc *Round) End() {
|
||||
if rc == nil || rc.rt == nil || rc.ended {
|
||||
if rc == nil || rc.ended {
|
||||
return
|
||||
}
|
||||
rc.ended = true
|
||||
if rc.rt.active == nil {
|
||||
|
||||
// per-agent 视图:只清**自己的** session 档,longtime 档保留
|
||||
//(它是该 agent 的生命状态,不是本轮事实)。
|
||||
if rc.scope != nil {
|
||||
rc.scope.clearSession()
|
||||
return
|
||||
}
|
||||
|
||||
// 旧的进程级路径:恢复进入前的值(那时多个 agent 共享一个 active)。
|
||||
if rc.rt == nil || rc.rt.active == nil {
|
||||
return
|
||||
}
|
||||
if rc.restored != "" {
|
||||
// 恢复到进入前的档位(可能是别的输入正在用的档)。
|
||||
rc.rt.active.Activate(rc.restored, scopeTTLDefault)
|
||||
return
|
||||
}
|
||||
@ -301,3 +309,11 @@ func (r *Runtime) RevokeChildPolicy(childID string) {
|
||||
func (r *Runtime) ClassifyPrincipalForTest(in RoundInput) Principal {
|
||||
return r.classifyPrincipal(in)
|
||||
}
|
||||
|
||||
// classifyScopePrincipal 按 per-agent 视图的事实判定主体类别。
|
||||
//
|
||||
// 与 classifyPrincipal 同一套规则(**唯一事实来源**),
|
||||
// 只是入参来自 AgentScope 而不是 RoundInput。
|
||||
func (r *Runtime) classifyScopePrincipal(agentID string, isChild bool) Principal {
|
||||
return r.classifyPrincipal(RoundInput{AgentID: agentID, IsChild: isChild})
|
||||
}
|
||||
|
||||
@ -69,6 +69,20 @@ type Runtime struct {
|
||||
//(QQ 的 beforeToolcall):插件只声明谁可以调什么,
|
||||
// 判定与拒绝文案由内核统一产出。
|
||||
principals *principalRegistry
|
||||
|
||||
// scopesByAgent 是**每 agent 一个**策略执行视图(见 agentscope.go)。
|
||||
//
|
||||
// ★ 为何需要(2026-10-31 用户裁定):每个 agent / 子 agent 一个实例,
|
||||
// 因为轮次档位(sessionProfile)是**该 agent 独有**的状态。
|
||||
// 放在 Runtime 单例上会让并发 agent 互相覆写(主 agent 的 guest
|
||||
// 被驻留子的 owner 盖掉,或反之)。
|
||||
//
|
||||
// ★ 为何不把整个 Runtime 复制一份:`profiles` / `principals` / `scopes`
|
||||
// 是**插件声明出来的注册表**,每个 agent 各持一份就会变成
|
||||
// “插件往 A 声明了档位,B 看不见”—— 那是最难查的一类缺陷。
|
||||
// 所以切分是「共享注册表 + per-agent 执行视图」。
|
||||
scopesByAgent map[string]*AgentScope
|
||||
scopeMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRuntime 用已装好的变量库构造运行时。
|
||||
@ -217,7 +231,30 @@ func (r *Runtime) DeclareTurnPolicy(opts ScopeOptions) error {
|
||||
// 组合顺序(**deny 永远优先**,这是安全策略的基本直觉):
|
||||
//
|
||||
// 全局 deny → 作用域 deny → 全局 allow(非空则必须命中)→ 作用域 allow
|
||||
//
|
||||
// ## 与 per-agent 视图的关系(2026-10-31 用户裁定)
|
||||
//
|
||||
// 本函数用 `Runtime.active`(**进程级**)上的档位 —— 新代码不要依赖它。
|
||||
// 新调用方应走 `Runtime.ForAgent(id, isChild)` 拿 `*AgentScope`:
|
||||
// 那里的档位是**该 agent 独有**的,不会与其他 agent 串。
|
||||
//
|
||||
// 保留本函数是为了:独立工具(memgc 等,进程内只有一个执行体)、判据、
|
||||
// 以及尚未按 agent 切分的旧路径。
|
||||
func (r *Runtime) CheckTool(name string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
return r.checkToolWithProfile(Principal{}, r.ActiveProfile(), name)
|
||||
}
|
||||
|
||||
// checkToolWithProfile 是工具判定的**唯一实现**:档位由调用方显式给定。
|
||||
//
|
||||
// `profileName` 为空 = 不使用档位(零影响)。
|
||||
//
|
||||
// ★ 为什么档位作参数、而不是读全局 `r.active`:
|
||||
// per-agent 视图必须传入**它自己的**档位(session 或 longtime)。
|
||||
// 若这里读 `r.active`,per-agent 视图就白做了 —— 那正是本轮要修的缺陷。
|
||||
func (r *Runtime) checkToolWithProfile(p Principal, profileName, name string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
@ -227,7 +264,7 @@ func (r *Runtime) CheckTool(name string) Decision {
|
||||
// ★ 活跃档位(可选):插件没声明/没激活时这里零影响。
|
||||
// 档位只参与**收窄**:它的 deny / allow 与主体、全局同一条链,
|
||||
// 并且不能绕过全局黑名单(上面已先判过)。
|
||||
if prof, ok := r.profilePolicy(); ok {
|
||||
if prof, ok := r.profilePolicyByName(profileName); ok {
|
||||
for _, pat := range prof.Policy.DenyTools {
|
||||
if matchTool(pat, name) {
|
||||
return deny("tool %q is disabled by profile %q", name, prof.Name)
|
||||
|
||||
Reference in New Issue
Block a user