feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 触发代审 + 审计有行有端到端断言
This commit is contained in:
AskaEth committed 2026-10-04 12:30:16 +08:00
1 parent ed5bd92e1b
commit 63236c9b00
20 files changed
+1709 -24

No files matched your search

@@ -0,0 +1,110 @@
package gateway
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定裁决 1.3/10.4.2 的管理面一侧:面板的「工具策略」保存
// (POST /api/v1/admin/plugins/policy)真的**先落库再改内存**——重启后策略仍在。
// 落盘载体是 tool_policies 表(迁移 0012_tool_policy),由 registry 的
// PolicyStore 钩子写入;这里用真实 SQLite 库走完整 HTTP 路径。
// policyEnv 接线一个「策略已接存储」的能力注册表的网关,返回服务端地址、
// JWT 服务与库连接(用例直接查 tool_policies 断言落盘)。
func policyEnv(t *testing.T) (string, *auth.JWTService, *sql.DB) {
t.Helper()
ctx := context.Background()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), log)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { conn.Close() })
if err := db.MigrateUp(ctx, conn, log); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
store, err := capreg.NewSQLitePolicyStore(conn, log)
if err != nil {
t.Fatalf("policy store: %v", err)
}
reg := capreg.New()
if err := reg.AttachPolicyStore(ctx, store); err != nil {
t.Fatalf("AttachPolicyStore: %v", err)
}
if err := reg.RegisterTool(stubTool{}); err != nil {
t.Fatalf("register stub tool: %v", err)
}
_, ts, jwt := buildEnv(t, nil, Deps{Registry: reg})
return ts.URL, jwt, conn
}
// TestAdminSetPolicyPersistsToToolPolicies 锁定「面板保存 → 真落库」:
// 走真实 HTTP 端点写入后,tool_policies 里有对应的行。
func TestAdminSetPolicyPersistsToToolPolicies(t *testing.T) {
t.Parallel()
ctx := context.Background()
url, jwt, conn := policyEnv(t)
owner := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
map[string]any{"name": "echo", "allowInGroup": true, "allowInStranger": false}, owner)
if resp.StatusCode != 200 {
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
}
var (
group bool
stranger bool
)
if err := conn.QueryRowContext(ctx,
`SELECT allow_in_group, allow_in_stranger FROM tool_policies WHERE name = 'echo'`).
Scan(&group, &stranger); err != nil {
t.Fatalf("tool_policies 里没有 echo 的策略行(未落盘?): %v", err)
}
if !group || stranger {
t.Fatalf("落盘策略 = {group:%v stranger:%v}, want {true false}", group, stranger)
}
}
// TestAdminSetPolicySurvivesRestart 跨「重启」验证:写完策略后另外构造一个
// 注册表重新 Load,标记仍在(这正是裁决 1.3 要求消灭的「重启即失」)。
func TestAdminSetPolicySurvivesRestart(t *testing.T) {
t.Parallel()
ctx := context.Background()
url, jwt, conn := policyEnv(t)
owner := signAny(t, jwt, "acct", auth.RoleOwner)
if resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
map[string]any{"name": "echo", "allowInGroup": false, "allowInStranger": true}, owner); resp.StatusCode != 200 {
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
}
// 模拟重启:新注册表 + 新 store,只有 Load 一条路能拿到刚才的标记。
log := slog.New(slog.NewTextHandler(io.Discard, nil))
store, err := capreg.NewSQLitePolicyStore(conn, log)
if err != nil {
t.Fatalf("policy store (2): %v", err)
}
rebooted := capreg.New()
if err := rebooted.AttachPolicyStore(ctx, store); err != nil {
t.Fatalf("AttachPolicyStore (2): %v", err)
}
if err := rebooted.RegisterTool(stubTool{}); err != nil {
t.Fatalf("RegisterTool (2): %v", err)
}
pol, err := rebooted.Policy("echo")
if err != nil {
t.Fatalf("Policy (2): %v", err)
}
if pol.AllowInGroup || !pol.AllowInStranger {
t.Fatalf("重启后策略 = %+v, want {group:false stranger:true}", pol)
}
}