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 把作用域里追加的敏感词并进基础策略。 // // ⚠️ 它读的是**进程级 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 }