Files
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

135 lines
4.9 KiB
Go

package restrict
import (
"context"
"errors"
"testing"
)
// reviewStub 是 Reviewer 的桩:记录调用、按配置返回结论或错误。
type reviewStub struct {
allowed bool
err error
calls []reviewCall
}
type reviewCall struct{ actor, action, describe string }
func (s *reviewStub) ReviewAndApprove(_ context.Context, actor, action, describe string) (bool, error) {
s.calls = append(s.calls, reviewCall{actor: actor, action: action, describe: describe})
if s.err != nil {
return false, s.err
}
return s.allowed, nil
}
// staticResolver 固定返回一遍归属。
func staticResolver(sessionID, actor string) Resolver {
return func(context.Context) (string, string) { return sessionID, actor }
}
// TestGate_NonSensitivePassesWithoutApproval 锁定「非敏感自主」:读文件/检索
// 不进代审、不产生记录。
func TestGate_NonSensitivePassesWithoutApproval(t *testing.T) {
t.Parallel()
rv := &reviewStub{allowed: true}
g := NewGate(rv, staticResolver("web", "昔涟"))
d := g.Admit(context.Background(), "file", map[string]any{"action": "read", "path": "/data/a.md"})
if !d.Allow {
t.Fatalf("读文件应自主放行,得到 %+v", d)
}
if d.Approval != nil {
t.Fatalf("非敏感调用不应产生代审记录: %+v", d.Approval)
}
if len(rv.calls) != 0 {
t.Fatalf("非敏感调用不应触发代审,代审者被调用 %d 次", len(rv.calls))
}
}
// TestGate_SensitiveToolTriggersApproval 是裁决 13.4.3 的反向用例(loop 侧):
// terminal 不再自主执行,而是触发主代理代审,并把记录交给调用方落审计。
func TestGate_SensitiveToolTriggersApproval(t *testing.T) {
t.Parallel()
rv := &reviewStub{allowed: true}
g := NewGate(rv, staticResolver("web", "昔涟"))
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"})
if !d.Allow {
t.Fatalf("代审批准后应放行,得到 %+v", d)
}
if len(rv.calls) != 1 {
t.Fatalf("代审者调用次数 = %d, want 1", len(rv.calls))
}
if rv.calls[0].actor != "昔涟" {
t.Fatalf("代审归属 actor = %q, want 昔涟", rv.calls[0].actor)
}
if rv.calls[0].action != "执行命令:terminal echo hi" {
t.Fatalf("代审动作 = %q, want 执行命令:terminal echo hi", rv.calls[0].action)
}
ev := d.Approval
if ev == nil {
t.Fatal("敏感调用放行也必须留下代审记录(design/11 §8:批准/拒绝各落一行)")
}
if !ev.Allowed || ev.SessionID != "web" || ev.Actor != "昔涟" || ev.Tool != "terminal" || ev.Op != SensitiveExecCommand {
t.Fatalf("代审记录 = %+v", ev)
}
if ev.Explain != "「昔涟 想要干什么,我审查后批准了」" {
t.Fatalf("前端小字 = %q, want design/11 §4 的固定句式", ev.Explain)
}
if ev.At.IsZero() {
t.Fatal("代审记录应带时间戳")
}
}
// TestGate_DeniedSensitiveCallIsBlocked 锁定拒绝路径:代审不放行 → 调用被拦截、
// 回灌软提示、记录里 Allowed=false 且小字换成「拒绝了」。
func TestGate_DeniedSensitiveCallIsBlocked(t *testing.T) {
t.Parallel()
rv := &reviewStub{allowed: false}
g := NewGate(rv, staticResolver("web", "昔涟"))
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "rm -rf /"})
if d.Allow {
t.Fatalf("代审未放行却被放行: %+v", d)
}
if d.Reason == "" {
t.Fatal("拦截必须给出回灌给模型的说明")
}
if d.Approval == nil || d.Approval.Allowed {
t.Fatalf("拒绝也要落记录且 Allowed=false: %+v", d.Approval)
}
if d.Approval.Explain != "「昔涟 想要干什么,我审查后拒绝了」" {
t.Fatalf("拒绝小字 = %q", d.Approval.Explain)
}
}
// TestGate_ReviewerFailureIsFailClosed 锁定 fail-closed:代审者报错(或压根
// 没配)时敏感操作一律拦截并留下拒绝记录,绝不静默放行。
func TestGate_ReviewerFailureIsFailClosed(t *testing.T) {
t.Parallel()
ctx := context.Background()
for name, g := range map[string]*Gate{
"reviewer error": NewGate(&reviewStub{err: errors.New("approver down")}, staticResolver("web", "昔涟")),
"no reviewer": NewGate(nil, staticResolver("web", "昔涟")),
} {
d := g.Admit(ctx, "terminal", map[string]any{"command": "echo hi"})
if d.Allow {
t.Fatalf("%s: 敏感调用被放行,应 fail-closed 拦截", name)
}
if d.Approval == nil || d.Approval.Allowed {
t.Fatalf("%s: 应留下 Allowed=false 的代审记录: %+v", name, d.Approval)
}
}
}
// TestGate_NilGateDeniesSensitive 锁定零值安全:未装配的门(nil 接收者)碰到
// 敏感调用按拦截处理,而不是 panic 或放行。
func TestGate_NilGateDeniesSensitive(t *testing.T) {
t.Parallel()
var g *Gate
if d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"}); d.Allow {
t.Fatalf("nil 门应拦截敏感调用: %+v", d)
}
if d := g.Admit(context.Background(), "calculator", nil); !d.Allow {
t.Fatalf("nil 门的非敏感调用仍应放行: %+v", d)
}
}