Files
HomeAgent/docs/zh/kernel-secret-firewall-design.md
JianFeeeee a68d6a428c feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:18:24 +08:00

50 KiB
Raw Blame History

内核安全模块(Secret Firewall)设计 · 计划追加

状态:已实现(持续审计中)。 本文的安全模块、配置加密、变量库统一与 C1–C8 判据已落地;E-1/E-2/E-3/E-4 已完成;剩余项见文末实现边界。本文保留原始设计与逐条审计记录。 记录一条由用户纠正后确立的架构决定,以及它带来的简化与下沉。

撰写时间:2026-10-14。触发:用户在讨论 commondev 加密 shell 时提出两点纠正。


0. 用户的两条纠正(原话,不可转述走样)

「『拦截后让 agent 用变量读取』意味着被拦截的明文要能再取回。 并不是明文取回,而是后续使用仅可使用变量替代。 我觉得这部分需要下沉到内核内部作为安全模块存在了。 (因为 llms 调用本身是不可信的)」

「这样插件设计就简单了,可以直接将密钥明文投到消息内部, 由内核安全机制全权负责」

这两条纠正了一个错误方向,本文先记录错在哪,再记录纠正后的设计。


1. 被纠正的错误方向(前稿的做法)

前稿(commondev-agent-runtime-design.md §2.7 之后那批讨论)里,我把 "敏感信息拦截"实现为 commondev 插件内的 stage:

// 前稿:插件在 StageOnInput / StageAfterToolcall 上拦截
p.sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
    res := p.icept.Intercept(ctx.RawMessage, "input")
    ctx.RawMessage = res.Text   // 换成 @{sec_1} 提示
    ...
})

错在两处:

# 错误 为什么错
1 把拦截器放在插件里 插件在信任边界之外。它是独立进程,别的插件(或它自己崩溃/被替换)都能绕过。而"LLM 调用不可信"意味着每个 LLM 调用都要过这道闸——插件的注册是自愿的,不是强制的
2 把"可再取回"当成需求 前稿甚至设计了 enc_show 之外的取回路径。但用户要的是只能替代引用,永远拿不到明文 —— 一旦有取回路径,LLM 就能把明文写回上下文,整层作废

1.1 还暴露了两个真实缺陷 [已核 · 单测抓到]

即便只作为插件实现,前稿也有两个 bug(保留记录,因为它们说明"判据在起作用"):

  1. *SensitiveInterceptor nil 时 panic —— Intercept 里 si.enabled 直接解引用。 已修(si == nil ||),这是真实健壮性问题:宿主可能在任何阶段调用。
  2. 赋值形态丢失键名 —— api_key = "xxx" 被整体替成提示,人看不出发生了什么。 splitKeyValue 对 assigned_secret 的命中形态判断有误。修法未完成(本文第 5 节列为待办)。

教训:这两个缺陷都是单测发现的,不是读码。印证本仓纪律——判据必须跑。


2. 纠正后的架构:下沉为内核安全模块

2.1 出向落点:provider 层(实测推翻初稿) [已核 · 源码确认]

初稿写“唯一出网点是 task.go:663”。实测扫全仓后这个结论是错的 —— 构造 CompletionRequest 的地方有 7 处(不含插件侧):

# 位置 用途
1 internal/agent/core/task.go:663 主对话轮次(最主要)
2 internal/agent/core/spawn.go:176 spawn_child 的工作式轻量子
3 internal/agent/core/spawn.go:321 LLM 相关工具(llm_*)
4 internal/agent/core/modalfallback.go:347 多模态回退调用
5 internal/agent/api/provider.go:1140 ProviderManager.QuickChat
6 internal/sdk/llm_impl.go:56 插件调 LLM(s.LLM().Chat)
7 internal/plugins/*(multimodal / healthcheck / remotedevice) 插件自己发起的 LLM 调用

⇒ 在 task 层拦一道会漏掉 4–7 四大类。其中 #6 尤其要紧: 插件经 s.LLM().Chat 发起的调用完全绕过主对话路径。

正确的落点:provider 层

所有路径最终都会进某个 Provider.Chat(ctx, *CompletionRequest)。所以安全模块 应放在provider 调用的统一层(装饰器/包装器),而不是 task 层:

任何来源 ──► CompletionRequest ──► ★ 安全包装器(必经)──► 真正的 Provider

待确认:是否存在不经 Provider.Chat 的出网路径(如某 provider 内部直发 HTTP)。 这一条必须先验(§5.1),否则安全模块仍有洞。

2.2 数据模型:明文可以自由流动,只在出口被替换

用户第二条纠正的直接后果:插件可以把密钥明文投到消息内部。

插件/工具/任何来源 ──(明文,随便用)──► f.Msgs
                                          │
                              ┌───────────▼───────────┐
                              │ 内核安全模块(必经)    │
                              │ 扫描 → 命中 → 换成别名  │
                              └───────────┬───────────┘
                                          ▼
                                   真正发往 LLM 的 req

好处(这是简化,不是妥协):

  • 插件不需要自己造加密库、别名表、stage 拦截(前稿那一整套)⇒ 插件变薄
  • 插件也不需要"安全地传输明文"的协议——直接投,内核负责
  • 别名表归内核管理 ⇒ 全局唯一,且与 agent 实例解耦

代价(必须写清):明文在内核进程内存里自由流动。 所以边界是"内核进程可信",不是"内存里不存在明文"。


2.3 ★ 不是“拦截”,而是“拦截 + 暂存 + 别名 + 回填” [用户 2026-10-14 纠正]

用户纠正了我“只做拦截”的单向理解,并给出完整回路:

「因为你只是简单的拦截的话,绝大部分插件会失去原本的功能。 安全模块是拦截,加密暂存,提供调用别名,收到 toolcall 后自动将别名填充进去。 因为内核 0 IO 设计,一切外部交互都是从插件走的,你只需要在 toolcall 和 outputch 部分有注入就行了」

⇒ 为何只拦截不行(这是本节的关键):

如果只在出向把明文换成 @{sec_1},则插件再也拿不到密钥 —— 而插件恰恰是唯一能真正用密钥的地方(push / 调 API / 写文件)。 结果就是:安全了,但活干不了了。那等于把功能删掉,不是安全。

⇒ 必须是一个闭环:

① 拦截    LLM 出向前扫到明文  ──► 换成 @{别名}
② 加密暂存 把明文 AES-GCM **落盘**在内核(不落明文)
③ 别名    ★ 哈希键(由明文派生,见 §2.4)
④ 回填    插件调用(toolcall / outputch)前,把 @{别名} 换回明文
          —— ● **插件无感知**:它只看到已经填好的明文

2.4 ★ 变量索引:哈希键 —— 于是自动获得“同变量合并” [用户 2026-10-14 纠正]

用户纠正了两件事(我初稿用的是 sec_1、sec_2 顺序号,那是错的):

「落盘暂存,各个变量通过哈希键索引,这样自然获得了同变量合并能力。 同时变量不自动淘汰,但是 agent 可主动调用安全模块提供的工具提交/删除」

为何哈希键是对的(顺序号错在哪)

顺序号 sec_<n>(错) 哈希键(对)
同一 secret 出现两次 得到 sec_1 / sec_2 两个别名 得到同一个别名 ⇒ ★ 自动合并
合并需要额外逻辑吗 需要(要扫描所有别名比对明文) 不需要(哈希本身就是键)
时序依赖 有(序号与拦截顺序绑定) 无(键由内容唯一确定)
重启后一致吗 依赖持久化的计数器 重算即可(同一明文 ⇒ 同一键)

⇒ “同变量合并”不是额外功能,而是哈希索引的天然性质 —— 这正是用户要的。 这也消除了一个隐患:同一把密钥在对话里出现 5 次,不应产生 5 个别名, 否则日志/记忆里会积起一堆指向同一密钥的不同名字。

键的构造

alias = "sec_" + hex( HMAC-SHA256(masterKey, plaintext)[:8] )

两个细节不能省:

  1. 必须用 HMAC(带 masterKey),不能裸 SHA256。 裸哈希对短/低熵输入可被字典枚举 —— 而别名会出现在上下文、日志、记忆里, 等于把“这个 secret 是什么”泄露出去。

    实测演示(本机跑过):取明文 hunter2,裸 SHA256 取前 8 字节作别名, 然后拿 5 个常见弱口令去试:

    别名(裸哈希): f52fbd32b2b3b86f
    ★ 字典命中:'hunter2' → 别名直接暴露了 secret 内容
    

    换成 HMAC(masterKey) 后,攻击者没有 masterKey 就无法枚举。

  2. 8 字节(16 hex) 足够:碰撞概率对本场景可忽略,而短别名省上下文。 实测别名形如 sec_3718c87a11e9478b(20 字符)。

为什么不加随机 salt:加了就失去合并能力(同一明文每次得到不同键)。 所以这里“确定性”是特性,不是缺陷;安全靠 HMAC 的密钥而不是 salt。

实测验证(本机跑过):

断言 结果
同明文两次 ⇒ 同一别名(自动合并) ✅ 两次都是 sec_3718c87a11e9478b
不同明文 ⇒ 不同别名 ✅
别名长度(要进上下文) ✅ 20 字符
换 masterKey ⇒ 别名变(键依赖密钥) ✅

生命周期:不自动淘汰,但可主动提交/删除

行为 谁决定 说明
自动淘汰 ❌ 不做 用户明确:变量不自动淘汰
主动提交 agent 调安全模块的工具 把某些明文显式登记为变量(即使尚未出现过)
主动删除 agent 调安全模块的工具 明文用完即弃(如轮换后)
持久化 内核 落盘暂存(加密),重启不丢

为何“不自动淘汰”是合理的:

  • 任务可能跨很多轮、跨多个子 agent(§2.6 全局共享),早先登记的变量随时要能用;
  • 自动淘汰需要定策略(按时间?按引用计数?)——而策略错了会导致“变量静默失效”、 任务在很后面才坏掉,且现象难归因;
  • 反过来,泄漏风险由“主动删除”控制,而不是靠超时时间。

⚠ 待定(T9):不自动淘汰 ⇒ 落盘文件会只增不减(除非人主动删)。 需要定一个可观测的规模上限降级策略(如超 N 条时告警而非静默删)。

2.5 ★ 两个注入点就够了(内核 0 IO) [已核 · 源码确认]

用户指出:“内核 0 IO 设计,一切外部交互都是从插件走的”。 ⇒ 于是只需在两根咽喉上做替换:

方向 咽喉 位置 作用
入向(→插件) StageHost.ExecuteTool(name, args) internal/agent/core/stages.go:140 toolcall 参数里的 @{别名} → 明文
入向(→插件) OutputChannel 的 device Execute internal/agent/core/output.go:76 输出载荷里的 @{别名} → 明文
出向(→LLM) provider 层的 Chat(req) internal/agent/api/(装饰器) f.Msgs 里的明文 → @{别名}

入向覆盖确认(调插件工具的入口)

路径 位置 覆盖
agent 发的 tool_call(主路径) toolcall.go:133 → stages.go:140 ✅
插件经 ToolAPI 调另一个插件 sdk/tool_impl.go:47 ✅
设备/通道工具 io/channel.go:714/730 ✅(走同一 Execute)
outputch 出向到插件 output.go:76 → dev.Execute ✅

⇒ 只需两处(StageHost.ExecuteTool + IOManager 的设备 Execute) 就覆盖全部插件交互 —— 因为内核自己不碰外部世界(零 IO)。 这是“零 IO”设计的一个意外红利:安全边界只有两个点。

2.6 为何“插件无感知”很重要(不是洁癖)

理由 说明
插件无法绕过 插件根本不知道有替换这回事 ⇒ 无法写“我自己解密” 的代码去绕开
存量插件无需改 所有已有插件(qq / browser / …)自动获得该能力
接口不变 不需要给 SDK 加任何参数 ⇒ 不破兼容(本仓“只增不减”)
职责单一 安全归内核;插件只管业务

⚠ 与“插件明文中转”的关系(用户拍板的简化)

用户已明确:“插件可以直接将密钥明文投到消息内部,由内核安全机制全权负责”。

⇒ 两者不矛盾,而是分工:

环节 谁负责
插件要向 LLM 交代“我用的是哪条密钥” 直接写明文(内核出向会把它变成 @{别名})
插件要实际用密钥(如 push) 写 @{别名},内核入向换成明文后再交给插件
别名表 / 加密存储 / 扦描 全部在内核(插件不参与、不知情)

待确认:入向替换也会换掉 cmd_run / terminal_write 的参数(它们是工具调用)。 这带来一个新问题 —— agent 可以把 @{别名} 写进任意命令参数,等于获得了 “在任意命令里使用明文”的能力。这正是用户要的(否则没法用密钥), 但它把“能做什么”的边界交回给了 命令本身(即 enc_shell 的安全边界问题)。详见 §5 T8。

2.7 ★ 变量系统是全局通用的(父子 agent 同一套) [用户 2026-10-14 追加]

用户明确:

「注意安全模块的变量系统是全局通用的,驻留式子 agent、子 agent、 主 agent 是同一套变量系统」

为何必须全局(不能 per-agent) [已核 · 源码确认]

驻留子共享父的对象(resident.go:184-185):

Provider:        a.provider,
ProviderManager: a.providerManager,   // ★ 共享同一个 ProviderManager
StageHost:       a.stageHost,

而 ProviderManager 全进程只建一份(cmd/homed/bootstrap.go:249 建一次; resident.go:185 把它传给每个子)。⇒ 安全模块(作为 provider 装饰器) 天然就是全进程单例。

为何“全局”是正确设计(而不只是实现便利)

理由 说明
否则无法跨 agent 协作 父拦截得到 @{sec_1},要能交给子去干活;子的 @{sec_1} 若不同则断链
别名不泄露就能共享 sec_<n> 本身不含明文,共享它不降低安全性
与 L4 归属正交 驻留子的 KernelSource/权限是 per-agent(隔离);变量系统是全局(共享)。两件事,不要混

⚠ 一个必须想清的后果:权限边界不因共享而放松

变量全局 ⇒ 任何 agent 都能写 @{sec_1}。但回填后能不能真的用,取决于该 agent 的工具/通道授权:

层 作用域 管什么
变量系统 ★ 全局 别名 ↔ 明文(不区分 agent)
AllowedOutputs / 工具授权 per-agent 该 agent 能不能调某个工具/通道
KernelSource(L4) per-agent 该 agent 的上级是谁

⇒ 子 agent 拿到了明文,但能否拿它去 push 仍受它自己的授权限。 两者是正交的两道门(变量共享 ≠ 授权共享)。

实现形态

⇒ 安全模块不能挂在 *Agent 上(那是 per-agent),而应:

internal/security/ 里的一个**进程级单例**(或挂在 ProviderManager 上)
    ├─ 别名表  alias → secret(全局)
    ├─ 出向扫描(provider 装饰器,天然单例)
    └─ 入向回填(StageHost.ExecuteTool / 设备 Execute;两者也是共享对象)

恰好舒服的地方:两个注入点(StageHost / IOManager)与出网点 (ProviderManager)都已经是共享对象 ⇒ 安全模块挂上去就是全局的, 不需要额外做“跨 agent 同步”。

2.8 与"两阶段拦截"的关系

前稿用 StageOnInput + StageAfterToolcall。那两个点仍然有意义,但不是安全边界:

位置 性质 说明
StageOnInput / StageAfterToolcall 提示性、可绕过 提前替换能让日志/记忆干净一些,但不构成安全
两根咽喉(入向 ExecuteTool/Execute,出向 Chat) ★ 强制的安全边界 无论前面漏了什么,这里都是最后一道且必经

⇒ 结论:安全责任放在咽喉上;前面的 stage 拦截降级为“卫生措施”(可选、可关)。

3. 硬约束(安全模块的不可让步项)

# 约束 理由
S1 不提供"取回明文"的任何接口 用户明确:只能替代引用。一旦有取回路径,LLM 就能把明文写回上下文,整层作废
S2 替换必须落在必经点上 插件是自愿注册的 ⇒ 插件侧拦不算边界。
已核两个必经点:出向 Provider.Chat、入向 StageHost.ExecuteTool + 设备 Execute
S3 别名全局唯一且不泄漏明文 ★ 哈希键:sec_ + HMAC-SHA256(masterKey, plain)[:8]。
必须用 HMAC 而非裸哈希(裸哈希可被字典枚举;而别名会出现在上下文/日志/记忆里);
不加 salt(加了就失去合并能力,确定性是特性)
S4 不可误伤 误报会把正常内容换掉、搞坏任务。模式库保守优先——宁可漏,不可误伤
S5 不得静默 每次拦截要可观测(别名、来源位置、长度、时间),否则出问题无从追查
S11 ★ 同变量自动合并 哈希键的天然性质:同一明文多次出现 ⇒ 同一个别名(不是多别名)。
不能退化成顺序号 —— 那会在日志/记忆里积起一堆指向同一密钥的名字
S12 ★ 不自动淘汰,但可主动提交/删除 用户明确:不自动淘汰(跨轮/跨子 agent 随时要用);
泄漏风险靠主动删除控制,不靠超时。自动淘汰错策略会导致“变量静默失效”
S6 关闭开关是显式特权 能关掉它的人必须知道自己关掉的是什么(与本仓 offload_enabled 同一条纪律)
S7 替换后仍要能干活 被替换掉的明文必须可用别名引用(否则任务直接坏掉)——这是 S1 与可用性的平衡点
S8 ★ 回填必须成对(只拦不填 = 删除功能) 用户明确:只拦截会让“绝大部分插件失去原本功能”
S9 ★ 插件无感知 替换发生在路由到插件的之前;插件代码不改、不可分辨、无法绕过
S10 ★ 变量系统全局唯一 主/子/驻留子 agent 同一套别名表。理由:ProviderManager 全进程只建一份(bootstrap.go:249),且共享才能跨 agent 协作。且与权限正交 —— 变量共享 ≠ 授权共享

S1 与 S7 不矛盾:别名可被引用(用于命令填充),但不可被读取。 读接口与用接口是两件事 —— 这正是前稿没分清的地方。


4. 需要改哪些地方(下沉清单)

总原则(用户 2026-10-14 拍板):安全模块 = 拦截 + 加密暂存 + 别名 + 回填, 插件完全无感知。因为内核零 IO(一切外部交互都从插件走), 所以只需在两根咽喉上做替换即可覆盖全部。

# 改动 位置 性质
C1 出向拦截(→LLM):包装 Provider.Chat,在 CompletionRequest 出网前扫到明文就换成 @{别名} + 加密暂存 internal/agent/api/(装饰器) 新增(核心)
C2 ★ 入向回填(→插件,toolcall):StageHost.ExecuteTool 入口把 args 里的 @{别名} 换回明文 internal/agent/core/stages.go:140 新增(核心)
C3 ★ 入向回填(→插件,outputch):IOManager 的设备 Execute 前把载荷里的 @{别名} 换回明文 internal/agent/io/channel.go / output.go:76 新增(核心)
C4 变量库:★ 哈希键索引 + 落盘加密暫存(internal/security/) 新建 internal/security/ 新增
C8 ★ 供 agent 调用的提交/删除工具(主动登记、主动删除) 内核内置工具(builtinToolOwner 注册) ✅ 已做(secret_submit/list/forget)
C5 LLM 返回也要扫:LLM 可能把明文编造/输出出来 ⇒ 返回内容同样过一遍 ✅ 已做:拦在 a.context.Append 咽喉处(非调用点)
C6 记忆写入前扫一次:防明文被写进长期记忆 ✅ 已做:Append/InsertByTimestamp + emitMemoryCandidate
C7 插件的加密 shell / enc_* 工具族大幅删减:不再自带加密库与别名表 commondev 插件 ✅ 已做(比原计划更保守,见 §5.4)

C2 + C3 是“插件无感知”的全部机制保证: 插件代码一行不改,也无法分辨自己拿到的是明文还是被替换过的。 无需给 SDK 加任何参数 ⇒ 不破接口兼容(本仓“只增不减”)。

C1 是对用户“两个终端工具需联动”那条要求的直接回答: 不必改 terminal_* / cmd_run 这两个内置插件 —— 它们的输出最终进 f.Msgs (被 C1 截),它们的参数走 ExecuteTool(被 C2 填),双向都覆盖。


5. 待办与未决

# 事项 说明
T1 修 splitKeyValue 对 assigned_secret 的键名丢失 ✅ 已修(插件侧;迁内核时可一并搬)。根因:键名后常带空格(api_key = "..."),旧判据把“含空格”当成“不像键名”就放弃拆分 ⇒ 整段被当 secret。已配变异验证
T9 不自动淘汰的规模上限 落盘文件只增不减(除非人主动删)。需定一个可观测的降级策略(如超 N 条时告警,而不是静默删某条)
T10 提交工具的泄露面 “主动提交”要把明文交给内核 —— 与 enc_store 同问题(明文会进会话/日志)。
应优先支持 ${ENV} / @file: 两条不明文入会话的路径
T2 把前稿的 intercept.go / encstore.go 从插件迁到内核 模式库可复用,别名/存储需重写为内核形态。★ 迁后本插件的 enc_* 工具族与加密 shell 应大幅删减(用户:插件对安全机制无感知)
T3 密钥从哪来(内核侧) 内核是长驻进程;密钥须由部署方提供(systemd 环境变量)还是内核自持密钥文件?未定
T4 变量持久化与生命周期 ✅ 已定:落盘加密持久化(重启不丢)、不自动淘汰、可主动删(§2.4)。剩余:落盘位置与 masterKey 来源(同 T3)
T5 压缩(compaction)对别名的影响 摘要可能把 @{sec_1} 当成普通文本;压缩后别名是否仍有效?
T6 明文在内存中的擦除 Go 字符串不可变 ⇒ 只能擦可写副本。要定"努力擦除"还是"不承诺"
T7 替代方案评估 内核是否可能拿不到 secret(如 SSH 密钥文件)⇒ 也许该支持"引用外部文件、内核只持路径"
T8 入向回填的安全边界 回填后 agent 可把 @{别名} 写进任意命令参数 ⇒ 等于获得“在任意命令里用明文”的能力。这正是必需的(否则没法用密钥),但“能做什么”的边界因此回到命令本身。要定:要不要限制哪些工具允许触发回填?

5.1 ★ 配置落盘加密 [已完成 · 真机验证]

用户需求(2026-10-14):通用配置在数据库里全是明文;配置管理器应提供 加密选项,并为插件暴露加密存储能力(密钥由内核统一管理)。

要解决的问题

config.db 是 0644 全局可读的(同目录还有 WAL/SHM),而里面装着 LLM API 密钥、WebUI 口令、插件凭据 —— 任何本机用户一条 sqlite3 查询 就能拿到全部明文。

做法:在存储边界加密,读写接口不变

Set/Register/RegisterDef/seed → Seal → DB 里是 enc:v1:<base64>
Get/Dump/ToConfig/GetString → Open → 调用方永远只看到明文

★ 关键取舍:读接口保持「返回明文」。

初稿曾想给 Dump() 返回掩码,但那会同时打断 ToConfig() (它靠 Dump 重建 LLMSource,含 api_key)与所有内部读取方 —— 而它们 本来就在可信边界内。本需求防的是「静态文件泄漏」,不是「内部读取」。 已配判据 TestToConfigStillGetsRealAPIKey 钉死这一点。

敏感判定(两种声明 + 键名兜底)

途径 说明
ConfigDef.Secret == true ★ SDK 里早就有这个字段,但从未实现
ConfigDef.Type == "password" 旧写法,兼容保留
键名模式 无 def 的插件键靠它兜底

键名匹配必须按「词段」而不是子串:初版用 strings.Contains(key,"token"), 把 core.llm.max_tokens(输出上限)与 core.agent.context.max_target_tokens (上下文预算)也加密了。真机跑出来才发现。现在改为切词后精确匹配, max_tokens 的末词段是 tokens(复数)⇒ 不命中。

密钥:域隔离派生

subKey = HMAC-SHA256(masterKey, "homeagent/config-at-rest/v1")

同一把主密钥服务多类用途(变量别名 HMAC、配置 AES、未来审计签名…)。 共用一把会在域之间产生攻击面(一处的输出变成另一处的合法输入), 所以每类用途派生独立子密钥。判据 TestDomainSeparation 钉住它。

密钥来源与安全模块一致:HOMED_SECURITY_KEY > <dataDir>/security.key(0600)。

为插件暴露的加密存储能力

SDK 新增两个方法(SettingsAPI):

方法 语义
SetSecret(key, value) 强制加密,不看键名(插件可能用 blob/conn 这类中性键名)
GetSecret(key) 读回(透明解密)

★ 无密钥时 SetSecret 拒绝写入,不静默存明文 —— 插件以为存了密文 而实际磁盘上是明文,是最危险的静默降级。

落点清单

# 改动 位置
E1 密钥域派生 + 编解码器 internal/security/codec.go
E2 敏感判定 + 迁移 + 加密写入 internal/config/secret.go
E3 读路径透明解密(Get/Dump/GetString/listPrefix/PluginSettings) internal/config/registry.go
E4 ★ Secret 标志透传(三处转换点都漏了它) internal/sdk/settings_impl.go
E5 显式加密 API(SetSecret/GetSecret) registry + sdk + SDK 接口
E6 ★ proc 桥:协议常量 + 3 处注册(路由/分发/能力表)+ 模板实现 internal/plugin/proc/、templates/proc_main.go.tmpl
E7 WebUI:密码框 + 显示/隐藏按钮 internal/plugins/webui/dashboard.js
E8 启动装配 + 存量迁移 cmd/homed/main.go

☆ 踩到的两个真坑

  1. Secret 字段从未生效的根因:SDK 一直有 Secret bool,但内核侧 三处转换点(RegisterDef/Defs/mapDefs)全都漏了它。 插件声明了意图,前端拿到的 meta 里却没有 —— 于是敏感项被渲染成 普通文本框明文回显。已配判据 TestSecretFlagReachesSettingsMeta。

  2. 新增 proc method 要改 4 个地方:协议常量、外层路由、内层分发、 能力表。我只改了分发,真机 e2e 立刻报 未知 method: settings.setSecret —— 这正是「真机端到端不可省」的实例。

  3. 守卫判据本身有盲区:TestCapability_AllMethodsClassified 号称 「本文件最重要的测试」,但它用手工维护的方法清单 —— 新方法不加进清单 就无人管(变异实验 V3 证实:删掉能力表条目,判据仍绿)。 已改为从 protocol.go 源码正则提取全部 method 常量,覆盖面自动跟上。

验证

  • internal/config/secret_test.go 17 条 + internal/security/codec 判据
  • internal/plugin/proc/e2e_secret_test.go —— 真实 hmapdev 模板编译的插件 跑完整链路:插件读回明文(27B)、磁盘存密文(83B)
  • internal/plugins/webui/secret_meta_test.go 2 条(声明→meta、UI 明文/磁盘密文)
  • 变异 6 条全部咬住(含 V3 暴露并修复的守卫盲区)
  • 真机:3 条存量迁移、明文残留 0、max_tokens 未被误加密
  • 全量回归 ./internal/... ./cmd/... ./pkg/... 零失败

5.2 前提验证(安全模块本体) [已完成 · 全部通过]

结论:安全模块可以稳在 provider 层。 两条前提均已实测:

P1:所有出网均经 Provider.Chat / ChatStream [已核]

事实 依据
Provider 是干净的两方法接口 internal/agent/api/provider.go:307(Chat + ChatStream + MaxContextTokens)
所有调用点都用接口方法 process.go:138/155/191、spawn.go:184/321、modalfallback.go:347、provider.go:1140
插件经 s.LLM().Chat 也回同一层 internal/sdk/llm_impl.go:48 → l.mgr.Default()
唯一一处内部直发 HTTP(provider.go:446) 在 LuaAdaptedProvider 实现内部,不是绕过
lua/vm.go 的 CallTransformRequest 是适配器层的载荷改写(在 provider 内部调用),不是绕过

P2:ProviderManager.Register 是唯一入口 [已核 · CodeGraph 交叉验证]

装饰 Register 即覆盖全部取用路径(Get / Default / OrderedProviders / ResolveForModel 都从同一 map 取)。

NewLuaAdaptedProvider 的全部调用者(CodeGraph 查得 6 个):

调用者 位置 是否经 Register
initLLMProviders cmd/homed/bootstrap.go:248 ✅ 下一行就是 providerMgr.Register
runFailbackRecovery cmd/homed/failback.go:28 ✅ Register("rescue", …)
ReloadFromConfig internal/sdk/llm_impl.go:115 ✅ l.mgr.Register
其余 3 个 测试文件 —

⇒ 无任何真实路径绕过 Register。

已落地的验证判据

internal/agent/api/security_premise_test.go(4 条,均配变异验证):

判据 验证什么
TestPremise_RegisterDecorateCoversAllAccessPaths 4 条取用路径都拿到装饰后实例
TestPremise_DecoratorSeesEveryChat Chat / ChatStream 都经装饰器
TestPremise_DecoratorCanRewriteMessages 装饰器能改写消息(不只是读)
TestPremise_StreamAlsoDecorated 流式也被覆盖

变异实验(两个都咬住):不包裹 ⇒ “装饰器应看到 1 次 Chat,实际 0”; 只读不改 ⇒ “明文被原样透传,改写未生效”。

方法论教训:这一步最初我用 grep 逐个查,漏了 sdk/llm.go 等入口, 也没发现 lua/vm.go 的变换链路。改用 CodeGraph(codegraph_callers / codegraph_node) 后,它一次列出 Chat 的 9 个调用者与 NewLuaAdaptedProvider 的 6 个调用者, 遗漏面直接归零。查接口关系用 codegraph,不要用 grep 翻。


5.3 ★ 持久化侧路的明文拦截(C5/C6) [已完成 · 真机验证]

发现的真实漏洞:出向拦截挡不住持久化

§4 的 C5/C6 原本是「计划项」。实测把它们变成了必须做 —— 因为发现了 一条完整的泄漏侧路:

出向拦截(provider 装饰器)只改 f.Msgs —— 要发出去的那一份。 但内核把同样内容另存副本送往持久化,它们不经过 provider:

持久化字段 来源副本 是否被 provider 拦住
ContextEvent.Input f.CleanInput(用户输入原文) ❌
ContextEvent.Response f.Response ❌
ContextEvent.ToolResults f.ToolResults ❌
memory_candidate.payload 同上(走 EmitOutput,不经 context) ❌

后果:明文被 LLM 看到之前虽已换成别名,但完整地写进了 context.json 与长期记忆 —— 而记忆跨会话保留、每轮被召回。泄漏面比重放一次大得多。

修法:拦在咽喉,不拦在调用点

写 context 的地方有 5 处(task.go ×3、toolcall.go ×1,加插入变体), memory_candidate 另走一条。逐个补必漏 —— 事实上第一版正是只补了 turnEvt,而漏掉了 f.CleanInput(判据实测确认泄漏仍在)。

最终落点:

咽喉 覆盖 文件
RelevanceContext.Append 全部追加写入 context.go
RelevanceContext.InsertByTimestamp 按时间插入(另一入口) context.go
Agent.emitMemoryCandidate 记忆候选(不经 context) distill.go
共用实现 scrubEventSecrets / scrubMemoryCandidate memory_scrub.go

清除字段:Input / Response / ToolResults[].Output / Blocks[].Text(仅文本块;图像/视频/音频的 Data 是二进制负载, 正则扫它既无意义又会损坏数据)。

☆ 一处必须记录的脆弱性

实测发现:Append 的 scrub 会连带改掉 f.ToolResults —— 因为 turnEvt.ToolResults = f.ToolResults 是值切片,共享底层数组。 于是「去掉 scrubMemoryCandidate」的变异不会被端到端判据发现。

这是脆弱设计(隐式依赖别名)。所以:

  1. scrubMemoryCandidate 显式保留,不指望上游顺手改了;
  2. 增加判据 TestEmitMemoryCandidateScrubsPayload,走真实入口 a.emitMemoryCandidate 而不是直接调底层函数 —— 初版直接调底层, 于是「去掉入口处的调用」这个变异不报红(判据测错了层次)。

通用教训:判据必须走真实入口。测底层函数只能证明「函数对」, 证明不了「入口调了它」。本次变异实验连续暴露了三次同类错误 (M2 判据漏测 Alias()、C6-M2 漏测 memory_candidate、M-B 测错层次)。

与 f.Msgs 原地改写的刻意不对称

路径 语义 为何
f.Msgs 原地改写 它就是要发出去的那份;改掉后明文永久离开上下文
f.ToolResults 拷贝后改写 同一轮里还被其它代码读(诊断、状态面),不能就地改

这个不对称是刻意的,判据 TestOutboundScrubIsInPlaceByDesign 与 TestToolResultsScrubDoesNotMutateSource 分别钉住,免得后来者「统一」成一种写法。

验证

  • internal/agent/core/persist_leak_test.go 4 条(输入侧 / 回复侧 / 记忆块 / 记忆候选入口)
  • internal/agent/core/toolresult_scrub_test.go 3 条(含真实调度循环的 e2e)
  • internal/agent/core/inplace_scrub_test.go 1 条(原地改写语义)
  • 变异 4 条(含 2 次修好的判据盲区)
  • 全量回归零失败

5.4 ★ C7:插件加密层的删减(比原计划更保守) [已完成]

先验证前提,再动代码

C7 原计划是「插件不再自带加密库与别名表」。但「删掉插件那套,内核能顶上吗」 是个必须先验的断言 —— 不成立的话就是削功能而非去冗余。验了三条:

判据 结论
TestKernelFillCoversBuiltinToolsToo 内核回填覆盖所有工具名(含内置 cmd_run) ⇒ 插件的填充功能冗余
TestKernelFillHappensBeforePluginHandler 内核在 handler 之前回填 ⇒ 插件自己的别名表对内核别名是死代码
TestKernelAndPluginAliasSyntaxDoNotCollide 内核只吃 [A-Za-z0-9_],插件的 @{gitea-local}(含连字符)不受影响

删了什么(691 行)

删除 理由
intercept.go / intercept_stage.go / intercept_test.go(691 行) 插件版拦截覆盖面严格小于内核版:它只能看 RawMessage/ToolResults,内核还能覆盖 Input/Response/Blocks/memory_candidate
enc_intercepts 工具 + Ledger.Interceptions 拦截记录由内核拥有(内核日志 + secret_list 的命中计数)
intercept_enabled 配置项 全局开关是 core.security.enabled(在 WebUI 设置页)。插件留一个同名开关会让人以为它能控制内核行为 —— 那是误导

★ 两套并存的真实害处:内核先回填,插件再扫一遍时明文已是别名 ⇒ 纯空转, 而且可能把别名二次改写。

☆ 但有三样不能删(原计划漏判的)

保留 为什么内核顶不上
enc_shell 的 argv 保护(脚本走 stdin) 内核 cmd_run 是 sh -c "<整条命令>" ⇒ 回填后的明文会进 argv,本机任何进程 /proc/<pid>/cmdline 都能读到。实测确认
enc_shell 的 输出脱敏 防命令意外回显凭据(如 git remote -v 带出 token)
enc_store 的 ${ENV} / @file: 来源 这两条路径明文从不进会话 —— 正是文档 T10 要求的更安全形态,而内核 secret_submit 目前只接受直传明文

⇒ C7 的最终形态是「互补」而非「替代」: 插件保留执行侧保护(argv / 脱敏)与安全来源(${ENV}/@file:), 内核负责拦截 / 存储 / 回填 / 记忆侧路。

给内核的后续项(进 §6)

secret_submit 应支持 ${ENV_NAME} 与 @file:/path 两种来源 —— 把「明文不进会话」的能力从插件上移到内核(T10)。


5.5 ★ 变量库统一(插件不再自建加密存储) [已完成]

用户指出的问题(2026-10-14):

不对,你写了两遍变量,为什么不直接让安全模块暴露相关接口让插件使用?

确认属实。 插件自带了一整套与内核完全重复的加密存储:

内核 internal/security.Store 插件 unifiedSecretFile
落盘 secrets.json secrets.json
加密 AES-256-GCM AES-256-GCM
别名 HMAC 派生(自动合并) 用户指定
引用 @{sec_xxx} @{name}

★ 重复的真正代价不是代码量,而是两套库互不可见: 内核拦截时登记的变量插件看不到、插件登记的拦截又不认识, 同一凭据可能在两处各存一份而使用者不知哪个权威。

做法:SDK 新增 SecretsAPI

Store(value, note) → alias      值交给内核,返回别名
List()                          元信息(**不含明文**)
Forget(alias)                   删除
Fill(text) → out, n, aliases    把 @{别名} 换成明文
Encrypt/Decrypt                 给插件自己的配置文件用(密钥由内核管)
Available()                     变量库是否可用

去重后的分工:

谁 存什么
内核 值(加密)、派生别名、调用插件前回填
插件 「可读名 → 内核别名」映射(不含值)、来源解析、argv 保护

★ 为什么插件还需要一层映射:内核别名是 HMAC 派生的 (sec_0cb468e2…),这是「同明文自动合并」的前提(S11),不能改成用户自选。 但人需要 @{gitea-local} 这种能读懂的名字 ⇒ 插件存一层名字映射。 映射里只有名字,没有值 —— 它泄了也没用。

☆ 安全性论证:为什么暴露 Fill 不破 S1

S1 说「不提供取回明文的接口」,理由是一旦 LLM 能取回,它就能把明文写回上下文。 那插件为什么可以拿明文?因为边界不在插件侧:

  1. 内核在插件 handler 之前就把工具参数里的 @{别名} 填好了 (StageHost.ExecuteTool → security.FillMap)—— 插件本来就能 从 args 读到明文,这是设计使然。
  2. 明文若被插件返回,会被两处拦回别名(ContextEvent scrub + provider 装饰器)。

两条都有判据钉住(TestPremise_PluginReturnedPlaintext*:插件故意回吐明文, 验证 context 侧与 provider 侧都拦得住)。所以:

暴露 Fill 不是"开了一个口子",而是把本来就存在的能力 从各插件自己实现改为统一接口。

⚠️ 但绝不能暴露 Reveal(alias) -> 明文 这样的按名字直取接口 —— 那会让插件(以及操纵它的 LLM)枚举别名并逐个取值。本接口刻意没有这样的方法。

☆☆ 实测踩到的坑

Available() 不能用 List 推断。 初版让模板的 procSecrets.Available() 调 secrets.list,看 RPC 是否成功。但未启用时 List 返回空列表(不报错), 于是"库是空的"与"库不存在"在 RPC 层长得一样 ⇒ Available() 误判为 true, 接着 Store 才失败。错误信息于是变成"存储失败"而非"能力不可用", 排查方向完全错。修法:加 secrets.available 专门回答这个问题。 有判据 + 变异钉住。

go:embed 第三次咬人。 hmapdev 用 //go:embed 把模板编进二进制。 我改了 templates/proc_main.go.tmpl 源码,但跑的是旧二进制, 于是生成出的 shim 缺方法、报 missing method GetSecret —— 而报错指向 生成文件,看不出真因是"模板没重新编译进二进制"。 修法:go build ./tools/hmapdev/ 重建并安装。

两处校验器字符集不一致。 引用正则 credRefPattern 允许 ., 而别名校验器 validName 不允许 ⇒ 「能被引用却存不进去」。 修法:secret 别名用自己的校验器(validSecretName), 不混用项目/通道名的校验器 —— 两者是两个命名空间。

验证

internal/plugin/proc/e2e_secrets_test.go 3 条(真实模板编译的插件跑全链路); internal/agent/core/leakback_test.go 2 条(插件回吐明文被拦); internal/security/alias_shape_test.go 1 条(别名是派生值、不能自选); 插件 encstore_test.go 重写为 7 条(含「值只存一处」不变量)。 三层零失败、-race 通过、hmapdev build 成功。


5.6 ★ 拦截范围盘点(「到底拦什么」) [实测 · 已固化判据]

判据:internal/security/survey_test.go(会拦 / 刻意不拦,两个清单)。

会拦(→ 换成 @{sec_xxx})

类别 形态
GitHub token ghp_/gho_/ghu_/ghs_/ghr_ + 36 位
GitHub fine-grained github_pat_ + 60 位
GitLab glpat- + 20 位
OpenAI sk-(含 sk-proj-)+ 20 位
Anthropic sk-ant- + 20 位
AWS 访问密钥 AKIA/ASIA + 16 位大写
Slack xox[baprs]-
Bearer 头 Bearer + 30 位以上
私钥块(7 类) -----BEGIN PRIVATE KEY-----(PKCS#8)|RSA PRIVATE KEY|DSA/EC PRIVATE KEY|OPENSSH PRIVATE KEY(ssh-keygen 现代默认)|ENCRYPTED PRIVATE KEY|PGP PRIVATE KEY BLOCK|SSH2 ENCRYPTED PRIVATE KEY
赋值语句 键名含 api_key/access_token/auth_token/secret_key/client_secret/password/passwd,分隔符 = 或 : 都可,值 16 位以上

刻意不拦(每条都有理由 —— 边界比能力更重要)

不拦 原因
40 位 hex(Gitea token 形态) 与 git commit SHA 形态完全相同 ⇒ 拦它会误伤每个 commit id(S4)
裸 32 位十六进制 与 UUID / 会话 id / 哈希撞车,太泛
JWT 形态宽泛,误伤风险高于收益(带 Bearer 前缀时那条会拦)
密码短语 / 中文口令 无固定字符集,无法与正常文本区分
PIN / 短口令 太短,任何数字都会撞
手机号 / 身份证 PII 不是本模块目标,且误伤面极大
URL 里的密码(https://user:pass@host) ⚠️ 已知缺口,需专门规则,当前未覆盖
SSH 私钥文件路径(.ssh/id_rsa) 拦路径不拦内容 —— 文件内容被读出时会命中私钥块规则
SSH 公钥(ssh-ed25519 AAAA…、ssh-rsa AAAA…) 公钥不是秘密(可公开)。拦它会让正常运维操作坏掉
authorized_keys / ~/.ssh/config / known_hosts 不含密钥本体(config 只有路径;known_hosts 与正常 hostname 无区别)
SSH_AUTH_SOCK 只是 ssh-agent 的 socket 路径,不含密钥

☆ SSH / 密钥专项:实测补上两个漏报

用本机真实密钥(~/.ssh/id_rsa、id_ed25519)+ 各类 PEM 头验证时发现, 初版正则漏了 两类真实存在的私钥格式:

漏掉的 为什么
-----BEGIN PGP PRIVATE KEY BLOCK----- 尾部是 KEY BLOCK,不是 PRIVATE KEY
-----BEGIN SSH2 ENCRYPTED PRIVATE KEY----- 头部是 SSH2 ENCRYPTED PRIVATE KEY

根因:初版正则在 END 侧硬写了 PRIVATE KEY:

-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{64,}?-----END [A-Z ]*PRIVATE KEY-----
                                                            ^^^^^^^^^^^ 写死了

而真实密钥的尾部词因格式而异。修法:两侧都用 [A-Z0-9 ]*PRIVATE KEY[A-Z ]*。

长度下界 64 的用意(防误伤):真实私钥体远超 64 字节,而 「文档里只引用头尾两行做说明」的情形中间没有内容 ⇒ 不会被拦。 有判据双向钉住(长体必拦 / 短引用不拦)。

☆ 盘点本身抓到一个真实漏报

写这份盘点时发现 password: "hunter2hunter2hunter2"(冒号形态)没被拦。

根因是我加 prefilter 时引入的:assigned_secret 的正则分隔符有 两个备选(: 与 =),而 prefilter 只写了 "=" ⇒ 冒号形态被 静默挡住。

更值得记的是为什么既有判据没发现:样本恰好都用了 =, 测试全绿 —— 判据覆盖了"能用的形态",漏了"另一种同样合法的形态"。

修法:

  1. Prefilter 支持多候选("a|b",任一命中即放行)
  2. 新增 TestPrefilterCoversAllAlternatives:逐形态验证 「正则支持的每种写法,prefilter 都必须放行」

教训:盘点"什么没被拦"比盘点"什么被拦了"更能发现问题。 正向清单告诉你能力边界,负向清单才暴露洞。


6. 待办与未决(续)

上面 §5 的 T1–T10 是安全模块本体的遗留项; 本节记录配置加密(§5.2)落地后新暴露的项。

# 事项 说明
E-1 密钥轮换 ✅ 已完成(2026-10-15):security.keyring(0600)持久化「当前钥 + 历史只读钥」;Store 支持 RotateKey(已有别名不变、密文重加密落盘、旧钥留在环里);Codec 支持 InitConfigCodecWithOldKeys + Rekey/NeedsRekey;config 侧 RekeySecrets 收口。7 条判据 + 4 条变异
E-2 压缩/备份工具的兼容 ✅ 已完成(2026-10-15):deploy-plan.sh backup 打印密钥位置与「密钥须与数据分开存放」提示;内核启动时 AuditSecrets 审计密文可读性,发现不可解即告警并给出修复动作(3 条判据 + 2 条变异)
E-3 WebUI 的「密钥可见范围」 ✅ 已完成(2026-10-15):设置列表默认以占位符屏蔽敏感值;新增单键 /api/v1/settings/reveal?key=,核心/当前 WebUI 插件可显式揭示,其他插件一律 403;揭示动作留痕。2 条判据 + 2 条变异
E-4 HOMED_SECURITY_KEY 生产方式 ✅ 已完成(2026-10-15):优先用 systemd LoadCredential(tmpfs、0400、不进 environ),降级 env,再降级磁盘文件。unit 已配 Type=exec。4 条判据 + 2 条变异,真实 systemd 实测

7. 与既有文档的关系

文档 关系
commondev-agent-runtime-design.md 前稿的插件侧拦截(§2.7 之后)降级为卫生措施;安全责任移到本文
resident-subagent-design.md 驻留子的 KernelSource L4 模型是另一个安全机制(权限隔离),与本文正交
kernel-modularization-design.md ★ 本文 §3 的装配点约束是该文档 §2.3「Phase 0」的实例。安全模块必须在任何读 core.llm.* 的操作之前装配——实测踩过:装配晚了只会打一行 warning,然后 key 变空一路走到 401(症状指向"key 配错",实际是顺序问题)。该文档把这类约束从"注释"变成"机器校验"
本仓 internal/plugin/proc/corehandler.go:279 的 L4 夹取 同属"内核不信插件自报"的同一设计哲学,可参考其实现风格

8. 实测教训:安全模块的装配顺序(2026-10-16)

这一条加到本文,因为它直接暴露了"顺序只写在注释里"的危险。

故障:安全模块装配晚于 cfgReg.SetLLMSnapshotFile()/cfgReg.ToConfig(), 而这两者启动即读 core.llm.*(需主密钥解密)。

后果链:

密钥未装配 → 解密失败(只打一行 warning)→ key 变空串
→ initLLMProviders 拿到空 key → 所有 LLM 调用 401 invalid api key

为何难查:401 看起来像"key 配错了"。而实际上:

  • key 值在库里是对的
  • 直连上游 llmsproxy HTTP 200
  • 只有内核 agent 401

⇒ 查 key 值、改 key、重配 provider 全部无效,因为问题是启动顺序。

修法:把整个安全块前移到任何读 core.llm.* 的操作之前,并加结构性判据 (cmd/homed/bootstrap_order_test.go,断言真实的相对行号)。

★ 一个中途犯的错(值得记住):修复第一版只把安全块移到 ToConfig() 之前, 漏了 SetLLMSnapshotFile() 也在它之前读密文 ⇒ 判据变绿而线上照旧报错。 教训:顺序类判据必须覆盖所有读取点,不能只盯一个。

完整设计见 kernel-modularization-design.md §1.2 与 §2.3。


8. 给接手者的三条提醒

  1. 安全模块的位置决定它是不是安全。初稿以为"唯一出网点"在 task 层, 实测扫出 7 处构造请求 —— 放在 task 层会漏 4 处。 教训:落点要靠扫仓实测确定,不能靠读一处代码推断。
  2. "能引用"与"能读取"必须分开。前者是可用性所需,后者是泄露。 把它们混在一起,就是前稿最大的设计错误。
  3. 模式库宁漏勿误伤。误报会搞坏正常任务,而任务坏了人会来查——那时代价远大于漏报一次。