- 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 触发代审 + 审计有行有端到端断言
135 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|