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

@ -39,6 +39,31 @@ func TestSecurityModuleInitializesBeforeConfigSnapshot(t *testing.T) {
}
src := string(b)
// ★ 2026-10-26 起安全装配搬进正式内核模块 internal/kernelmod/security。
// 判据必须继续守**真实的装配点**,所以把模块源码一起读进来:
// 否则锚点(LoadOrCreateKey / InitConfigCodecWithOldKeys)会"找不到",
// 而那种失败会被误读成"安全模块没了"。不变量本身不变:
// 主密钥必须先于任何读 core.llm.* 的操作。
if mb, merr := os.ReadFile("../../internal/kernelmod/security/mod.go"); merr == nil {
src += "\n" + string(mb)
} else {
t.Fatalf("读不到 kernelmod/security/mod.go(安全模块的实现文件): %v", merr)
}
lineOfMain := func(call string) int {
for i, ln := range strings.Split(string(b), "\n") {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, "//") {
continue
}
if strings.Contains(ln, call) {
return i + 1
}
}
return -1
}
// 装配入口现在是 main.go 里的 securitymod.Prepare(...):它必须早于快照。
prepLine := lineOfMain("securitymod.Prepare(")
// 找**真实的调用点**:行内出现调用名,且该行不是注释。
//
// 为什么用 Contains 而不是 HasPrefix:真实形式有三种
@ -76,34 +101,45 @@ func TestSecurityModuleInitializesBeforeConfigSnapshot(t *testing.T) {
"**不要**删掉这条判据:它守的是「主密钥必须先于配置快照」")
}
// 安全模块的**生效点**是 InitConfigCodecWithOldKeys(配置加密子密钥派生)
secLine := lineOf("security.InitConfigCodecWithOldKeys(")
// ★ 2026-10-26:模块内的调用是 sec.InitConfigCodecWithOldKeys(...),
// 裸源码读取(非 AST)匹配不到包名,故按函数名匹配。
secLine := lineOf("InitConfigCodecWithOldKeys(")
if secLine < 0 {
secLine = lineOf("security.InitConfigCodec(")
secLine = lineOf("InitConfigCodec(")
}
if secLine < 0 {
t.Fatal("找不到 InitConfigCodec 调用(安全模块装配点)")
}
// 密钥装载点也要在快照之前(两者都在安全块里)
keyLine := lineOf("security.LoadOrCreateKey(")
keyLine := lineOf("LoadOrCreateKey(")
if secLine > snapLine {
t.Errorf("★ 安全模块装配(第 %d 行)在配置快照(第 %d 行)**之后** —— "+
// 真正的"生效点"是 main.go 里对安全模块 Prepare() 的调用 ——
// 密钥装载与子密钥派生都在它内部完成。它必须在快照之前。
if prepLine <= 0 {
t.Fatalf("找不到 securitymod.Prepare( 调用(安全模块装配入口)")
}
if prepLine > snapLine {
t.Errorf("★ 安全模块装配(main.go 第 %d 行)在配置快照(第 %d 行)**之后** —— "+
"ToConfig() 会在无主密钥时读 core.llm.*.api_key,key 变空 ⇒ 全 provider 401。"+
"必须把整个安全块前移到 ToConfig() 之前", secLine, snapLine)
"必须把 securitymod.Prepare() 前移到 ToConfig() 之前", prepLine, snapLine)
}
if keyLine < 0 {
t.Fatal("找不到 LoadOrCreateKey 调用")
// 且模块内部**确实**装了密钥与子密钥(防止有人把 Prepare 改成空壳而判据仍绿)。
if secLine <= 0 || keyLine <= 0 {
t.Errorf("★ 安全模块实现里找不到密钥装配(InitConfigCodec=%d LoadOrCreateKey=%d)—— "+
"Prepare() 退化成空壳,安全审查将不生效", secLine, keyLine)
}
if keyLine > snapLine {
t.Errorf("★ 主密钥装载(第 %d 行)在配置快照(第 %d 行)之后 —— 同上",
keyLine, snapLine)
// ★ 行号只在**同一文件内**可比。密钥装载与子密钥派生现在都在
// kernelmod/security.Prepare() 内部,所以"主密钥先于快照"由上面
// prepLine < snapLine 保证;这里只需确认模块**确实**装了它们
//(防止 Prepare() 退化成空壳而判据仍绿)。
if keyLine <= 0 {
t.Fatal("安全模块实现里找不到 LoadOrCreateKey 调用(Prepare 可能被改成空壳)")
}
// 且 provider 注册必须用**快照之后**的 cfg(这条锚定"快照确实被使用了",
// 防止有人把 ToConfig() 挪到别处却没人发现)
// ★ 锚点已随"内核模块化"改写(2026-10-16):provider 注册搬进 llm 模块,
// 由 `RunKernelModules` 触发 ⇒ 新的等价锚点是它。
// 不变量不变:provider 注册必须晚于配置快照(否则拿到空 key)。
asmLine := lineOf("RunKernelModules(")
asmLine := lineOfMain("RunKernelModules(")
if asmLine > 0 && asmLine < snapLine {
t.Errorf("★ 内核模块装配(第 %d 行)早于配置快照(第 %d 行)—— "+
"llm 模块会拿到空 key 注册 provider", asmLine, snapLine)
@ -112,15 +148,16 @@ func TestSecurityModuleInitializesBeforeConfigSnapshot(t *testing.T) {
// ★ **第二个读取点**:SetLLMSnapshotFile 启动即快照 core.llm.*(会读密文)。
// 第一版判据只盯了 ToConfig() ⇒ 把它前移后判据变绿,但线上**照旧报**
// `listPrefix 解密 core.llm.api_key 失败: 无可用主密钥` ——
// 因为真正早于安全模块的是**这个**调用(main.go 里在 SeedDefaults 之后立刻执行)。
// 这正是本会话反复吃到的"判据只覆盖一个出口":修一个点,另一个点照样漏。
snapFileLine := lineOf("cfgReg.SetLLMSnapshotFile(")
// 因为真正早于安全模块的是**这个**调用。
// 这正是"判据只覆盖一个出口":修一个点,另一个点照样漏。
snapFileLine := lineOfMain("cfgReg.SetLLMSnapshotFile(")
if snapFileLine < 0 {
t.Fatal("找不到 SetLLMSnapshotFile 调用")
}
if snapFileLine < secLine {
if snapFileLine < prepLine {
t.Errorf("★ SetLLMSnapshotFile(第 %d 行)早于安全模块装配(第 %d 行)—— "+
"启动即快照会读 core.llm.* 密文,主密钥还没装 ⇒ 密文解不开、快照写成空值,"+
"且日志刷 '无可用主密钥'。必须把安全块前移到它之前", snapFileLine, secLine)
"且日志刷 '无可用主密钥'。必须把 securitymod.Prepare() 前移到它之前",
snapFileLine, prepLine)
}
}

View File

@ -8,6 +8,7 @@ import (
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod/pluginhost"
securitymod "github.com/JianFeeeee/HologramAgent/internal/kernelmod/security"
"github.com/JianFeeeee/HologramAgent/internal/meta"
// ★ 内置插件统一走 internal/plugins 的清单(all.go),**不要**在这里
// 单独导入某个插件:
@ -15,7 +16,6 @@ import (
// —— 按它盘点的工具/文档会漏项(kbtree 曾因此长期游离在清单之外);
// · 想增删内置插件请改 all.go。
_ "github.com/JianFeeeee/HologramAgent/internal/plugins"
"github.com/JianFeeeee/HologramAgent/internal/security"
"github.com/JianFeeeee/HologramAgent/internal/systemd"
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
@ -104,81 +104,14 @@ func main() {
//
// 约束:安全块只依赖 cfgReg(不依赖 cfg),所以可以安全前移。
// ========================================================================
if cfgReg.GetBool("core.security.enabled", true) {
// ★ 先收敛数据目录与敏感文件权限(幂等),再读密钥。
//
// 顺序有意为之:内嵌主密钥把密钥放进了 0755 的二进制里,
// 而 config.db 出厂是 0644 —— 「能读二进制就能解密数据库」。
// 收紧到 0600/0750 是本机缓解手段,必须在服务开始读写之前生效。
//
// 失败不 Fatal(权限问题不该让服务不可用),但会逐条响亮记录。
if hr := security.HardenDataDir(opt.dataDir); len(hr.Changed) > 0 {
log.Printf("[homed] hardened %d file permissions (necessary mitigation for embedded-key scenario): %v",
len(hr.Changed), hr.Changed)
}
key, keySrc, kerr := security.LoadOrCreateKey(opt.dataDir)
if kerr != nil {
log.Fatalf("[homed] security key init failed (refusing to start in degraded mode): %v", kerr)
}
// E-1:若存在密钥环,装入历史钥(只读旧密文;当前钥仍由
// systemd credential/env/security.key 提供)。密钥环损坏或 current 不一致
// 必须响亮失败,不能猜一把钥继续启动。
oldKeys, rerr := security.LoadKeyring(opt.dataDir, key)
if rerr != nil {
log.Fatalf("[homed] security keyring load failed (refusing to silently pick wrong key): %v", rerr)
}
// ★ 内嵌钥上台时,磁盘钥必须继续参与**解密**。
//
// 否则一台用磁盘钥加密过的老机器升级到内嵌了另一把钥的二进制后,
// 旧密文会全部解不开 ⇒ 服务起不来;用户此时若选择"重建库",
// 凭据就**一次性全丢** —— 那恰好与内嵌的初衷(防丢)相反。
//
// 与 E-1 同一套语义:当前钥负责 Seal,旧钥只参与 Open。
if extra := security.AdditionalDecryptKeys(opt.dataDir, key); len(extra) > 0 {
oldKeys = append(oldKeys, extra...)
}
if len(oldKeys) > 0 {
log.Printf("[homed] security module loaded %d historical/extra key(s) (E-1 smooth rotation + embedded migration)", len(oldKeys))
}
if _, serr := security.InitGlobal(security.Options{
Path: filepath.Join(opt.dataDir, "security", "variables.json"),
KeyFn: func() ([]byte, error) { return key, nil },
OldKeys: oldKeys,
Enabled: true,
}); serr != nil {
log.Fatalf("[homed] security variable store load failed (will not silently rebuild): %v", serr)
}
// 配置落盘用的子密钥(域隔离:与别名密钥不共用)。
// 必须早于 EncryptExistingSecrets,后者依赖它。
if _, cerr := security.InitConfigCodecWithOldKeys(key, oldKeys); cerr != nil {
log.Fatalf("[homed] config-encryption subkey derivation failed: %v", cerr)
}
// 把库里仍是明文的敏感项就地加密(幂等;升级路径必须跑)。
if n, merr := cfgReg.EncryptExistingSecrets(); merr != nil {
// 不 Fatal:库可能是只读/被锁,而“部分为明文”不等于服务不可用。
// 但要**响亮告警**:这是用户必须知道的安全事实。
log.Printf("[homed] existing sensitive config encryption incomplete (some entries still plaintext): %v", merr)
} else if n > 0 {
log.Printf("[homed] encrypted %d existing sensitive config entries", n)
}
log.Printf("[homed] security module enabled (key source %s, %d variables, config-at-rest encryption on)",
keySrc, security.Global().Count())
// ★ E-2:备份跨机器恢复的诊断。
//
// 场景:把 config.db 备份恢复到另一台机器,而主密钥没一起搬过来
// ⇒ 库里密文是**旧机器密钥**加的,当前钥解不开。
// 此前这种状态只在“读某一条配置”时才暴露成“某个功能坏了”,
// 用户看到的是症状而不是原因。这里一次性说清事实与修复动作。
if okN, badN, aerr := cfgReg.AuditSecrets(); aerr == nil && badN > 0 {
log.Printf("[homed] WARNING: %d ciphertext entries cannot be decrypted with current master key (%d decryptable) — " +
"most common cause: this config.db came from **another machine / another key**. " +
"Fix: bring the original machine's key over (systemd credential / %s / " +
"%s), or start once with the old key and run the re-encryption migration.",
badN, okN, security.EnvKeyName, security.KeyringFileName)
}
} else {
log.Printf("[homed] security module explicitly disabled (core.security.enabled=false) — data at rest is plaintext and relay is unprotected")
// ★ 安全模块的装配已迁到 internal/kernelmod/security(正式内核模块)。
//
// 为什么仍在这里调:密钥与变量库必须早于 `cfgReg.ToConfig()`
// (否则读 core.llm.* 的密文解不开 ⇒ 全部 provider 401 invalid api key,
// 症状极具误导性)。那是**时序**要求,依赖图表达不了 —— 与 Group 0
// 其他动作同性质。模块侧提供幂等的 Prepare(),调度时 Init 会复用同一实例。
if _, serr := securitymod.Prepare(opt.dataDir, cfgReg); serr != nil {
log.Fatalf("[homed] security module init failed (refusing to start in degraded mode): %v", serr)
}
// ========================================================================
@ -302,7 +235,6 @@ func main() {
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
defer stopRuntime()
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
defer closeIPC()