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` 内部先跑全局策略, 所以父的黑名单对子依然有效,子无法提权。
380 lines
13 KiB
Go
380 lines
13 KiB
Go
package security
|
||
|
||
// Runtime 是安全模块的**运行时对象**:横在 Core 与全部外部模块之间的边界。
|
||
//
|
||
// ## 形态(用户确认:显式代理)
|
||
//
|
||
// 业务模块看到的仍是它们熟悉的公共挂载点(IO / EventBus / 工具分发),
|
||
// 但那些挂载点在装配时被换成了本 Runtime 产出的代理:
|
||
//
|
||
// Core ──▶ proxy.PrepareOutbound ──▶ 外部
|
||
// Core ◀── proxy.ScrubInbound ◀── 外部
|
||
//
|
||
// 因此"劫持"只是架构语义(便于各模块解耦),实现上是**显式代理**:
|
||
// 没有 monkey patch、没有反射替换、没有隐式全局 hook。
|
||
//
|
||
// ## 为什么必须有 Disabled 形态
|
||
//
|
||
// 安全模块可以被配置关掉(`core.security.enabled=false`)。若此时 Runtime
|
||
// 为 nil,每个调用点都得写 `if r != nil`;漏一个就是 panic 或绕过。
|
||
// 装一个 **Disabled Runtime**(所有方法零成本透传)后,调用点只有一条路径。
|
||
//
|
||
// ★ 这不是"降级放行":用户显式关掉了这项能力,读配置就能看出来。
|
||
// 与"没装配好却假装安全"是两件事。
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
)
|
||
|
||
var (
|
||
runtimeMu sync.RWMutex
|
||
runtime *Runtime
|
||
)
|
||
|
||
// Runtime 是安全运行时。
|
||
//
|
||
// 它把安全模块的三块能力合成**一个边界**:
|
||
//
|
||
// gate 凭据防火墙(明文 ⇄ @{别名})
|
||
// policy 工具黑白名单 + 沙箱约束
|
||
// content 敏感词 + 提示词注入预警
|
||
// scopes 插件声明的**轮次作用域策略**(内核持有,按时过期)
|
||
type Runtime struct {
|
||
gate *Gate
|
||
enabled bool
|
||
|
||
// policy 是全局策略快照(启动/重载时替换)。
|
||
policy Policy
|
||
// content 是内容审查策略(敏感词 + 注入检测)。
|
||
content ContentPolicy
|
||
// scopes 是插件可声明的轮次作用域(内核持有,deny 优先)。
|
||
scopes *ScopeRegistry
|
||
// profiles 是**插件声明的命名限式**(`qq.owner` / `qq.guest` …)。
|
||
//
|
||
// ★ 可选:插件不声明任何档位时,一切判定沿用内核默认策略
|
||
//(全局 policy + 主体策略 + 轮次作用域),行为与之前完全一致。
|
||
profiles *profileRegistry
|
||
// active 记录**当前活跃档位**(由 inputch 输入的 SecurityProfile 选择)。
|
||
active *profileActivator
|
||
// eventPolicy 是**事件总线授权表**(谁能发/订阅什么事件)。
|
||
//
|
||
// ★ 它与凭据闸门无关:`core.security.enabled=false` 关掉的是凭据替换,
|
||
// 而"插件不许发 raw_input"这类授权始终生效 —— 所以 Disabled Runtime
|
||
// 同样持有默认策略(见 NewDisabledRuntime)。
|
||
eventPolicy EventPolicy
|
||
// principals 是**按主体**的能力策略(插件声明,内核执行)。
|
||
//
|
||
// ★ 它替代的正是"插件自己在 stage 里拦工具"那种实现
|
||
//(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 用已装好的变量库构造运行时。
|
||
func NewRuntime(store *Store) *Runtime {
|
||
if store == nil {
|
||
return NewDisabledRuntime()
|
||
}
|
||
return &Runtime{
|
||
gate: NewGate(store),
|
||
enabled: true,
|
||
policy: DefaultPolicy(),
|
||
eventPolicy: DefaultEventPolicy(),
|
||
scopes: NewScopeRegistry(),
|
||
principals: newPrincipalRegistry(),
|
||
profiles: newProfileRegistry(),
|
||
active: &profileActivator{},
|
||
}
|
||
}
|
||
|
||
// NewDisabledRuntime 构造一个零成本透传的运行时(安全模块被显式关闭时用)。
|
||
func NewDisabledRuntime() *Runtime {
|
||
// 即使禁用也装一个策略表:否则"关闭凭据防火墙"会连带让工具策略失效,
|
||
// 而这是两件不同的事(用户可以只要策略、不要凭据替换)。
|
||
return &Runtime{
|
||
enabled: false,
|
||
policy: DefaultPolicy(),
|
||
eventPolicy: DefaultEventPolicy(),
|
||
scopes: NewScopeRegistry(),
|
||
principals: newPrincipalRegistry(),
|
||
profiles: newProfileRegistry(),
|
||
active: &profileActivator{},
|
||
}
|
||
}
|
||
|
||
// RuntimeGlobal 取进程级运行时;未装配时返回 nil。
|
||
//
|
||
// ★ 返回 nil 而不是 Disabled:这只表示"安全模块还没装配",
|
||
//
|
||
// 装配完成后(无论启用与否)一定非 nil。调用方拿 nil 应视为装配错误。
|
||
func RuntimeGlobal() *Runtime {
|
||
runtimeMu.RLock()
|
||
defer runtimeMu.RUnlock()
|
||
return runtime
|
||
}
|
||
|
||
// SetRuntimeGlobal 装入运行时(幂等:重复装同一个实例无副作用)。
|
||
func SetRuntimeGlobal(r *Runtime) {
|
||
runtimeMu.Lock()
|
||
runtime = r
|
||
runtimeMu.Unlock()
|
||
}
|
||
|
||
// Enabled 报告安全审查是否真的生效。
|
||
func (r *Runtime) Enabled() bool { return r != nil && r.enabled && r.gate != nil }
|
||
|
||
// Gate 返回边界闸门(未启用时为 nil)。
|
||
func (r *Runtime) Gate() *Gate {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
return r.gate
|
||
}
|
||
|
||
// PrepareOutbound 处理 Core → 外部的文本(明文换成 @{别名})。
|
||
func (r *Runtime) PrepareOutbound(ins Inspection, text string) (string, int) {
|
||
if r == nil || !r.enabled || r.gate == nil {
|
||
return text, 0
|
||
}
|
||
return r.gate.PrepareOutbound(ins, text)
|
||
}
|
||
|
||
// ScrubInbound 处理外部 → Core 的文本(明文换成 @{别名})。
|
||
//
|
||
// ★ 入向必须扫:入向内容直接进模型上下文(role=tool)。
|
||
//
|
||
// 只在"写记忆"时清洗是最坏组合 —— 记忆侧干净、当轮泄漏、事后查不到。
|
||
func (r *Runtime) ScrubInbound(ins Inspection, text string) (string, int) {
|
||
if r == nil || !r.enabled || r.gate == nil {
|
||
return text, 0
|
||
}
|
||
return r.gate.ScrubInbound(ins, text)
|
||
}
|
||
|
||
// FillForExecution 在真正交给外部执行前把 `@{别名}` 换回明文。
|
||
func (r *Runtime) FillForExecution(ins Inspection, text string) (string, int) {
|
||
if r == nil || !r.enabled || r.gate == nil {
|
||
return text, 0
|
||
}
|
||
return r.gate.FillForExecution(ins, text)
|
||
}
|
||
|
||
// Store 返回底层变量库(安全工具族 secret_* 需要元信息)。
|
||
func (r *Runtime) Store() *Store {
|
||
if r == nil || r.gate == nil {
|
||
return nil
|
||
}
|
||
return r.gate.Store()
|
||
}
|
||
|
||
// Count 返回变量条数(状态面用;未启用时为 0)。
|
||
func (r *Runtime) Count() int {
|
||
if s := r.Store(); s != nil {
|
||
return s.Count()
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// ───────────────────── 策略(工具 / 内容 / 作用域) ─────────────────────
|
||
|
||
// SetPolicy 替换全局策略(配置重载时调用;快照式,不做增量)。
|
||
func (r *Runtime) SetPolicy(p Policy) {
|
||
if r == nil {
|
||
return
|
||
}
|
||
r.policy = p
|
||
}
|
||
|
||
// SetContentPolicy 替换内容审查策略(敏感词 + 注入检测)。
|
||
func (r *Runtime) SetContentPolicy(p ContentPolicy) {
|
||
if r == nil {
|
||
return
|
||
}
|
||
r.content = p
|
||
}
|
||
|
||
// Scopes 返回作用域登记表(插件声明入口;nil Runtime 时返回 nil)。
|
||
func (r *Runtime) Scopes() *ScopeRegistry {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
return r.scopes
|
||
}
|
||
|
||
// DeclareTurnPolicy 是**插件声明轮次策略**的入口(SDK 转发到这里)。
|
||
//
|
||
// 语义:声明在**本轮之后**生效,到期自动回收(内核持有,不依赖插件存活)。
|
||
func (r *Runtime) DeclareTurnPolicy(opts ScopeOptions) error {
|
||
if r == nil || r.scopes == nil {
|
||
return fmt.Errorf("security: security runtime is not initialized")
|
||
}
|
||
return r.scopes.Declare(opts)
|
||
}
|
||
|
||
// CheckTool 判定工具是否允许调用(含当前活跃档位)。
|
||
//
|
||
// 组合顺序(**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()
|
||
}
|
||
if d := r.policy.CheckTool(name); !d.Allowed {
|
||
return d
|
||
}
|
||
// ★ 活跃档位(可选):插件没声明/没激活时这里零影响。
|
||
// 档位只参与**收窄**:它的 deny / allow 与主体、全局同一条链,
|
||
// 并且不能绕过全局黑名单(上面已先判过)。
|
||
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)
|
||
}
|
||
}
|
||
if len(prof.Policy.AllowTools) > 0 && len(r.policy.ToolAllow) == 0 {
|
||
hit := false
|
||
for _, pat := range prof.Policy.AllowTools {
|
||
if matchTool(pat, name) {
|
||
hit = true
|
||
}
|
||
}
|
||
if !hit {
|
||
return deny("tool %q is outside profile %q allowed set", name, prof.Name)
|
||
}
|
||
}
|
||
}
|
||
// 作用域覆盖:插件的 deny 追加在全局之上。
|
||
active := r.scopes.Active()
|
||
if len(active) == 0 {
|
||
return allowed()
|
||
}
|
||
for _, sc := range active {
|
||
for _, pat := range sc.DenyTools {
|
||
if matchTool(pat, name) {
|
||
return deny("tool %q is disabled by plugin scope %q", name, sc.ID)
|
||
}
|
||
}
|
||
}
|
||
// 作用域 allow 只在"全局白名单为空"时才作为额外许可集合使用,
|
||
// 否则会把全局白名单绕掉(作用域不该扩大权限)。
|
||
if len(r.policy.ToolAllow) == 0 {
|
||
allowedByScope := false
|
||
for _, sc := range active {
|
||
for _, pat := range sc.AllowTools {
|
||
if matchTool(pat, name) {
|
||
allowedByScope = true
|
||
}
|
||
}
|
||
}
|
||
// 有 allow 声明但一个都没命中 ⇒ 说明该作用域只允许更窄的集合。
|
||
// 只要有作用域声明了 allow,就要求命中(否则等于没声明)。
|
||
for _, sc := range active {
|
||
if len(sc.AllowTools) > 0 && !allowedByScope {
|
||
return deny("tool %q is not allowed by active plugin scope %q", name, sc.ID)
|
||
}
|
||
}
|
||
}
|
||
return allowed()
|
||
}
|
||
|
||
// CheckNetwork 判定网络意图是否允许(含作用域覆盖)。
|
||
func (r *Runtime) CheckNetwork() Decision {
|
||
if r == nil {
|
||
return allowed()
|
||
}
|
||
if d := r.policy.CheckNetwork(); !d.Allowed {
|
||
return d
|
||
}
|
||
for _, sc := range r.scopes.Active() {
|
||
if sc.DisableNetwork {
|
||
return deny("network access is disabled by plugin scope %q", sc.ID)
|
||
}
|
||
}
|
||
if prof, ok := r.profilePolicy(); ok && prof.DisableNetwork {
|
||
return deny("network access is disabled by profile %q", prof.Name)
|
||
}
|
||
return allowed()
|
||
}
|
||
|
||
// CheckPath 判定路径是否被沙箱允许。
|
||
func (r *Runtime) CheckPath(direction, path string) Decision {
|
||
if r == nil {
|
||
return allowed()
|
||
}
|
||
return r.policy.CheckPath(direction, path)
|
||
}
|
||
|
||
// Content 返回当前内容审查策略(只读快照)。
|
||
func (r *Runtime) Content() ContentPolicy {
|
||
if r == nil {
|
||
return ContentPolicy{}
|
||
}
|
||
return r.content
|
||
}
|
||
|
||
// effectiveContent 把作用域里追加的敏感词并进基础策略。
|
||
func (r *Runtime) effectiveContent() ContentPolicy {
|
||
p := r.content
|
||
if prof, ok := r.profilePolicy(); ok && len(prof.SensitiveWords) > 0 {
|
||
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), prof.SensitiveWords...)
|
||
}
|
||
for _, sc := range r.scopes.Active() {
|
||
if len(sc.SensitiveWords) > 0 {
|
||
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), sc.SensitiveWords...)
|
||
}
|
||
}
|
||
return p
|
||
}
|
||
|
||
// ScanContent 审查一段文本,返回命中(凭据由 ScrubInbound 另行处理)。
|
||
//
|
||
// 返回值第二项表示"是否必须阻断"(敏感词,或策略要求阻断的注入迹象)。
|
||
func (r *Runtime) ScanContent(ins Inspection, text string) ([]Finding, bool, string) {
|
||
if r == nil || text == "" {
|
||
return nil, false, ""
|
||
}
|
||
p := r.effectiveContent()
|
||
findings := p.Scan(text, ins.where())
|
||
blocked, reason := p.BlockedBy(findings)
|
||
return findings, blocked, reason
|
||
}
|