* 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>
50 KiB
内核安全模块(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(保留记录,因为它们说明"判据在起作用"):
*SensitiveInterceptornil 时 panic ——Intercept里si.enabled直接解引用。 已修(si == nil ||),这是真实健壮性问题:宿主可能在任何阶段调用。- 赋值形态丢失键名 ——
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] )
两个细节不能省:
-
必须用 HMAC(带 masterKey),不能裸 SHA256。 裸哈希对短/低熵输入可被字典枚举 —— 而别名会出现在上下文、日志、记忆里, 等于把“这个 secret 是什么”泄露出去。
实测演示(本机跑过):取明文
hunter2,裸 SHA256 取前 8 字节作别名, 然后拿 5 个常见弱口令去试:别名(裸哈希): f52fbd32b2b3b86f ★ 字典命中:'hunter2' → 别名直接暴露了 secret 内容换成 HMAC(masterKey) 后,攻击者没有 masterKey 就无法枚举。
-
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 |
☆ 踩到的两个真坑
-
Secret字段从未生效的根因:SDK 一直有Secret bool,但内核侧 三处转换点(RegisterDef/Defs/mapDefs)全都漏了它。 插件声明了意图,前端拿到的 meta 里却没有 —— 于是敏感项被渲染成 普通文本框明文回显。已配判据TestSecretFlagReachesSettingsMeta。 -
新增 proc method 要改 4 个地方:协议常量、外层路由、内层分发、 能力表。我只改了分发,真机 e2e 立刻报
未知 method: settings.setSecret—— 这正是「真机端到端不可省」的实例。 -
守卫判据本身有盲区:
TestCapability_AllMethodsClassified号称 「本文件最重要的测试」,但它用手工维护的方法清单 —— 新方法不加进清单 就无人管(变异实验 V3 证实:删掉能力表条目,判据仍绿)。 已改为从 protocol.go 源码正则提取全部 method 常量,覆盖面自动跟上。
验证
internal/config/secret_test.go17 条 +internal/security/codec判据internal/plugin/proc/e2e_secret_test.go—— 真实 hmapdev 模板编译的插件 跑完整链路:插件读回明文(27B)、磁盘存密文(83B)internal/plugins/webui/secret_meta_test.go2 条(声明→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」的变异不会被端到端判据发现。
这是脆弱设计(隐式依赖别名)。所以:
scrubMemoryCandidate显式保留,不指望上游顺手改了;- 增加判据
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.go4 条(输入侧 / 回复侧 / 记忆块 / 记忆候选入口)internal/agent/core/toolresult_scrub_test.go3 条(含真实调度循环的 e2e)internal/agent/core/inplace_scrub_test.go1 条(原地改写语义)- 变异 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 能取回,它就能把明文写回上下文。 那插件为什么可以拿明文?因为边界不在插件侧:
- 内核在插件 handler 之前就把工具参数里的
@{别名}填好了 (StageHost.ExecuteTool→security.FillMap)—— 插件本来就能 从 args 读到明文,这是设计使然。 - 明文若被插件返回,会被两处拦回别名(
ContextEventscrub + 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 只写了 "=" ⇒ 冒号形态被
静默挡住。
更值得记的是为什么既有判据没发现:样本恰好都用了 =,
测试全绿 —— 判据覆盖了"能用的形态",漏了"另一种同样合法的形态"。
修法:
Prefilter支持多候选("a|b",任一命中即放行)- 新增
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. 给接手者的三条提醒
- 安全模块的位置决定它是不是安全。初稿以为"唯一出网点"在 task 层, 实测扫出 7 处构造请求 —— 放在 task 层会漏 4 处。 教训:落点要靠扫仓实测确定,不能靠读一处代码推断。
- "能引用"与"能读取"必须分开。前者是可用性所需,后者是泄露。 把它们混在一起,就是前稿最大的设计错误。
- 模式库宁漏勿误伤。误报会搞坏正常任务,而任务坏了人会来查——那时代价远大于漏报一次。