Files
HomeAgent/internal/kernelmod/security/policy_config_test.go
JianFeeeee b850feaef2 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 缺失。
2026-10-29 23:53:48 +08:00

236 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}