Files
HomeAgent/internal/security/gate_test.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

147 lines
4.9 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
// 安全边界(Gate / Runtime)的判据。
//
// 这些判据守的是**用户裁定的架构不变量**(2026-10-26):
//
// > 安全模块就是横在其他所有模块与 core 之间的一堵墙:
// > core 发出的请求和外部返回的请求都会经过它审查,通过才能进行下一步。
//
// 所以每一条都在问同一个问题:**这条数据流有没有可能绕过审查**。
// 判据都必须能红(变异实现即失败),否则等于没写。
import (
"strings"
"testing"
)
// testStore 造一个已装好主密钥的变量库(不落盘到真实数据目录)。
func testStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
s, err := InitGlobal(Options{
Path: dir + "/vars.json",
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatalf("InitGlobal: %v", err)
}
// 全局单例是进程级的:测试之间要复位,否则互相污染。
t.Cleanup(func() { SetGlobal(nil) })
return s
}
// ① 出向:明文必须被换成 @{别名},不能原样发给外部。
func TestGateOutboundBlocksPlaintext(t *testing.T) {
s := testStore(t)
g := NewGate(s)
const secret = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"
out, n := g.PrepareOutbound(Inspection{Direction: DirectionOutbound, Stage: "tool_args", Tool: "cmd_run"},
"curl -H 'Authorization: Bearer "+secret+"' https://api.example.com")
if n == 0 {
t.Fatal("出向没有拦截明文 —— 凭据会直接发给外部(这堵墙没起作用)")
}
if strings.Contains(out, secret) {
t.Fatalf("出向结果仍含明文: %q", out)
}
if !strings.Contains(out, "@{sec_") {
t.Fatalf("出向结果应含变量别名,实际: %q", out)
}
}
// ② 入向:外部返回的明文同样必须被换掉。
//
// ★ 为什么入向是必须的:入向内容直接进模型上下文(role=tool)。
// 只在"写记忆"时清洗是最坏组合 —— 记忆侧干净、当轮已泄漏、事后查不到。
func TestGateInboundScrubsPlaintext(t *testing.T) {
s := testStore(t)
g := NewGate(s)
const secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
out, n := g.ScrubInbound(Inspection{Direction: DirectionInbound, Stage: "tool_result", Tool: "cmd_run"},
"server said: token="+secret)
if n == 0 {
t.Fatal("入向没有清洗明文 —— 明文会原样进入模型上下文")
}
if strings.Contains(out, secret) {
t.Fatalf("入向结果仍含明文: %q", out)
}
}
// ③ 执行前回填:外部必须拿到真实值("插件无感知"的既有语义)。
func TestGateFillsAliasBeforeExecution(t *testing.T) {
s := testStore(t)
g := NewGate(s)
const secret = "ghp_FILLME0123456789abcdefghijklmnopqrst"
alias, err := s.Submit(secret, "test")
if err != nil {
t.Fatal(err)
}
out, n := g.FillForExecution(Inspection{Direction: DirectionOutbound, Stage: "plugin_call"},
"token=@{"+alias+"}")
if n == 0 {
t.Fatal("回填没有发生 —— 插件会收到字面量 @{别名} 而不是真实值")
}
if !strings.Contains(out, secret) {
t.Fatalf("回填结果不含真实值: %q", out)
}
}
// ④ 未启用时零成本透传(且**不是**"装配失败")。
//
// 判据同时钉住两件事:Disabled 不是 nil(调用点只有一条路径),
// 且它真的不改写内容(不能假装审查过)。
func TestDisabledRuntimePassesThrough(t *testing.T) {
r := NewDisabledRuntime()
if r == nil {
t.Fatal("Disabled Runtime 必须是有效对象,不能让调用点各自判 nil")
}
if r.Enabled() {
t.Fatal("Disabled Runtime 不应报告 enabled")
}
const text = "ghp_SHOULDNOTBECHANGED0123456789abcdefgh"
if out, n := r.PrepareOutbound(Inspection{Stage: "x"}, text); n != 0 || out != text {
t.Errorf("disabled 出向应原样返回: n=%d out=%q", n, out)
}
if out, n := r.ScrubInbound(Inspection{Stage: "x"}, text); n != 0 || out != text {
t.Errorf("disabled 入向应原样返回: n=%d out=%q", n, out)
}
if out, n := r.FillForExecution(Inspection{Stage: "x"}, text); n != 0 || out != text {
t.Errorf("disabled 回填应原样返回: n=%d out=%q", n, out)
}
}
// ⑤ 启用后 Runtime 必须报告 enabled —— 状态面据此证明"审查真的在生效",
// 而不是靠读配置猜(配置开了但装配失败是另一件事)。
func TestEnabledRuntimeReportsEnabled(t *testing.T) {
s := testStore(t)
r := NewRuntime(s)
if !r.Enabled() {
t.Fatal("装好变量库的 Runtime 必须报告 enabled")
}
}
// ⑥ nil Runtime 不得 panic(装配期错误不该变成崩溃)。
func TestNilRuntimeIsSafe(t *testing.T) {
var r *Runtime
if out, n := r.PrepareOutbound(Inspection{}, "x"); n != 0 || out != "x" {
t.Errorf("nil runtime 出向应透传")
}
if out, n := r.ScrubInbound(Inspection{}, "x"); n != 0 || out != "x" {
t.Errorf("nil runtime 入向应透传")
}
if r.Enabled() {
t.Error("nil runtime 不应报告 enabled")
}
if r.Store() != nil {
t.Error("nil runtime 不应给出 Store")
}
}