mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +00:00
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 包全绿
251 lines
7.6 KiB
Go
251 lines
7.6 KiB
Go
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)
|
||
}
|