mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 16:27:28 +00:00
feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层: - MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID - coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失 - 召回先估算总 token,超预算时按全局页相似度标记 inactive - inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置 - Peek/确认/Remove 两阶段召回,失败保留冷上下文副本 - stablemem 记录 last_recalled_at,删块连带清理边 命名: - internal/memory/document → internal/memory/coldcontext - Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit - ContextToColdContext/RecentColdContexts/FindColdContexts - 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据 安全治理: - 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏 - 声明式 SecurityAPI 与 QQ owner/guest profile - 日志保留策略与相关 e2e/回归判据 验证:go build ./...、go vet ./...、go test ./...(62 包); 生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
@ -170,6 +170,17 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error
|
||||
|
||||
rt := sec.NewRuntime(store)
|
||||
sec.SetRuntimeGlobal(rt)
|
||||
|
||||
// ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。
|
||||
//
|
||||
// 它必须在这里装(而不是等模块 Init):`Prepare` 可能在 Group 0 被
|
||||
// 提前调一次,而工具可能在那之后任意时刻被执行 —— 策略若晚一步生效,
|
||||
// 就存在一个“工具已在跑、但黑名单还没上”的窗口。
|
||||
//
|
||||
// 注:配置里全空/全 false 时得到零值 Policy(不限制),
|
||||
// 与加配置前的行为逐字一致。
|
||||
ApplyPolicyFromConfig(rt, cfgReg)
|
||||
|
||||
log.Printf("[security] module enabled (key source %s, %d variables, config-at-rest encryption on)",
|
||||
keySrc, store.Count())
|
||||
return rt, nil
|
||||
|
||||
151
internal/kernelmod/security/policy_config.go
Normal file
151
internal/kernelmod/security/policy_config.go
Normal file
@ -0,0 +1,151 @@
|
||||
package security
|
||||
|
||||
// 把**部署方配置**(`core.security.*`)装配成内核全局策略基线。
|
||||
//
|
||||
// ## 为什么全局基线需要有配置入口(用户 2026-10-29 裁定:选项 B)
|
||||
//
|
||||
// 策略有两层,职责不同:
|
||||
//
|
||||
// ① 全局基线 security.Policy ← 本文件负责:部署方兜底("这台机器不许跑 cmd_*")
|
||||
// ② 档位 Profile / 作用域 Scope ← 插件声明:单次请求的收窄("这个 QQ 访客不行")
|
||||
//
|
||||
// ②是**声明式**且**只能收窄**(见 Runtime.CheckTool:全局黑名单先判,
|
||||
// 档位绕不过去)。但②有它的结构性盲区:**部署方无法阻止一个恶意/写坏的
|
||||
// 插件**——插件自己声明档位,等于被约束者决定约束。所以必须有一层
|
||||
// 不受插件影响的基线,且它要能在不重编译的前提下改 —— 那就是①。
|
||||
//
|
||||
// ## 为什么不放在 internal/security 读配置
|
||||
//
|
||||
// `internal/security` 不 import `internal/config`(否则配置包与安全包
|
||||
// 互相依赖,且 security 会被迫感知 SQLite)。装配放在本桥接层,
|
||||
// security 保持纯策略/算法包。
|
||||
//
|
||||
// ## 为什么是启动期一次快照(不热重载)
|
||||
//
|
||||
// `Policy` 在**每次工具调用**的热路径上被查;查 SQLite 不可接受。
|
||||
// 策略变更本就低频,且"安全策略在运行中被悄悄改变"本身是可疑行为。
|
||||
// 因此:启动期读一次,改配置需重启(与 core.agent.offload_enabled 同纪律)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
sec "github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// ConfigReader 是本桥接层需要的最小配置面(便于测试注入假实现)。
|
||||
type ConfigReader interface {
|
||||
GetString(key, defaultVal string) string
|
||||
GetBool(key string, defaultVal bool) bool
|
||||
}
|
||||
|
||||
// PolicyFromConfig 从配置读出一份全局策略快照。
|
||||
//
|
||||
// 缺省语义(重要):所有项留空/false 时返回**零值 Policy**,
|
||||
// 即"什么都不限制",与加配置之前的存量行为逐字一致。
|
||||
// 这条是升级安全的底线:老安装升级后行为不得改变。
|
||||
func PolicyFromConfig(cfg ConfigReader) sec.Policy {
|
||||
if cfg == nil {
|
||||
return sec.DefaultPolicy()
|
||||
}
|
||||
p := sec.Policy{
|
||||
ToolDeny: splitToolList(cfg.GetString("core.security.tool_deny", "")),
|
||||
ToolAllow: splitToolList(cfg.GetString("core.security.tool_allow", "")),
|
||||
Sandbox: sec.SandboxPolicy{
|
||||
Enabled: cfg.GetBool("core.security.sandbox.enabled", false),
|
||||
ReadRoots: splitPathList(cfg.GetString("core.security.sandbox.read_roots", "")),
|
||||
WriteRoots: splitPathList(cfg.GetString("core.security.sandbox.write_roots", "")),
|
||||
AllowNetwork: cfg.GetBool("core.security.sandbox.allow_network", true),
|
||||
},
|
||||
}
|
||||
// 自相矛盾的配置(同名工具同时在黑白名单)不静默择一:
|
||||
// CheckTool 的语义是 deny 优先,但那种配置通常意味着写错了,
|
||||
// 而"写错的安全策略悄悄偏向一边"比启动失败更危险。这里只响亮记录。
|
||||
if both := intersect(p.ToolDeny, p.ToolAllow); len(both) > 0 {
|
||||
log.Printf("[security] WARNING: tool(s) %v appear in BOTH tool_deny and tool_allow; "+
|
||||
"deny wins (denylist is evaluated first), but this configuration is likely a mistake", both)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ApplyPolicyFromConfig 读取配置并把策略装进运行时。
|
||||
//
|
||||
// 返回装好的策略,便于调用方记录/断言。rt 为 nil 时安全跳过
|
||||
// (未装配安全模块的独立工具/测试)。
|
||||
func ApplyPolicyFromConfig(rt *sec.Runtime, cfg ConfigReader) sec.Policy {
|
||||
p := PolicyFromConfig(cfg)
|
||||
if rt != nil {
|
||||
rt.SetPolicy(p)
|
||||
}
|
||||
if !p.Sandbox.Enabled && len(p.ToolDeny) == 0 && len(p.ToolAllow) == 0 {
|
||||
// 零值策略:不打印噪音日志(存量安装的常态)。
|
||||
return p
|
||||
}
|
||||
log.Printf("[security] global policy applied: deny=%v allow=%v sandbox=%v (read=%d roots, write=%d roots, network=%v)",
|
||||
p.ToolDeny, p.ToolAllow, p.Sandbox.Enabled,
|
||||
len(p.Sandbox.ReadRoots), len(p.Sandbox.WriteRoots), p.Sandbox.AllowNetwork)
|
||||
return p
|
||||
}
|
||||
|
||||
// splitToolList 解析逗号分隔的工具名单。
|
||||
//
|
||||
// 空串 ⇒ nil(而不是 [""])—— 后者在 CheckTool 里会被当成"有一个空模式",
|
||||
// 虽然 matchTool 对空模式返回 false 不会误伤,但 nil 语义更干净:
|
||||
// "没配置"与"配置了一个空项"应当可区分。
|
||||
func splitToolList(s string) []string {
|
||||
return splitNonEmpty(s)
|
||||
}
|
||||
|
||||
// splitPathList 解析逗号分隔的路径前缀。
|
||||
//
|
||||
// 去尾部分隔符:`/tmp/` 与 `/tmp` 应等价,否则 `filepath.HasPrefix` 式的
|
||||
// 前缀匹配会把 `/tmpfoo` 误判进 `/tmp`。规范化交给 CheckPath(它会 Clean)。
|
||||
func splitPathList(s string) []string {
|
||||
out := splitNonEmpty(s)
|
||||
for i, v := range out {
|
||||
out[i] = strings.TrimRight(v, "/")
|
||||
if out[i] == "" {
|
||||
// "/" 被 TrimRight 成空 —— 根目录是个合法的意图,还原回去。
|
||||
out[i] = "/"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if v := strings.TrimSpace(part); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intersect 返回两个名单的交集(用于诊断自相矛盾的配置)。
|
||||
func intersect(a, b []string) []string {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool, len(b))
|
||||
for _, v := range b {
|
||||
set[v] = true
|
||||
}
|
||||
var out []string
|
||||
for _, v := range a {
|
||||
if set[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// String 便于日志里一眼看出策略是否为空(诊断用)。
|
||||
func DescribePolicy(p sec.Policy) string {
|
||||
return fmt.Sprintf("deny=%d allow=%d sandbox=%v", len(p.ToolDeny), len(p.ToolAllow), p.Sandbox.Enabled)
|
||||
}
|
||||
235
internal/kernelmod/security/policy_config_test.go
Normal file
235
internal/kernelmod/security/policy_config_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package security
|
||||
|
||||
// 全局策略基线的**配置装配**判据。
|
||||
//
|
||||
// ## 为什么需要这个文件
|
||||
//
|
||||
// 2026-10-29 之前的实际状况是:`core.security.*` 只有 `enabled` 一个键被读,
|
||||
// `Policy.ToolDeny/ToolAllow/Sandbox` **没有任何配置入口**,`SetPolicy`
|
||||
// 零调用方。于是运维即使想「这台机器不许跑 cmd_*」也无从下手。
|
||||
//
|
||||
// 本文件的判据钉三件事:
|
||||
// ① 留空 = 零值策略(升级不得改变存量行为)
|
||||
// ② 逗号列表/通配符/路径规范化解析正确
|
||||
// ③ 装配后 `CheckTool` 真的按配置拒绝(而不是只存了字段)
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sec "github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// fakeCfg 是 ConfigReader 的测试替身。
|
||||
type fakeCfg map[string]string
|
||||
|
||||
func (f fakeCfg) GetString(key, def string) string {
|
||||
if v, ok := f[key]; ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (f fakeCfg) GetBool(key string, def bool) bool {
|
||||
v, ok := f[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
|
||||
// ① 全部留空 ⇒ 零值策略,不限制任何工具(**升级安全底线**)。
|
||||
func TestEmptyConfigYieldsZeroPolicy(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{})
|
||||
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 || p.Sandbox.Enabled {
|
||||
t.Fatalf("空配置必须得到零值策略,实际 %+v", p)
|
||||
}
|
||||
if d := p.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("零值策略必须放行全部工具,实际拒绝: %s", d.Reason)
|
||||
}
|
||||
// AllowNetwork 默认 true(不施加网络约束)—— 与加配置前一致。
|
||||
if !p.Sandbox.AllowNetwork {
|
||||
t.Fatal("空配置下 AllowNetwork 应为 true(不限制),否则升级会突然断网")
|
||||
}
|
||||
}
|
||||
|
||||
// ② 黑名单解析:逗号分隔 + 通配符 + 空白容忍。
|
||||
func TestToolDenyListParsing(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.tool_deny": " cmd_* , files_write , ",
|
||||
})
|
||||
if len(p.ToolDeny) != 2 {
|
||||
t.Fatalf("应解析出 2 项(空项被丢弃),实际 %v", p.ToolDeny)
|
||||
}
|
||||
if d := p.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("cmd_* 应命通配匹配拒绝 cmd_run")
|
||||
}
|
||||
if d := p.CheckTool("files_write"); d.Allowed {
|
||||
t.Fatal("精确项应拒绝 files_write")
|
||||
}
|
||||
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
|
||||
t.Fatalf("未命中项必须放行: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 白名单语义:非空即"只有这些"。
|
||||
//
|
||||
// ★ 通配符是**纯字面前缀**匹配:`memory_*` 不会匹配 `stablemem_recall`。
|
||||
// 工具按裁定 #11 改名后,运维配置里的白名单必须同步改,
|
||||
// 否则白名单会静默变成"什么都不允许"。
|
||||
func TestToolAllowListIsOnlyThese(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "stablemem_*,knowledge_*"})
|
||||
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
|
||||
t.Fatalf("白名单内应放行: %s", d.Reason)
|
||||
}
|
||||
if p.CheckTool("cmd_run").Allowed {
|
||||
t.Fatal("白名单外必须拒绝 —— 白名单是\"只有这些\"")
|
||||
}
|
||||
}
|
||||
|
||||
// ③b 旧名前缀不再匹配改名后的工具(升级风险的具体形状)。
|
||||
//
|
||||
// 存量部署的配置里写的是 `memory_*`,而工具已叫 `stablemem_*`:
|
||||
// 白名单会**静默**变成空集,而不是报错。本判据把这一行为钉住,
|
||||
// 使任何“通配符语义悄悄变宽”的改动都会变红。
|
||||
func TestLegacyMemoryWildcardDoesNotMatchRenamedTools(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "memory_*"})
|
||||
if p.CheckTool("stablemem_recall").Allowed {
|
||||
t.Fatal("`memory_*` 不应匹配 stablemem_recall —— 若匹配,说明通配符语义变了")
|
||||
}
|
||||
// 旧前缀仍应匹配旧名字(存量插件可能仍用旧名注册工具)。
|
||||
if d := p.CheckTool("memory_something_legacy"); !d.Allowed {
|
||||
t.Fatalf("`memory_*` 应仍匹配旧前缀工具: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ deny 优先于 allow(同名工具同时在两边)。
|
||||
func TestDenyWinsOverAllow(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.tool_deny": "cmd_run",
|
||||
"core.security.tool_allow": "cmd_*",
|
||||
})
|
||||
if d := p.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("deny 必须优先于 allow")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 路径列表:去尾部分隔符、/ 保留、空项丢弃。
|
||||
func TestPathListParsing(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.enabled": "true",
|
||||
"core.security.sandbox.write_roots": "/tmp/, /home/x ,,/",
|
||||
})
|
||||
got := p.Sandbox.WriteRoots
|
||||
want := []string{"/tmp", "/home/x", "/"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("期望 %v,实际 %v", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("期望 %v,实际 %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ ★ 装配进 Runtime 后 `CheckTool` 真的按配置生效(不是只存字段)。
|
||||
func TestApplyPolicyActuallyEnforced(t *testing.T) {
|
||||
rt := sec.NewRuntime(nil)
|
||||
if d := rt.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("装配前应放行: %s", d.Reason)
|
||||
}
|
||||
|
||||
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
|
||||
|
||||
if d := rt.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("★ 装配后 cmd_run 必须被拒 —— 否则配置只是存进了字段没落到判定上")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 沙箱:启用后按写根拒绝 `..` 逃逸到根外。
|
||||
func TestSandboxWriteRootsEnforced(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.enabled": "true",
|
||||
"core.security.sandbox.write_roots": "/tmp/work",
|
||||
})
|
||||
if d := p.CheckPath("write", "/tmp/work/a.txt"); !d.Allowed {
|
||||
t.Fatalf("写根内应放行: %s", d.Reason)
|
||||
}
|
||||
if d := p.CheckPath("write", "/tmp/work/../etc/passwd"); d.Allowed {
|
||||
t.Fatal("`..` 逃逸到写根外必须拒绝")
|
||||
}
|
||||
if d := p.CheckPath("write", "/etc/passwd"); d.Allowed {
|
||||
t.Fatal("写根外必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ 沙箱未启用 ⇒ 路径判定不施约束(默认行为不变)。
|
||||
func TestSandboxDisabledDoesNotConstrain(t *testing.T) {
|
||||
p := PolicyFromConfig(fakeCfg{
|
||||
"core.security.sandbox.write_roots": "/tmp/work",
|
||||
// enabled 缺省 false
|
||||
})
|
||||
if d := p.CheckPath("write", "/etc/passwd"); !d.Allowed {
|
||||
t.Fatal("沙箱未启用时不应约束路径 —— 否则升级即改变存量行为")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑨ ★ 语义锁定:`core.security.enabled=false` 关掉的**只是凭据替换**,
|
||||
// 工具策略必须继续生效。
|
||||
//
|
||||
// ## 为何这条必须单独钉
|
||||
//
|
||||
// `enabled` 这个名字太容易被理解成「关掉整个安全模块」。实际上:
|
||||
//
|
||||
// enabled=false:不加载主密钥、不做明文⇄别名替换(需要密钥才能做)
|
||||
// 但工具黑白名单/沙箱**与密钥无关**,仍然生效
|
||||
//
|
||||
// 若有人把 `SetPolicy` 挪进 `if rt.Enabled()` 分支,工具闸会跟着凭据替换
|
||||
// 一起静默失效 —— 而日志上看不出区别。本判据就是拦这个。
|
||||
// (与 eventpolicy 同一纪律:"关闭安全不等于开权限"。)
|
||||
func TestDisabledRuntimeStillEnforcesPolicy(t *testing.T) {
|
||||
// NewRuntime(nil) 构造的正是 Disabled Runtime(enabled=false)。
|
||||
rt := sec.NewRuntime(nil)
|
||||
if rt.Enabled() {
|
||||
t.Fatal("前置条件不成立:NewRuntime(nil) 应为 Disabled Runtime")
|
||||
}
|
||||
|
||||
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
|
||||
|
||||
if d := rt.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("★ enabled=false 时工具策略仍须生效 —— 关掉的是凭据替换,不是授权")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑩ ★ 兜底值焊死:装配层的 GetXxx 兜底必须与 registry 的 ConfigDef.Default 一致。
|
||||
//
|
||||
// ## 为什么这条是必需的(2026-10-29)
|
||||
//
|
||||
// 这些键用 `regMeta` 登记 —— **只登记、不播种**。而 `defDefault` 要求
|
||||
// `seeded[key]` 为真才回退 `ConfigDef.Default`,regMeta 从不写 seeded,
|
||||
// 所以 `GetXxx` 对它们**永远返回调用方传入的兜底值**,Default 字段
|
||||
// 只给 WebUI 展示。
|
||||
//
|
||||
// 于是同一个"默认值"存在两处:
|
||||
//
|
||||
// registry.ConfigDef.Default ← WebUI 上写的
|
||||
// PolicyFromConfig 的 GetXxx 兜底 ← 实际生效的
|
||||
//
|
||||
// 两处一旦漂移,WebUI 显示 `sandbox.enabled = false` 而实际按 true 运行
|
||||
// (或反之)—— 安全配置显示与实际不一致,是最不该出现的分歧。
|
||||
func TestPolicyConfigDefaultsMatchRegistry(t *testing.T) {
|
||||
// 用 fakeCfg(空 map)走一遍装配 = 全部键取兜底值。
|
||||
p := PolicyFromConfig(fakeCfg{})
|
||||
|
||||
if p.Sandbox.Enabled {
|
||||
t.Error("sandbox.enabled 的装配兜底应为 false,与 ConfigDef.Default 一致")
|
||||
}
|
||||
if !p.Sandbox.AllowNetwork {
|
||||
t.Error("sandbox.allow_network 的装配兜底应为 true,与 ConfigDef.Default 一致")
|
||||
}
|
||||
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 {
|
||||
t.Errorf("两个名单的装配兜底应为空,实际 deny=%v allow=%v", p.ToolDeny, p.ToolAllow)
|
||||
}
|
||||
if len(p.Sandbox.ReadRoots) != 0 || len(p.Sandbox.WriteRoots) != 0 {
|
||||
t.Errorf("读写根装配兜底应为空,实际 read=%v write=%v", p.Sandbox.ReadRoots, p.Sandbox.WriteRoots)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user