Files
HomeAgent/internal/security/runtime.go
JianFeeeee 4af07d08fe fix(memory,llm): L2 预填充并入召回主路 + 回退换 provider 时换 model(视觉失效真因)
## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有
设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto
时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。
但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**;
QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」,
与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。
(现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里
 memory recall (input…) 只有 5 次、且 query 是元信息。)

改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文,
两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。
中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆),
这是设计要求,未改动。

## 2. 回退换 provider 时必须换 model(视觉失效的真因)
现场日志:
  deepseek 402 Insufficient Balance
  → 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」
  → llmsproxy 403 model_not_allowed
  → llmsproxy 被整源标记 unavailable(冷却)
  → 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」

两处修正:
  · 每个 provider 用自己的 model 发请求(model 是 provider 的属性)
  · 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model,
    provider 仍可用于其他 model 与视觉回退

判据:internal/agent/core/provider_model_fallback_test.go
2026-10-27 11:49:26 +08:00

343 lines
11 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"
)
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
}
// 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
func (r *Runtime) CheckTool(name string) Decision {
if r == nil {
return allowed()
}
if d := r.policy.CheckTool(name); !d.Allowed {
return d
}
// ★ 活跃档位(可选):插件没声明/没激活时这里零影响。
// 档位只参与**收窄**:它的 deny / allow 与主体、全局同一条链,
// 并且不能绕过全局黑名单(上面已先判过)。
if prof, ok := r.profilePolicy(); 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
}