Files
HomeAgent/internal/security/runtime.go
JianFeeeee 5bdd0b5107 refactor(security): Store 复用 Codec 加密能力 + 删 globalCodec 单例
## 用户裁定

> store也应该调用security的加密能力!

## 改动前的事实(实测)

两套**完全重复**的 AES-256-GCM:

    Store.seal/open     store.go:372,388    裸 base64
    Codec.Seal/Open     codec.go:113,139    enc:v1: 前缀 + 域隔离 + 幂等

算法、nonce 布局(前置 + io.ReadFull(rand))、密钥环轮换逻辑全都一样,
只有 Codec 多了前缀与域隔离。

危害不是"代码重复"这么轻:

  · 两套实现各自演化,任何一侧的改动不会被另一侧的判据发现
  · Store 缺域隔离 ⇒ 变量库与配置共用密钥(安全倒退)
  · Store 缺前缀 ⇒ 无法区分"旧密文"与"明文"(惰性迁移的障碍)

## 本次做的

### Store 委托 Codec

    codecOf()            按当前 keys 环惰性构建(轮换后自动跟上)
    sealWithRing()       新格式(enc:v1: 前缀)
    openWithStoreRing()  兼容新旧两种格式,返回 needsMigrate

删除 Store 自带的 seal/open/openWithRing —— 现在**只有一处** AES-GCM。

### 域隔离(安全判据)

    storeCodecDomain  = "security/variables"
    configCodecDomain = "config-at-rest"

两者必须不同:同域时一处明文泄漏可推导另一处密文。

### 存量密文惰性迁移

旧格式是裸 base64 无前缀,而 Codec.Open 对无前缀输入**原样返回明文**
(那是给 config 的存量明文兼容)。直接交给 Open 会把密文当明文返回,
症状是"凭据回填出一个 base64 串"。

所以 Store 走 openWithStoreRing:

    enc:v1:... → Codec.Open
    裸 base64  → Codec.OpenLegacy(解开后立刻回写新格式)

不做启动时全量迁移(21G 数据目录的教训:迁移窗口越长越危险),
也不让两种格式永久共存(判据永不能要求"落盘都是新格式")。

### 删 globalCodec 单例 → 归 Runtime

    security 模块 → rt.SetCodec(c) → ks.Security
    config 模块   → cfgReg.SetCodec(ks.Security.Codec())

ConfigRegistry 加 codec 字段(注入而非读全局),6 处 ConfigCodec() 改为
r.codecOf();PluginSettings 经 p.registry 转调。
sdk.SecretsAPI 也加 codec 参数(插件加密能力来自它的内核实例)。

### 顺带

  · InitConfigCodec* / ClearConfigCodec 删除,改 NewConfigCodec 返回实例
  · homed-key-rotate 改为 构造 + reg.SetCodec(不再装全局)
  · bootstrap_order_test 的锚点从 InitConfigCodecWithOldKeys 改为
    NewConfigCodec(守的不变量不变,只是生效点写法变了)

## 判据(均经变异验证)

  TestNoPackageLevelSingletons          禁表补入 globalCodec
  TestStoreWritesPrefixedCiphertext     新密文必须带 enc:v1:(旧实现裸 base64 ⇒ 红)
  TestStoreAndConfigUseDifferentDomains 域隔离;改成同域 ⇒ 红(实测确认)
  TestLegacyCiphertextStillReadableAndMigrates  旧格式可读 + 落盘已升级
  TestRotationKeepsOldCiphertextReadable        轮换后旧条目仍可回填

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + security/config 测试全绿

## 数据风险

加密实现切换有丢数据风险,因此:
  · 先写往返判据,再改实现
  · 惰性迁移(读到才升级),不做启动全量重写
  · 旧格式与旧密钥环**都保留**读取路径
2026-11-04 12:22:08 +08:00

519 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
)
// 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 执行视图」。
// codec 是配置落盘加密执行者(独立域,与变量库不共用密钥派生)。
//
// ★ 2026-11-04(T3.2):此前是包级单例 `globalCodec`(ConfigCodec())。
// 那个单例绕开了内核统一挂载点,且让"哪个 codec 生效"取决于
// "最后谁调了 InitConfigCodec"。
//
// 现在它归 Runtime:config 模块从 ks.Security.Codec() 取,
// 与变量库(ks.Security.Store())同源同生命周期。
codec *Codec
scopesByAgent map[string]*AgentScope
scopeMu sync.Mutex
}
// NewRuntime 用已装好的变量库构造运行时。
// 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{},
}
}
// ★ 2026-11-04:进程级单例 `runtime *Runtime` 已删除(用户裁定
// 「所有共享数据统一挂载一个 kernel 提供的挂载点上暴露」)。
//
// 防火墙的产物现在是 `kstate.State.Security`:security 模块产出它,
// 消费方(agent / plugin-host / llm)各自从注入面取:
//
// AgentConfig.Security → a.security
// plugin.Registry.SetSecurityRuntime
// ProviderManager.SetSecretStore(变量库)
//
// 原先的 `RuntimeGlobal()` / `SetRuntimeGlobal()` 是**公开**访问器,
// 任何包都能绕开 ks 直接取 —— 那正是"两处各存一份、哪份权威取决于
// 装配顺序"的来源。装配器内部的幂等缓存改为 kernelmod/security 私有变量。
// 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)
}
// Codec 返回配置落盘加密器(未装配时为 nil)。
//
// ★ 与 Store() 的区别:Store 管**凭据别名**(@{sec_xxx}),
// Codec 管**配置项密文**(enc:v1:...)。两者是独立的密钥域,
// 同一把主密钥派生出的不同子密钥 —— 域隔离是安全要求。
func (r *Runtime) Codec() *Codec {
if r == nil {
return nil
}
return r.codec
}
// SetCodec 注入配置加密器(安全模块装配时调用)。
func (r *Runtime) SetCodec(c *Codec) {
if r == nil {
return
}
r.codec = c
}
// 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 把作用域里追加的敏感词并进基础策略。
//
// ⚠️ 它读的是**进程级 active 档位**(`profilePolicy`)。这是旧路径,
// 只适用于单执行体场景。多 agent 并发时必须用 effectiveContentFor(profileName),
// 否则会话档(session)声明的敏感词根本并不过来 —— 见下面的说明。
func (r *Runtime) effectiveContent() ContentPolicy {
return r.effectiveContentFor("")
}
// effectiveContentFor 按**指定档位**合并敏感词(per-agent 视图用)。
//
// ## 为什么必须拆出来(2026-11-02 实测)
//
// 2026-10-31 用户裁定「每个 agent 一个档位,分 sessionProfile 与
// longtimeProfile」后,档位状态从进程级 `Runtime.active` 迁到了
// `AgentScope`。工具策略同步改了(checkToolWithProfile 走
// profilePolicyByName),**但内容审查没改** —— 它仍读 profilePolicy()。
//
// 后果:插件在 session 档里声明的敏感词,永远并不进审查词表。
// 而 ScanContent 本身又没有调用方,两个缺陷叠在一起,
// 表现为"敏感词声明了完全没反应",且没有任何报错。
//
// profileName 为空时退回旧行为(进程级 active),保持兼容。
func (r *Runtime) effectiveContentFor(profileName string) ContentPolicy {
return r.effectiveContentForTool(profileName, "")
}
// effectiveContentForTool 按**指定档位 + 指定工具**合并敏感词。
//
// ## 为什么按工具再加一层(用户裁定 2026-11-03)
//
// > 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及**自己工具**的
// > 敏感词策略」
//
// 不同工具带回来的外部内容风险不同:web_fetch 拿的是网页正文(间接提示词
// 注入的主入口),qq_get_message 拿的是聊天记录。插件最清楚自己每个工具的
// 内容形态。
//
// ★ 语义**追加不覆盖**:整轮 SensitiveWords + 该工具追加的词。
//
// 当前处置是 B 档(标记+警示),多标一条只是多一句警示,漏标是安全缺口。
func (r *Runtime) effectiveContentForTool(profileName, toolName string) ContentPolicy {
p := r.content
if r == nil {
return p
}
var prof Profile
var ok bool
if profileName != "" {
prof, ok = r.profilePolicyByName(profileName)
} else {
prof, ok = r.profilePolicy()
}
if ok && len(prof.SensitiveWords) > 0 {
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), prof.SensitiveWords...)
}
// 注入迹象:档位可为本轮显式开启(默认 false,理由见 Profile.DetectInjection)。
if ok && prof.DetectInjection {
p.DetectInjection = true
}
// 工具级追加(在整轮词表之上)。toolName 为空或档位没声明该工具 ⇒ 零影响。
if ok && toolName != "" && len(prof.ToolSensitiveWords) > 0 {
for pattern, words := range prof.ToolSensitiveWords {
if !matchTool(pattern, toolName) || len(words) == 0 {
continue
}
p.SensitiveWords = append(append([]string{}, p.SensitiveWords...), words...)
}
}
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) {
return r.ScanContentFor(ins, text, "")
}
// ScanContentFor 按**指定档位**审查内容(per-agent 视图用)。
//
// profileName 是**该 agent 当前生效档位**(AgentScope.EffectiveProfile())。
// 传空串等价于 ScanContent(读进程级 active,单执行体场景)。
//
// 与 CheckTool 的分工对称:
//
// CheckTool(tool) ← AgentScope 用 EffectiveProfile 判工具
// ScanContentFor(ins,,p) ← 同一档位判内容
//
// 两条路必须用同一个档位名,否则"工具禁了但敏感词没生效"这类半开状态。
func (r *Runtime) ScanContentFor(ins Inspection, text, profileName string) ([]Finding, bool, string) {
return r.ScanContentForTool(ins, text, profileName, "")
}
// ScanContentForTool 按档位 + 工具审查内容(工具结果路径用)。
//
// toolName 是该结果出自哪个工具:档位可以为它单独追加敏感词。
// ScanContentForAgent 按**执行体身份**审查内容(工具结果路径的正路)。
//
// ## 为什么这是正路(2026-11-03,用户裁定)
//
// > 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
// > 敏感词策略」
//
// 工具结果属于某个执行体,属于某一轮;但**调用方不该把轮次句柄搬过来**:
//
// · 工具执行发生在 LLM 决定之后,插件已不在调用栈上;
// · 一个 agent 有多个工具执行路径(主链、批量、rtchild、子 agent);
// 每加一条就要求调用方多传一次句柄 —— 那是把分层策略实现成
// 「跨层传参的莲藕」,本仓 profileActivator 的设计注释已明确反对:
// 内核在输入进来时记住,工具判定时**自己读**。
//
// 所以:security 按 agentID+isChild 取**它自己的** AgentScope,
// 读 session > longtime 的生效档位(per-agent,天然不串档),
// 再叠加该工具声明的词表。
//
// ★ 与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
func (r *Runtime) ScanContentForAgent(agentID string, isChild bool, ins Inspection, text string) ([]Finding, bool, string) {
if r == nil || text == "" {
return nil, false, ""
}
var profileName string
if sc := r.ForAgent(agentID, isChild); sc != nil {
profileName = sc.EffectiveProfile()
}
return r.ScanContentForTool(ins, text, profileName, ins.Tool)
}
func (r *Runtime) ScanContentForTool(ins Inspection, text, profileName, toolName string) ([]Finding, bool, string) {
if r == nil || text == "" {
return nil, false, ""
}
p := r.effectiveContentForTool(profileName, toolName)
findings := p.Scan(text, ins.where())
blocked, reason := p.BlockedBy(findings)
return findings, blocked, reason
}