mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 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
343 lines
11 KiB
Go
343 lines
11 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
|
||
}
|
||
|
||
// 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
|
||
}
|