mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 用户裁定
> 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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
519 lines
19 KiB
Go
519 lines
19 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"
|
||
)
|
||
|
||
// 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
|
||
}
|