Files
HomeAgent/internal/security/runtime.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

335 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
// 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(),
scopes: NewScopeRegistry(),
principals: newPrincipalRegistry(),
profiles: newProfileRegistry(),
active: &profileActivator{},
}
}
// NewDisabledRuntime 构造一个零成本透传的运行时(安全模块被显式关闭时用)。
func NewDisabledRuntime() *Runtime {
// 即使禁用也装一个策略表:否则"关闭凭据防火墙"会连带让工具策略失效,
// 而这是两件不同的事(用户可以只要策略、不要凭据替换)。
return &Runtime{
enabled: false,
policy: DefaultPolicy(),
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
}