Files
HomeAgent/internal/security/profile.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

251 lines
7.6 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
// 插件**命名安全限式**(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)
}