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:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

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