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 包全绿
This commit is contained in:
JianFeeeee
2026-10-26 20:55:25 +08:00
parent ca956f69bf
commit 55fa2685ae
92 changed files with 3932 additions and 596 deletions

View File

@ -0,0 +1,250 @@
package security
// 插件**命名安全限式**(policy profile):插件声明风格,inputch 触发时选一个。
//
// ## 用户模型(2026-10-26)
//
// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数
// > 选择其中一种 —— 这样正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
//
// 于是"谁能做什么"变成**插件自己声明的命名档位**:
//
// qq 声明: "owner" → 允许开发类工具(cmd_* / files_write …)
// "guest" → 只允许聊天类工具
// "group" → 更窄(不得私发文件)
//
// 而每条 inputch 输入携带 `SecurityProfile: "qq.guest"`。
// 内核据此在该轮激活对应档位 —— **插件一行判定代码都不写**。
//
// ## 为什么档位必须由内核持有
//
// ① 插件崩了/被重载,档位不能跟着消失(否则挂掉插件即可绕过)
// ② 判定必须与全局策略、主体策略**同一条链**(否则又出现"两套语义")
// ③ 拒绝文案要统一(模型看到的拒绝语义必须一致,否则它会反复重试)
//
// ## 档位不是"提权"
//
// 档位只在**收窄**方向生效:它可以追加 deny、追加敏感词、收紧沙箱,
// 但**不能**绕过全局黑名单或全局白名单(判据见下)。
import (
"fmt"
"strings"
"sync"
"time"
)
// Profile 是插件声明的一个命名限式。
type Profile struct {
// Name 是**全局唯一**的档位名,约定 `<插件名>.<档位>`(如 "qq.owner")。
//
// ★ 强制前缀约定:拼错时能被立刻发现(`qq.ownr` 找不到 ⇒ 走默认档位
// 而不是静默放行)。
Name string
// Owner 是声明它的插件(审计用;跨插件覆盖会被拒绝)。
Owner string
// Policy 是这一档的能力集合。
Policy PrincipalPolicy
// SensitiveWords 是这一档追加的敏感词。
SensitiveWords []string
// DisableNetwork 在这一档内禁止网络意图。
DisableNetwork bool
// Sandbox 覆盖沙箱约束(零值 = 沿用全局)。
Sandbox SandboxPolicy
}
// profileRegistry 持有全部已声明档位(进程级,内核持有)。
type profileRegistry struct {
mu sync.RWMutex
byName map[string]Profile
}
func newProfileRegistry() *profileRegistry {
return &profileRegistry{byName: map[string]Profile{}}
}
// Declare 登记一个档位。
//
// 覆盖规则:**同一 Owner 可覆盖自己的档位**(便于插件更新),
// 但**不能覆盖别人的**(否则任何插件都能改写 QQ 的 owner 档 = 提权口子)。
func (r *profileRegistry) Declare(p Profile) error {
if r == nil {
return errNoRuntime
}
name := strings.TrimSpace(p.Name)
if name == "" {
return &policyErr{"security: profile name must not be empty"}
}
if p.Owner == "" {
return &policyErr{"security: profile " + name + " must declare Owner (audit + overwrite protection)"}
}
// 约定 `<owner>.<档位>`:档位名必须带自己的插件名前缀。
// 这样"跨插件覆盖"天然被名字挡住,而不是只靠下面的检查。
if !strings.HasPrefix(name, p.Owner+".") {
return &policyErr{fmt.Sprintf(
"security: profile %q 必须以声明者前缀 %q 开头(防止跨插件覆盖)", name, p.Owner+".")}
}
r.mu.Lock()
defer r.mu.Unlock()
if old, ok := r.byName[name]; ok && old.Owner != p.Owner {
return &policyErr{fmt.Sprintf(
"security: profile %q 已由 %q 声明,%q 不能覆盖", name, old.Owner, p.Owner)}
}
r.byName[name] = p
return nil
}
// Get 查询档位。
func (r *profileRegistry) Get(name string) (Profile, bool) {
if r == nil {
return Profile{}, false
}
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.byName[name]
return p, ok
}
// Names 返回全部档位名(状态面用,排序后返回更稳定)。
func (r *profileRegistry) Names() []string {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.byName))
for n := range r.byName {
out = append(out, n)
}
return out
}
// activeProfile 是一次已激活的档位(带 TTL)。
type activeProfile struct {
name string
expires time.Time
}
// profileActivator 持有"当前活跃档位"。
//
// ★ 为什么是"当前活跃"而不是"每次调用都传":
// 工具调用发生在 LLM 决定之后,此时插件已经不在调用栈上(异步通道、
// 多轮工具循环)。唯一可靠的传递方式是**内核在输入进来时记住**,
// 在该轮的工具判定里使用。
type profileActivator struct {
mu sync.RWMutex
active activeProfile
}
// Activate 激活一个档位(带 TTL)。
func (a *profileActivator) Activate(name string, ttl time.Duration) {
if a == nil || name == "" {
return
}
if ttl <= 0 {
ttl = scopeTTLDefault
}
a.mu.Lock()
a.active = activeProfile{name: name, expires: time.Now().Add(ttl)}
a.mu.Unlock()
}
// Current 返回当前活跃档位名(过期即空)。
func (a *profileActivator) Current() string {
if a == nil {
return ""
}
a.mu.RLock()
cur := a.active
a.mu.RUnlock()
if cur.name == "" || time.Now().After(cur.expires) {
return ""
}
return cur.name
}
// Clear 清除活跃档位(一轮结束/换来源时调用)。
func (a *profileActivator) Clear() {
if a == nil {
return
}
a.mu.Lock()
a.active = activeProfile{}
a.mu.Unlock()
}
// ───────────────────── Runtime 上的对外入口 ─────────────────────
// DeclareProfile 是**插件声明限式**的入口(SDK 转发到这里)。
func (r *Runtime) DeclareProfile(p Profile) error {
if r == nil || r.profiles == nil {
return errNoRuntime
}
return r.profiles.Declare(p)
}
// Profiles 返回已声明档位名(诊断/状态面用)。
func (r *Runtime) Profiles() []string {
if r == nil || r.profiles == nil {
return nil
}
return r.profiles.Names()
}
// ActivateProfile 在 inputch 输入进来时激活插件选择的档位。
//
// 调用点:注入层拿到 `InjectOptions.SecurityProfile` 之后。
// ★ 未声明的档位**不静默放行**:返回错误,让注入方能看见配置错了
// (拼错档位名却默默用默认档,是安全事故的经典形态)。
func (r *Runtime) ActivateProfile(name string, ttl time.Duration) error {
if r == nil || r.profiles == nil {
return errNoRuntime
}
if name == "" {
// 空 = 不使用档位(沿用全局策略)。这是**显式**选择,不是拼错。
if r.active != nil {
r.active.Clear()
}
return nil
}
if _, ok := r.profiles.Get(name); !ok {
return &policyErr{fmt.Sprintf(
"security: 未声明的安全档位 %q(已声明: %v)—— 拼错不会退化,直接报错", name, r.profiles.Names())}
}
if r.active == nil {
return errNoRuntime
}
r.active.Activate(name, ttl)
return nil
}
// ActiveProfile 返回当前活跃档位名(空 = 未使用档位)。
func (r *Runtime) ActiveProfile() string {
if r == nil {
return ""
}
return r.active.Current()
}
// ErrNoRuntimeForBridge 表示桥接层拿到 nil Runtime(装配顺序错误)。
var ErrNoRuntimeForBridge = &policyErr{"security: security runtime is not initialized"}
// TTLSeconds 把秒数转成 Duration(0 = 用默认 TTL)。
//
// 为什么桥接层用秒而不是 Duration:SDK 是跨语言的(Lua/Go/外部进程),
// 秒是唯一无歧义的单位。
func TTLSeconds(n int) time.Duration {
if n <= 0 {
return 0
}
return time.Duration(n) * time.Second
}
// profilePolicy 取当前活跃档位的策略(无档位时返回零值 + false)。
func (r *Runtime) profilePolicy() (Profile, bool) {
name := r.ActiveProfile()
if name == "" || r.profiles == nil {
return Profile{}, false
}
return r.profiles.Get(name)
}