feat(intercept): 增加审批记录详情与执行审计
This commit is contained in:
1 parent
8dae851b9b
commit
9ad323dc4c
22 files changed
+1687
-535
No files matched your search
@@ -46,6 +46,20 @@ AI 自主渗透测试系统(Go 后端 + Next.js 前端)
|
||||
|
||||
---
|
||||
|
||||
## 审批记录详情
|
||||
|
||||
全局「审批记录」和任务内「拦截审批」支持点击行或展开按钮查看详情。展示结构参考
|
||||
[AegisHook 的审批详情组件](https://github.com/RuoJi6/AegisHook/blob/main/web/src/components/CallDetail.vue),沿用 ARTEX 的组件和主题:
|
||||
|
||||
- 工具请求与审批裁决左右分栏,手机端上下排列;待审批记录可在详情中允许或拒绝。
|
||||
- 展开上下文可查看当前轮输入、会话记录片段、模型或规则初判、执行输出、调用 ID 与参数/审查配置的 SHA-256 指纹。
|
||||
- 审批状态和执行结果分别保存。模型直接允许也会记录;重复提交已处理审批返回 HTTP 409。
|
||||
- `GET /api/intercept/history/{id}` 按需读取详情,原列表接口不返回上下文和输出。数据库启动迁移自动补列;旧记录显示未记录,旧任务归档可正常恢复。
|
||||
|
||||
上下文是工具请求产生时保存的会话记录片段,最多 24 条、每条 8 KiB;当前轮输入最多 32 KiB,执行输出最多 64 KiB,截断会明确标注。
|
||||
当前裁判模型仍只接收工具名称和参数,展示的会话上下文不代表模型已使用这些内容作出判断。
|
||||
当前 Norma SDK 的审批钩子不包含调用 ID,因此仅在当前运行中能够唯一匹配工具请求事件时关联结果;相同参数的并发调用无法唯一匹配时,会显示未关联,避免错误归属。
|
||||
|
||||
## 资产同步(ScopeSentry)
|
||||
|
||||
支持从 [ScopeSentry](https://github.com/Autumn-27/ScopeSentry) 直接同步资产数据,免去重复收集:
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/harness"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
@@ -33,8 +34,13 @@ func captureRun(ctx context.Context, opts agentcore.Options, input string, emit
|
||||
// multiple prompts on the SAME conversation (e.g. a settlement round that reuses
|
||||
// the worker's accumulated context after the main run hit max_turns).
|
||||
func captureRunSession(ctx context.Context, s *agentcore.Session, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
|
||||
ctx, auditTrace := intercept.WithTrace(ctx, input, approvalHistory(s.Messages()))
|
||||
defer auditTrace.Finish()
|
||||
var reason harness.TerminalReason
|
||||
rec := func(r db.Activity) {
|
||||
if r.Kind == "text" || r.Kind == "tool_result" {
|
||||
auditTrace.Append(db.InterceptContextEntry{Kind: r.Kind, Tool: r.Tool, ToolUseID: r.ToolUseID, Text: r.Detail, IsError: r.IsError})
|
||||
}
|
||||
if emit != nil {
|
||||
emit(r)
|
||||
}
|
||||
@@ -90,6 +96,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
|
||||
in := string(ev.ToolUse.Input)
|
||||
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
|
||||
auditTrace.Start(ev.ToolUse.ID, ev.ToolUse.Name, ev.ToolUse.Input)
|
||||
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
|
||||
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
|
||||
case harness.KindToolResult:
|
||||
@@ -99,6 +106,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
flush()
|
||||
out := blocksText(ev.ToolResult.Content)
|
||||
lastTool.done(ev.ToolResult.ToolUseID)
|
||||
auditTrace.Complete(ev.ToolResult.ToolUseID, out, ev.ToolResult.IsError)
|
||||
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
|
||||
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
|
||||
case harness.KindText:
|
||||
@@ -184,3 +192,26 @@ func firstLine(s string, max int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Preserve the recorded session's visible messages, excluding thinking blocks.
|
||||
// This is audit context; it is not presented as input to the existing tool-only judge.
|
||||
func approvalHistory(messages []llm.Message) []db.InterceptContextEntry {
|
||||
var entries []db.InterceptContextEntry
|
||||
for _, message := range messages {
|
||||
for _, block := range message.Content {
|
||||
entry := db.InterceptContextEntry{Kind: string(message.Role)}
|
||||
switch block.Type {
|
||||
case llm.BlockText:
|
||||
entry.Text = block.Text
|
||||
case llm.BlockToolUse:
|
||||
entry.Kind, entry.Tool, entry.ToolUseID, entry.Text = "tool_use", block.Name, block.ID, string(block.Input)
|
||||
case llm.BlockToolResult:
|
||||
entry.Kind, entry.ToolUseID, entry.Text, entry.IsError = "tool_result", block.ToolUseID, blocksText(block.Content), block.IsError
|
||||
default:
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
"github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Exercise the actual SDK event -> hook -> execution -> result path. No real
|
||||
// model or command is used; the probe tool only returns a fixed string.
|
||||
func TestCaptureApprovalLifecycle(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
ic := intercept.New(d)
|
||||
priorTools, err := ic.GetEnabledTools()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorConfig := ic.GetJudgeConfig()
|
||||
t.Cleanup(func() { _ = ic.SetEnabledTools(priorTools); _ = ic.SetJudgeConfig(priorConfig) })
|
||||
if err := ic.SetEnabledTools([]string{"ApprovalAuditProbe"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ic.SetJudgeConfig(intercept.JudgeConfig{Enabled: true, AskTimeoutSeconds: 1, AskTimeoutAction: "allow"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, action, status, execution string
|
||||
manual, approve, toolError bool
|
||||
}{
|
||||
{"model_fallback", "invalid", "allowed", "succeeded", false, false, false},
|
||||
{"model_allow", "allow", "allowed", "succeeded", false, false, false},
|
||||
{"model_deny", "deny", "denied", "not_executed", false, false, false},
|
||||
{"human_allow_tool_error", "ask", "allowed", "failed", true, true, true},
|
||||
{"human_deny", "ask", "denied", "not_executed", true, false, false},
|
||||
{"timeout_allow", "ask", "timeout", "succeeded", false, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ic.SetReviewer(func(context.Context, int64, string, string, string) (intercept.Decision, error) {
|
||||
return intercept.Decision{Action: tc.action, Message: "probe review", ProfileID: 7}, nil
|
||||
})
|
||||
g := guard.NewWithInterceptor(ic)
|
||||
taskID := "approval-lifecycle-" + tc.name
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID) })
|
||||
ctx := intercept.WithTaskContext(t.Context(), taskID, "test-agent", func(a db.Activity) {
|
||||
if a.Kind != "intercept_request" || !tc.manual {
|
||||
return
|
||||
}
|
||||
var detail struct {
|
||||
ID int64 `json:"pending_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(a.Detail), &detail); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := ic.Decide(detail.ID, tc.approve); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
turn, executions := 0, 0
|
||||
provider := captureUsageProvider{stream: func(_ context.Context, yield func(llm.StreamEvent, error) bool) {
|
||||
turn++
|
||||
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
|
||||
if turn == 1 {
|
||||
events = []llm.StreamEvent{
|
||||
{Type: llm.SEToolUseStart, ToolID: "probe-call", ToolName: "ApprovalAuditProbe"},
|
||||
{Type: llm.SEToolInputJSON, Text: `{}`}, {Type: llm.SEMessageDelta, StopReason: "tool_use"},
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if !yield(event, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
probe := tool.Build(tool.Spec{Name: "ApprovalAuditProbe", Schema: map[string]any{"type": "object"},
|
||||
Run: func(context.Context, json.RawMessage, *tool.ToolContext) (tool.Result, error) {
|
||||
executions++
|
||||
if tc.toolError {
|
||||
return tool.Errorf("probe failed"), nil
|
||||
}
|
||||
return tool.Text("probe succeeded"), nil
|
||||
},
|
||||
})
|
||||
_, _, err := captureRun(ctx, agentcore.Options{Provider: provider, Tools: []tool.CoreTool{probe}, Hooks: g.Hooks(),
|
||||
PermissionMode: permission.ModeBypass, WorkingDir: t.TempDir(), MaxTurns: 2}, "record this review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := d.ListTaskIntercepts(taskID)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
detail, err := d.GetInterceptDetail(rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := detail.Audit
|
||||
initialAction := tc.action
|
||||
if tc.action == "invalid" {
|
||||
initialAction = "allow"
|
||||
}
|
||||
if detail.Status != tc.status || detail.DecisionSource != "model" || a == nil || a.ExecutionStatus != tc.execution || a.ToolUseID != "probe-call" || a.UserMessage != "record this review" || a.InitialAction != initialAction || a.ModelFallback != (tc.action == "invalid") || a.ProfileID != 7 {
|
||||
t.Fatalf("unexpected review: row=%+v audit=%+v", detail.InterceptApprovalRow, a)
|
||||
}
|
||||
wantCalls := 1
|
||||
if tc.execution == "not_executed" {
|
||||
wantCalls = 0
|
||||
}
|
||||
if executions != wantCalls {
|
||||
t.Fatalf("tool ran %d times, wanted %d", executions, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+42
-15
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,7 @@ type InterceptPending struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Status string `json:"status"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
Reason string `json:"reason"` // 规则 message 或模型判定理由(前缀 [模型])
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -106,7 +108,7 @@ func (d *DB) ToggleInterceptRule(id int64, enabled bool) error {
|
||||
// CreateInterceptPending inserts a pending approval record and returns its ID.
|
||||
// convID == 0 → conversation_id stored as NULL (background task).
|
||||
// taskID == "" → task_id stored as NULL.
|
||||
func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, toolName string, input []byte, reason string) (int64, error) {
|
||||
func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, toolName string, input []byte, reason string, audits ...*InterceptAudit) (int64, error) {
|
||||
raw := json.RawMessage(input)
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
@@ -126,9 +128,9 @@ func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, too
|
||||
}
|
||||
var id int64
|
||||
err := d.QueryRow(`
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, reason).Scan(&id)
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason, decision_source, audit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -142,7 +144,7 @@ func (d *DB) DecideInterceptPending(id int64, status string) error {
|
||||
// (status = 'allowed' | 'denied'), decided_at stamped now. Used to log allow/deny
|
||||
// rule matches for observability — they don't block and need no user action, so unlike
|
||||
// CreateInterceptPending (which starts 'pending') this records the outcome directly.
|
||||
func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, toolName string, input []byte, status, reason string) (int64, error) {
|
||||
func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, toolName string, input []byte, status, reason string, audits ...*InterceptAudit) (int64, error) {
|
||||
raw := json.RawMessage(input)
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
@@ -162,17 +164,17 @@ func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, too
|
||||
}
|
||||
var id int64
|
||||
err := d.QueryRow(`
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason).Scan(&id)
|
||||
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`,
|
||||
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at`
|
||||
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source`
|
||||
|
||||
func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error {
|
||||
return s.Scan(&p.ID, &p.RuleID, &p.ConversationID, &p.TaskID, &p.AgentName,
|
||||
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt)
|
||||
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource)
|
||||
}
|
||||
|
||||
// ListPendingIntercepts returns all unresolved approval requests, newest first.
|
||||
@@ -218,20 +220,45 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
||||
return rows.Scan(
|
||||
&r.ID, &r.RuleID, &r.ConversationID, &r.TaskID, &r.AgentName,
|
||||
&r.ToolName, &r.ToolInput, &r.Status, &r.Reason, &r.DecidedAt, &r.CreatedAt,
|
||||
&r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
|
||||
&r.DecisionSource, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
|
||||
)
|
||||
}
|
||||
|
||||
const approvalRowSelect = `
|
||||
SELECT ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at,
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ip.decision_source,
|
||||
COALESCE(c.title,'') AS conv_title,
|
||||
COALESCE(c.agent_key,'') AS conv_agent_key,
|
||||
COALESCE(ir.name,'') AS rule_name
|
||||
COALESCE(ir.name,'') AS rule_name`
|
||||
|
||||
const approvalRowJoins = `
|
||||
FROM intercept_pending ip
|
||||
LEFT JOIN conversations c ON c.id = ip.conversation_id
|
||||
LEFT JOIN intercept_rules ir ON ir.id = ip.rule_id`
|
||||
|
||||
const approvalRowSelect = `SELECT ` + approvalRowColumns + approvalRowJoins
|
||||
const approvalRowSelectWithAudit = `SELECT ` + approvalRowColumns + `, ip.audit` + approvalRowJoins
|
||||
|
||||
func interceptSource(ruleID int64, reason string) string {
|
||||
if ruleID != 0 {
|
||||
return "rule"
|
||||
}
|
||||
if strings.HasPrefix(reason, "[模型]") {
|
||||
return "model"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func firstAudit(audits []*InterceptAudit) any {
|
||||
if len(audits) == 0 || audits[0] == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(audits[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ListAllIntercepts returns up to limit intercept_pending rows (newest first)
|
||||
// joined with conversation and rule info.
|
||||
func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InterceptContextEntry is a bounded, recorded session event, not model reasoning.
|
||||
type InterceptContextEntry struct {
|
||||
Kind string `json:"kind"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// InterceptAudit is captured at review time. It is deliberately excluded from
|
||||
// polling/list responses; old rows have no audit instead of reconstructed data.
|
||||
type InterceptAudit struct {
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Correlation string `json:"correlation"` // exact | ambiguous | unavailable
|
||||
InputDigest string `json:"input_digest"`
|
||||
UserMessage string `json:"user_message"`
|
||||
UserTruncated bool `json:"user_truncated,omitempty"`
|
||||
Context []InterceptContextEntry `json:"context"`
|
||||
ContextTruncated bool `json:"context_truncated,omitempty"`
|
||||
CapturedAt time.Time `json:"captured_at"`
|
||||
ModelFallback bool `json:"model_fallback,omitempty"`
|
||||
InitialAction string `json:"initial_action"`
|
||||
InitialReason string `json:"initial_reason"`
|
||||
EffectiveAction string `json:"effective_action,omitempty"`
|
||||
DecisionReason string `json:"decision_reason,omitempty"`
|
||||
RuleName string `json:"rule_name,omitempty"`
|
||||
ConfigDigest string `json:"config_digest,omitempty"`
|
||||
ProfileID int64 `json:"profile_id,omitempty"`
|
||||
ExecutionStatus string `json:"execution_status"`
|
||||
Output string `json:"output,omitempty"`
|
||||
OutputTruncated bool `json:"output_truncated,omitempty"`
|
||||
ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"`
|
||||
}
|
||||
|
||||
type InterceptDetail struct {
|
||||
InterceptApprovalRow
|
||||
Audit *InterceptAudit `json:"audit"`
|
||||
}
|
||||
|
||||
func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) {
|
||||
var out InterceptDetail
|
||||
var raw []byte
|
||||
err := d.QueryRow(approvalRowSelectWithAudit+` WHERE ip.id=$1`, id).Scan(
|
||||
&out.ID, &out.RuleID, &out.ConversationID, &out.TaskID, &out.AgentName,
|
||||
&out.ToolName, &out.ToolInput, &out.Status, &out.Reason, &out.DecidedAt, &out.CreatedAt,
|
||||
&out.DecisionSource, &out.ConvTitle, &out.ConvAgentKey, &out.RuleName, &raw,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out.Audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ResolveIntercept atomically settles a pending request. A timeout cannot
|
||||
// overwrite a human decision and repeat decisions cannot rewrite history.
|
||||
func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, error) {
|
||||
execution := "not_executed"
|
||||
if action == "allow" {
|
||||
execution = "awaiting_result"
|
||||
}
|
||||
patch, err := json.Marshal(map[string]any{
|
||||
"effective_action": action, "decision_reason": reason, "execution_status": execution,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(),
|
||||
audit=CASE WHEN audit IS NULL THEN NULL ELSE audit || $3::jsonb ||
|
||||
CASE WHEN $4='allow' AND audit->>'correlation' IS DISTINCT FROM 'exact'
|
||||
THEN '{"execution_status":"unknown"}'::jsonb ELSE '{}'::jsonb END END
|
||||
WHERE id=$1 AND status='pending'`, id, status, patch, action)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := r.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
// CompleteIntercept only updates the exact recorded call after it was allowed.
|
||||
// A blocked tool_result must never be presented as a failed execution.
|
||||
func (d *DB) CompleteIntercept(id int64, runID, toolUseID, status, output string, truncated bool) error {
|
||||
patch, err := json.Marshal(map[string]any{
|
||||
"execution_status": status, "output": output, "output_truncated": truncated,
|
||||
"execution_ended_at": time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.Exec(`UPDATE intercept_pending SET audit=audit || $4::jsonb
|
||||
WHERE id=$1 AND audit->>'run_id'=$2 AND audit->>'tool_use_id'=$3
|
||||
AND audit->>'effective_action'='allow' AND audit->>'execution_status'='awaiting_result'`,
|
||||
id, runID, toolUseID, patch)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInterceptDetails(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
create := func(t *testing.T, audit *InterceptAudit) int64 {
|
||||
t.Helper()
|
||||
id, err := d.CreateInterceptPending(0, 0, "approval-detail-test", "test", "Write", []byte(`{"path":"report.md"}`), "[模型] 请确认", audit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id) })
|
||||
return id
|
||||
}
|
||||
t.Run("legacy and lazy payload", func(t *testing.T) {
|
||||
id := create(t, nil)
|
||||
got, err := d.GetInterceptDetail(id)
|
||||
if err != nil || got == nil || got.Audit != nil {
|
||||
t.Fatalf("legacy: %+v %v", got, err)
|
||||
}
|
||||
create(t, &InterceptAudit{UserMessage: "snapshot-only-marker", InitialAction: "ask"})
|
||||
items, err := d.ListTaskIntercepts("approval-detail-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(items)
|
||||
if strings.Contains(string(raw), "snapshot-only-marker") || strings.Contains(string(raw), `"audit"`) {
|
||||
t.Fatal("snapshot leaked into list response")
|
||||
}
|
||||
missing, err := d.GetInterceptDetail(-1)
|
||||
if err != nil || missing != nil {
|
||||
t.Fatal("missing record not reported")
|
||||
}
|
||||
})
|
||||
t.Run("decision race and exact output", func(t *testing.T) {
|
||||
id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", Correlation: "exact", InitialAction: "ask", ExecutionStatus: "not_started"})
|
||||
var wins atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
ok, err := d.ResolveIntercept(id, "allowed", "allow", "人工允许执行")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if ok {
|
||||
wins.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("%d decisions won", wins.Load())
|
||||
}
|
||||
ok, err := d.ResolveIntercept(id, "timeout", "deny", "late timeout")
|
||||
if err != nil || ok {
|
||||
t.Fatal("timeout overwrote decision")
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "different-run", "call", "succeeded", "WRONG", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := d.GetInterceptDetail(id)
|
||||
if got.Audit.Output != "" {
|
||||
t.Fatal("cross-run result attached")
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run", "call", "failed", "permission denied", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = d.GetInterceptDetail(id)
|
||||
if err != nil || got.Status != "allowed" || got.Audit.InitialAction != "ask" || got.Audit.ExecutionStatus != "failed" || !got.Audit.OutputTruncated {
|
||||
t.Fatalf("wrong details: %+v %v", got, err)
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run", "call", "succeeded", "late duplicate", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = d.GetInterceptDetail(id)
|
||||
if got.Audit.Output != "permission denied" {
|
||||
t.Fatal("duplicate result rewrote output")
|
||||
}
|
||||
})
|
||||
t.Run("denied output and timeout allow", func(t *testing.T) {
|
||||
id := create(t, &InterceptAudit{RunID: "run", ToolUseID: "call", ExecutionStatus: "not_started"})
|
||||
if _, err := d.ResolveIntercept(id, "denied", "deny", "人工拒绝"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run", "call", "failed", "Blocked by hook", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := d.GetInterceptDetail(id)
|
||||
if got.Audit.ExecutionStatus != "not_executed" || got.Audit.Output != "" {
|
||||
t.Fatal("denial presented as executed")
|
||||
}
|
||||
id = create(t, &InterceptAudit{RunID: "run2", ToolUseID: "call2", Correlation: "exact", InitialAction: "ask"})
|
||||
if _, err := d.ResolveIntercept(id, "timeout", "allow", "超时允许"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CompleteIntercept(id, "run2", "call2", "succeeded", "ok", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = d.GetInterceptDetail(id)
|
||||
if got.Status != "timeout" || got.Audit.EffectiveAction != "allow" || got.Audit.ExecutionStatus != "succeeded" {
|
||||
t.Fatal("timeout action lost")
|
||||
}
|
||||
})
|
||||
t.Run("archive compatibility", func(t *testing.T) {
|
||||
for _, legacy := range []bool{true, false} {
|
||||
id := create(t, &InterceptAudit{InitialAction: "ask", ExecutionStatus: "not_started"})
|
||||
var raw []byte
|
||||
if err := d.QueryRow(`SELECT row_to_json(ip) FROM intercept_pending ip WHERE id=$1`, id).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy {
|
||||
delete(row, "audit")
|
||||
delete(row, "decision_source")
|
||||
}
|
||||
archived, _ := json.Marshal([]map[string]any{row})
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreInterceptRows(tx, archived); err != nil {
|
||||
t.Fatalf("legacy=%t: %v", legacy, err)
|
||||
}
|
||||
var status, source string
|
||||
var auditJSON []byte
|
||||
if err := tx.QueryRow(`SELECT status, decision_source, audit FROM intercept_pending WHERE id=$1`, id).Scan(&status, &source, &auditJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "timeout" || source != "model" {
|
||||
t.Fatalf("restored %s/%s", status, source)
|
||||
}
|
||||
if legacy && len(auditJSON) > 0 {
|
||||
t.Fatal("fabricated legacy audit")
|
||||
}
|
||||
if !legacy {
|
||||
var a InterceptAudit
|
||||
if err := json.Unmarshal(auditJSON, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.InitialAction != "ask" || a.EffectiveAction != "deny" || a.ExecutionStatus != "not_executed" {
|
||||
t.Fatalf("restored audit: %+v", a)
|
||||
}
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1024,6 +1024,11 @@ CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(sta
|
||||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC);
|
||||
-- 补旧库:reason 列(已发版,加列要带 IF NOT EXISTS)。
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
|
||||
-- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history.
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
|
||||
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
|
||||
|
||||
-- =====================================================================
|
||||
-- L. 漏洞发现持久化
|
||||
|
||||
@@ -634,10 +634,28 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
// Archives predating approval snapshots lack this NOT NULL column.
|
||||
if source, _ := row["decision_source"].(string); source == "" {
|
||||
source = "unknown"
|
||||
if row["rule_id"] != nil {
|
||||
source = "rule"
|
||||
} else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[模型]") {
|
||||
source = "model"
|
||||
}
|
||||
row["decision_source"] = source
|
||||
}
|
||||
if status, _ := row["status"].(string); status == "pending" {
|
||||
row["status"] = "timeout"
|
||||
row["reason"] = "任务归档期间审批已超时"
|
||||
row["decided_at"] = time.Now().UTC()
|
||||
if audit, ok := row["audit"].(map[string]any); ok {
|
||||
audit["effective_action"] = "deny"
|
||||
audit["decision_reason"] = "任务归档期间审批已超时"
|
||||
audit["execution_status"] = "not_executed"
|
||||
}
|
||||
} else if audit, ok := row["audit"].(map[string]any); ok && audit["execution_status"] == "awaiting_result" {
|
||||
// An archived run cannot resume its former result callback.
|
||||
audit["execution_status"] = "unknown"
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
|
||||
+3
-5
@@ -88,6 +88,7 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event, cmd string) h
|
||||
if !g.interceptor.IsToolEnabled(ev.ToolName) {
|
||||
return hook.Result{}
|
||||
}
|
||||
ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
|
||||
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
|
||||
if !matched {
|
||||
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
|
||||
@@ -104,11 +105,8 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event, cmd string) h
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
|
||||
return g.block(ev.ToolName, dec.Message, "")
|
||||
case "allow":
|
||||
// 观测:显式 allow 规则命中记一条 allowed(无规则命中的放行不记,避免全量刷屏)。
|
||||
// 模型兜底判 allow(RuleID==0)同样不落库,与「未命中直接放行」一致。
|
||||
if dec.RuleID != 0 {
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
|
||||
}
|
||||
// Record explicit rule and model approvals so review details remain auditable.
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
|
||||
return hook.Result{}
|
||||
case "ask":
|
||||
// If the worker context is already cancelled (task stopped / killed), block
|
||||
|
||||
+61
-13
@@ -14,6 +14,7 @@ package intercept
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -282,6 +283,10 @@ func (i *Interceptor) SetEnabledTools(tools []string) error {
|
||||
|
||||
// Decision is the outcome of a successful rule match.
|
||||
type Decision struct {
|
||||
ModelFallback bool
|
||||
RuleName string
|
||||
ConfigDigest string
|
||||
ProfileID int64
|
||||
Action string // "allow" | "deny" | "ask"
|
||||
Message string
|
||||
RuleID int64
|
||||
@@ -437,16 +442,22 @@ func (i *Interceptor) Judge(ctx context.Context, tool, command string) (Decision
|
||||
|
||||
out, err := rv(cctx, cfg.ProfileID, cfg.Prompt, tool, command)
|
||||
if err != nil {
|
||||
out = Decision{Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
|
||||
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
|
||||
}
|
||||
switch out.Action {
|
||||
case "allow", "ask", "deny":
|
||||
// valid verdict
|
||||
default:
|
||||
out = Decision{Action: cfg.FailAction, Message: "模型输出无法解析,按失败策略处理"}
|
||||
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型输出无法解析,按失败策略处理"}
|
||||
}
|
||||
// A model verdict never carries a rule; keep RuleID 0 (→ NULL) for history.
|
||||
out.RuleID = 0
|
||||
if out.ProfileID != 0 {
|
||||
cfg.ProfileID = out.ProfileID
|
||||
}
|
||||
out.ProfileID = cfg.ProfileID
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
out.ConfigDigest = digestInput(configJSON)
|
||||
if out.Message == "" {
|
||||
out.Message = "[模型] " + judgeActionLabel(out.Action)
|
||||
} else if !strings.HasPrefix(out.Message, "[模型]") {
|
||||
@@ -487,7 +498,9 @@ func (i *Interceptor) Match(toolName string, input []byte) (Decision, bool) {
|
||||
if msg == "" {
|
||||
msg = defaultMessage(r.Action, r.Name)
|
||||
}
|
||||
configJSON, _ := json.Marshal(r.InterceptRule)
|
||||
return Decision{
|
||||
RuleName: r.Name, ConfigDigest: digestInput(configJSON),
|
||||
Action: r.Action,
|
||||
Message: msg,
|
||||
RuleID: r.ID,
|
||||
@@ -527,15 +540,19 @@ func defaultMessage(action, name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Log records an allow/deny rule match into intercept_pending as an ALREADY-decided
|
||||
// Log records an allow/deny rule or model decision into intercept_pending as an ALREADY-decided
|
||||
// row (status = "allowed" | "denied"), for observability. Unlike HandleAsk it does NOT
|
||||
// block and needs no user action — it just makes every rule hit visible on the history
|
||||
// block and needs no user action — it makes explicit review decisions visible on the history
|
||||
// page (GET /api/intercept/history) and the task's intercept list. Best-effort: a DB
|
||||
// error is swallowed so logging never changes the tool call's outcome. The pending list
|
||||
// (status='pending') is unaffected, so it still shows only asks awaiting a decision.
|
||||
func (i *Interceptor) Log(ctx context.Context, convID int64, dec Decision, toolName string, input []byte, status string) {
|
||||
taskID, agentName := taskInfoFromCtx(ctx)
|
||||
_, _ = i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message)
|
||||
audit := auditFor(ctx, dec, input, status)
|
||||
id, err := i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message, audit)
|
||||
if err == nil {
|
||||
i.bindResult(ctx, id, audit)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAsk creates a pending approval record and blocks until the user decides
|
||||
@@ -551,11 +568,22 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
taskID, agentName := taskInfoFromCtx(ctx)
|
||||
taskEmit := taskEmitFromCtx(ctx)
|
||||
|
||||
pendingID, err := i.db.CreateInterceptPending(ruleID, convID, taskID, agentName, toolName, input, dec.Message)
|
||||
audit := auditFor(ctx, dec, input, "pending")
|
||||
pendingID, err := i.db.CreateInterceptPending(ruleID, convID, taskID, agentName, toolName, input, dec.Message, audit)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
i.bindResult(ctx, pendingID, audit)
|
||||
ch := i.pending.add(pendingID)
|
||||
defer i.pending.remove(pendingID)
|
||||
// Polling clients can decide after INSERT commits but before the channel is
|
||||
// registered. Re-read after registration so that decision cannot be lost;
|
||||
// later decisions will be delivered through ch.
|
||||
if saved, err := i.db.GetInterceptDetail(pendingID); err == nil && saved != nil && saved.Status != "pending" {
|
||||
return saved.Status == "allowed" || (saved.Audit != nil && saved.Audit.EffectiveAction == "allow")
|
||||
}
|
||||
|
||||
detail, _ := json.Marshal(map[string]any{
|
||||
"pending_id": pendingID,
|
||||
"tool": toolName,
|
||||
@@ -576,16 +604,14 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
taskEmit(activity)
|
||||
}
|
||||
|
||||
ch := i.pending.add(pendingID)
|
||||
defer i.pending.remove(pendingID)
|
||||
|
||||
if !dec.TimeoutEnabled {
|
||||
// No timeout: wait indefinitely until user decides or worker stops.
|
||||
select {
|
||||
case allowed := <-ch:
|
||||
return allowed
|
||||
case <-ctx.Done():
|
||||
_ = i.db.DecideInterceptPending(pendingID, "denied")
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "工作已取消")
|
||||
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "执行前工作已取消", false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -601,23 +627,45 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
return allowed
|
||||
case <-timer.C:
|
||||
allowed := dec.TimeoutAction == "allow"
|
||||
_ = i.db.DecideInterceptPending(pendingID, "timeout")
|
||||
action := "deny"
|
||||
if allowed {
|
||||
action = "allow"
|
||||
}
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, "timeout", action, "审批超时,按超时策略处理")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !resolved {
|
||||
detail, err := i.db.GetInterceptDetail(pendingID)
|
||||
return err == nil && detail != nil && (detail.Status == "allowed" || (detail.Audit != nil && detail.Audit.EffectiveAction == "allow"))
|
||||
}
|
||||
return allowed
|
||||
case <-ctx.Done():
|
||||
_ = i.db.DecideInterceptPending(pendingID, "denied")
|
||||
_, _ = i.db.ResolveIntercept(pendingID, "denied", "deny", "工作已取消")
|
||||
_ = i.db.CompleteIntercept(pendingID, audit.RunID, audit.ToolUseID, "not_executed", "执行前工作已取消", false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var ErrAlreadyDecided = errors.New("审批已处理或不存在,请刷新记录")
|
||||
|
||||
// Decide resolves a pending request. Called by the HTTP decide endpoint.
|
||||
func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
|
||||
status := "denied"
|
||||
if allowed {
|
||||
status = "allowed"
|
||||
}
|
||||
if err := i.db.DecideInterceptPending(pendingID, status); err != nil {
|
||||
action, reason := "deny", "人工拒绝执行"
|
||||
if allowed {
|
||||
action, reason = "allow", "人工允许执行"
|
||||
}
|
||||
resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resolved {
|
||||
return ErrAlreadyDecided
|
||||
}
|
||||
i.pending.resolve(pendingID, allowed)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const (
|
||||
contextLimit = 24
|
||||
entryLimit = 8 * 1024
|
||||
promptLimit = 32 * 1024
|
||||
outputLimit = 64 * 1024
|
||||
)
|
||||
|
||||
type traceKey struct{}
|
||||
type callKey struct{}
|
||||
type completion func(status, output string, truncated bool)
|
||||
|
||||
type tracedCall struct {
|
||||
key string
|
||||
audit db.InterceptAudit
|
||||
claimed bool
|
||||
ambiguous bool
|
||||
complete completion
|
||||
}
|
||||
|
||||
// Trace belongs to ONE Prompt invocation. SDK v0.3.6 hooks omit the tool ID;
|
||||
// correlate only when exactly one outstanding event has matching input. Never
|
||||
// guess between simultaneous identical requests, even when results arrive FIFO.
|
||||
type Trace struct {
|
||||
mu sync.Mutex
|
||||
runID string
|
||||
user string
|
||||
userCut bool
|
||||
entries []db.InterceptContextEntry
|
||||
cut bool
|
||||
calls map[string]*tracedCall
|
||||
}
|
||||
|
||||
func WithTrace(ctx context.Context, user string, prior []db.InterceptContextEntry) (context.Context, *Trace) {
|
||||
t := &Trace{runID: rand.Text(), calls: make(map[string]*tracedCall)}
|
||||
t.user, t.userCut = bounded(user, promptLimit)
|
||||
for _, e := range prior {
|
||||
t.append(e)
|
||||
}
|
||||
return context.WithValue(ctx, traceKey{}, t), t
|
||||
}
|
||||
|
||||
func (t *Trace) append(e db.InterceptContextEntry) {
|
||||
var cut bool
|
||||
e.Text, cut = bounded(e.Text, entryLimit)
|
||||
e.Truncated = e.Truncated || cut
|
||||
t.cut = t.cut || e.Truncated
|
||||
t.entries = append(t.entries, e)
|
||||
if len(t.entries) > contextLimit {
|
||||
t.entries = append([]db.InterceptContextEntry(nil), t.entries[len(t.entries)-contextLimit:]...)
|
||||
t.cut = true
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Trace) Append(e db.InterceptContextEntry) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.append(e)
|
||||
}
|
||||
|
||||
func (t *Trace) Start(id, tool string, input []byte) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.calls[id] = &tracedCall{key: tool + ":" + digestInput(input), audit: db.InterceptAudit{
|
||||
RunID: t.runID, ToolUseID: id, Correlation: "exact", InputDigest: digestInput(input),
|
||||
UserMessage: t.user, UserTruncated: t.userCut, CapturedAt: time.Now().UTC(),
|
||||
Context: append([]db.InterceptContextEntry{}, t.entries...), ContextTruncated: t.cut,
|
||||
}}
|
||||
t.append(db.InterceptContextEntry{Kind: "tool_use", Tool: tool, ToolUseID: id, Text: string(input)})
|
||||
}
|
||||
|
||||
// WithCall claims the event before model review starts, so subsequent tools
|
||||
// cannot change this approval's context while the judge is running.
|
||||
func WithCall(ctx context.Context, tool string, input []byte) context.Context {
|
||||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||||
a := db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||||
if t != nil {
|
||||
t.mu.Lock()
|
||||
var candidates []*tracedCall
|
||||
for _, c := range t.calls {
|
||||
if !c.claimed && c.key == tool+":"+a.InputDigest {
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 1 && !candidates[0].ambiguous {
|
||||
candidates[0].claimed = true
|
||||
a = candidates[0].audit
|
||||
} else {
|
||||
a.RunID, a.UserMessage, a.UserTruncated = t.runID, t.user, t.userCut
|
||||
if len(candidates) > 0 {
|
||||
a.Correlation = "ambiguous"
|
||||
for _, c := range candidates {
|
||||
c.ambiguous = true
|
||||
}
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return context.WithValue(ctx, callKey{}, a)
|
||||
}
|
||||
|
||||
func (t *Trace) bind(id string, f completion) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if c := t.calls[id]; c != nil {
|
||||
c.complete = f
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Trace) Complete(id, output string, isError bool) {
|
||||
t.mu.Lock()
|
||||
c := t.calls[id]
|
||||
delete(t.calls, id)
|
||||
t.mu.Unlock()
|
||||
if c == nil || c.complete == nil {
|
||||
return
|
||||
}
|
||||
status := "succeeded"
|
||||
if isError {
|
||||
status = "failed"
|
||||
}
|
||||
out, cut := bounded(output, outputLimit)
|
||||
c.complete(status, out, cut)
|
||||
}
|
||||
|
||||
// Finish marks missing results unknown, never successful. The tool may have
|
||||
// been interrupted or its final event lost; this is distinct from a tool error.
|
||||
func (t *Trace) Finish() {
|
||||
t.mu.Lock()
|
||||
calls := t.calls
|
||||
t.calls = make(map[string]*tracedCall)
|
||||
t.mu.Unlock()
|
||||
for _, c := range calls {
|
||||
if c.complete != nil {
|
||||
c.complete("unknown", "执行结束但未收到工具结果", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func auditFor(ctx context.Context, dec Decision, input []byte, status string) *db.InterceptAudit {
|
||||
a, ok := ctx.Value(callKey{}).(db.InterceptAudit)
|
||||
if !ok {
|
||||
a = db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||||
}
|
||||
a.InitialAction, a.InitialReason = dec.Action, dec.Message
|
||||
a.ModelFallback = dec.ModelFallback
|
||||
a.RuleName, a.ConfigDigest, a.ProfileID = dec.RuleName, dec.ConfigDigest, dec.ProfileID
|
||||
a.ExecutionStatus = "not_started"
|
||||
if status == "allowed" {
|
||||
a.EffectiveAction, a.ExecutionStatus = "allow", "awaiting_result"
|
||||
if a.Correlation != "exact" {
|
||||
a.ExecutionStatus = "unknown"
|
||||
}
|
||||
}
|
||||
if status == "denied" {
|
||||
a.EffectiveAction, a.ExecutionStatus = "deny", "not_executed"
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
func (i *Interceptor) bindResult(ctx context.Context, id int64, audit *db.InterceptAudit) {
|
||||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||||
if t == nil || audit.Correlation != "exact" || audit.ToolUseID == "" {
|
||||
return
|
||||
}
|
||||
t.bind(audit.ToolUseID, func(status, output string, cut bool) {
|
||||
_ = i.db.CompleteIntercept(id, audit.RunID, audit.ToolUseID, status, output, cut)
|
||||
})
|
||||
}
|
||||
|
||||
func digestInput(input []byte) string {
|
||||
var value any
|
||||
d := json.NewDecoder(bytes.NewReader(input))
|
||||
d.UseNumber()
|
||||
if d.Decode(&value) == nil {
|
||||
if canonical, err := json.Marshal(value); err == nil {
|
||||
input = canonical
|
||||
}
|
||||
}
|
||||
h := sha256.Sum256(input)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func bounded(s string, limit int) (string, bool) {
|
||||
if len(s) <= limit {
|
||||
return s, false
|
||||
}
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(s[end]) {
|
||||
end--
|
||||
}
|
||||
return s[:end], true
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestTraceExactCorrelationAndSnapshot(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "save report", []db.InterceptContextEntry{{Kind: "user", Text: "prior message"}})
|
||||
trace.Start("call-a", "Write", []byte(`{"path":"a","n":12345678901234567890}`))
|
||||
trace.Append(db.InterceptContextEntry{Kind: "text", Text: "later context"})
|
||||
callCtx := WithCall(ctx, "Write", []byte(`{ "n":12345678901234567890, "path":"a" }`))
|
||||
a := auditFor(callCtx, Decision{Action: "allow"}, nil, "allowed")
|
||||
if a.Correlation != "exact" || a.ToolUseID != "call-a" || len(a.Context) != 1 || a.Context[0].Text != "prior message" {
|
||||
t.Fatalf("wrong snapshot: %+v", a)
|
||||
}
|
||||
var got string
|
||||
trace.bind("call-a", func(status, output string, cut bool) { got = status + ":" + output })
|
||||
trace.Complete("different-call", "wrong", false)
|
||||
if got != "" {
|
||||
t.Fatal("unrelated result attached")
|
||||
}
|
||||
trace.Complete("call-a", "written", false)
|
||||
trace.Finish()
|
||||
if got != "succeeded:written" {
|
||||
t.Fatalf("result %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceIdenticalParallelCallsAreNeverGuessed(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
input := []byte(`{"command":"pwd"}`)
|
||||
trace.Start("first", "Bash", input)
|
||||
trace.Start("second", "Bash", input)
|
||||
for range 2 {
|
||||
a := auditFor(WithCall(ctx, "Bash", input), Decision{}, input, "pending")
|
||||
if a.Correlation != "ambiguous" || a.ToolUseID != "" {
|
||||
t.Fatalf("guessed identity: %+v", a)
|
||||
}
|
||||
}
|
||||
trace.Complete("first", "first-result", false)
|
||||
a := auditFor(WithCall(ctx, "Bash", input), Decision{}, input, "pending")
|
||||
if a.Correlation != "ambiguous" {
|
||||
t.Fatal("ambiguity must survive the other call completing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceSequentialIdenticalCallsAndRunIsolation(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
input := []byte(`{}`)
|
||||
trace.Start("a", "Read", input)
|
||||
a := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "pending")
|
||||
trace.Start("b", "Read", input)
|
||||
b := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "pending")
|
||||
other, next := WithTrace(context.Background(), "next run", nil)
|
||||
next.Start("a", "Read", input)
|
||||
c := auditFor(WithCall(other, "Read", input), Decision{}, input, "pending")
|
||||
if a.ToolUseID != "a" || b.ToolUseID != "b" || a.RunID == c.RunID {
|
||||
t.Fatal("run or call identities overlap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceBoundsAndMissingResult(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), strings.Repeat("中文", promptLimit), nil)
|
||||
for range contextLimit + 5 {
|
||||
trace.Append(db.InterceptContextEntry{Kind: "text", Text: strings.Repeat("中", entryLimit)})
|
||||
}
|
||||
trace.Start("a", "Read", []byte(`{}`))
|
||||
a := auditFor(WithCall(ctx, "Read", []byte(`{}`)), Decision{}, nil, "pending")
|
||||
if !a.UserTruncated || !a.ContextTruncated || len(a.Context) != contextLimit || !utf8.ValidString(a.UserMessage) {
|
||||
t.Fatal("unbounded/invalid snapshot")
|
||||
}
|
||||
for _, e := range a.Context {
|
||||
if len(e.Text) > entryLimit || !utf8.ValidString(e.Text) {
|
||||
t.Fatal("invalid entry bound")
|
||||
}
|
||||
}
|
||||
got := ""
|
||||
trace.bind("a", func(status, _ string, _ bool) { got = status })
|
||||
trace.Finish()
|
||||
if got != "unknown" {
|
||||
t.Fatalf("missing result presented as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceConcurrentDistinctCalls(t *testing.T) {
|
||||
ctx, trace := WithTrace(context.Background(), "test", nil)
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range []string{"a", "b", "c", "d"} {
|
||||
wg.Go(func() {
|
||||
input := []byte(`{"path":"` + id + `"}`)
|
||||
trace.Start(id, "Read", input)
|
||||
a := auditFor(WithCall(ctx, "Read", input), Decision{}, input, "allowed")
|
||||
if a.ToolUseID != id {
|
||||
t.Errorf("got %s for %s", a.ToolUseID, id)
|
||||
}
|
||||
trace.Complete(id, "ok", false)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
+30
-3
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -35,15 +36,15 @@ func (s *Server) wireInterceptReviewer() {
|
||||
}
|
||||
prov, _, ok := s.providerForProfile(profileID)
|
||||
if !ok {
|
||||
return intercept.Decision{}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
|
||||
}
|
||||
user := fmt.Sprintf("Tool: %s\nArguments:\n%s", tool, command)
|
||||
text, err := streamCollectText(ctx, prov, prompt, user)
|
||||
if err != nil {
|
||||
return intercept.Decision{}, err
|
||||
return intercept.Decision{ProfileID: profileID}, err
|
||||
}
|
||||
v := intercept.ParseVerdict(text)
|
||||
return intercept.Decision{Action: v.Action, Message: v.Reason}, nil
|
||||
return intercept.Decision{Action: v.Action, Message: v.Reason, ProfileID: profileID}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,6 +279,10 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.m.interceptor.Decide(id, req.Decision == "allowed"); err != nil {
|
||||
if errors.Is(err, intercept.ErrAlreadyDecided) {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -396,3 +401,25 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok || id <= 0 {
|
||||
writeErr(w, 400, "bad approval id")
|
||||
return
|
||||
}
|
||||
detail, err := pg.GetInterceptDetail(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if detail == nil {
|
||||
writeErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, detail)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestInterceptDetailHTTP(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
// Exercise the authenticated HTTP surface without starting unrelated task
|
||||
// schedulers or mutating the process-global tool assembly used by other tests.
|
||||
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||
s := &Server{m: m, jwtKey: []byte("approval-http-test-signing-key")}
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := m.pg.CreateInterceptPending(0, 0, "approval-http", "test", "Write", []byte(`{}`), "[模型] 确认", &db.InterceptAudit{InitialAction: "ask", UserMessage: "snapshot", ExecutionStatus: "not_started"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec(`DELETE FROM intercept_pending WHERE id=$1`, id) })
|
||||
do := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if authenticated {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
path := fmt.Sprintf("/api/intercept/history/%d", id)
|
||||
if r := do(http.MethodGet, path, "", false); r.Code != 401 {
|
||||
t.Fatalf("unprotected detail: %d", r.Code)
|
||||
}
|
||||
r := do(http.MethodGet, path, "", true)
|
||||
var detail db.InterceptDetail
|
||||
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &detail) != nil || detail.Audit == nil || detail.Audit.UserMessage != "snapshot" {
|
||||
t.Fatalf("detail: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
code int
|
||||
}{{"/api/intercept/history/not-a-number", 400}, {"/api/intercept/history/0", 400}, {"/api/intercept/history/9223372036854775807", 404}} {
|
||||
if r := do(http.MethodGet, tc.path, "", true); r.Code != tc.code {
|
||||
t.Fatalf("%s: %d", tc.path, r.Code)
|
||||
}
|
||||
}
|
||||
decisionPath := fmt.Sprintf("/api/intercept/pending/%d/decide", id)
|
||||
if r := do(http.MethodPost, decisionPath, `{"decision":"denied"}`, true); r.Code != 200 {
|
||||
t.Fatalf("decide: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
if r := do(http.MethodPost, decisionPath, `{"decision":"allowed"}`, true); r.Code != 409 {
|
||||
t.Fatalf("duplicate decision: %d", r.Code)
|
||||
}
|
||||
}
|
||||
@@ -949,6 +949,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
||||
mux.HandleFunc("GET /api/intercept/history", s.interceptHistory)
|
||||
mux.HandleFunc("GET /api/intercept/history/{id}", s.interceptDetail)
|
||||
mux.HandleFunc("GET /api/intercept/task/{taskID}", s.interceptListTaskItems)
|
||||
mux.HandleFunc("GET /api/intercept/tool-config", s.interceptGetToolConfig)
|
||||
mux.HandleFunc("PUT /api/intercept/tool-config", s.interceptSetToolConfig)
|
||||
|
||||
@@ -1,241 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
function fmtTime(s: string) {
|
||||
if (!s) return "—";
|
||||
return new Date(s).toLocaleString("zh-CN", {
|
||||
month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function AgentChip({ name }: { name: string }) {
|
||||
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
const color =
|
||||
name === "planner" ? "bg-amber-500" :
|
||||
name === "mainagent" ? "bg-primary" :
|
||||
"bg-sky-600";
|
||||
return (
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function InterceptTab({ taskId }: Props) {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [deciding, setDeciding] = React.useState<number | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRows(await api.interceptTask(taskId));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function decide(id: number, decision: "allowed" | "denied") {
|
||||
if (deciding !== null) return;
|
||||
setDeciding(id);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
setRows((prev) =>
|
||||
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
|
||||
拦截审批
|
||||
{pending.length > 0 && (
|
||||
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
|
||||
{pending.length} 待审批
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* pending block */}
|
||||
{pending.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlertIcon className="h-3.5 w-3.5" />
|
||||
待处理({pending.length})— 超时 5 分钟后自动拒绝
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />允许
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
|
||||
<XIcon className="mr-1 h-3 w-3" />拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* history table */}
|
||||
<div className="rounded-lg border">
|
||||
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
全部记录({rows.length})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
<TableHead>决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.decided_at ? fmtTime(row.decided_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
export function InterceptTab({ taskId }: { taskId: string }) {
|
||||
return <ApprovalRecords key={taskId} taskId={taskId} />;
|
||||
}
|
||||
@@ -1,263 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { BotIcon, CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
function fmtTime(s: string) {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function SourceCell({ row }: { row: InterceptApprovalRow }) {
|
||||
if (row.task_id) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-mono text-xs font-medium">{row.task_id}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{row.agent_name || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (row.conversation_id) {
|
||||
const agent = row.conv_agent_key || "—";
|
||||
const title = row.conv_title || `对话 #${row.conversation_id}`;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">Agent: {agent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
// MatchCell shows the matched rule name, or a "模型判定" badge when the decision
|
||||
// came from the LLM fallback judge (rule_id null). The reason (rule message or
|
||||
// model rationale, minus the [模型] prefix) is shown beneath.
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-sm">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
const short = s.length > 120 ? s.slice(0, 120) + "…" : s;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">
|
||||
{short}
|
||||
</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- main component ---------------------------------------------------------
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [deciding, setDeciding] = React.useState<number | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const items = await api.interceptHistory();
|
||||
setRows(items);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
async function decide(id: number, decision: "allowed" | "denied") {
|
||||
if (deciding !== null) return;
|
||||
setDeciding(id);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const decided = rows.filter((r) => r.status !== "pending");
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-1 flex-col gap-6 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">审批记录</h1>
|
||||
{pending.length > 0 && (
|
||||
<Badge className="rounded-full bg-destructive text-destructive-foreground">{pending.length} 待审批</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* pending section */}
|
||||
{pending.length > 0 && (
|
||||
<section className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-3 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlertIcon className="h-4 w-4" />
|
||||
待处理 ({pending.length})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>匹配规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-28 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={deciding === row.id}
|
||||
onClick={() => decide(row.id, "allowed")}
|
||||
>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
允许
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={deciding === row.id}
|
||||
onClick={() => decide(row.id, "denied")}
|
||||
>
|
||||
<XIcon className="mr-1 h-3 w-3" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* history section */}
|
||||
<section className="rounded-lg border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
全部记录 ({rows.length})
|
||||
</div>
|
||||
{rows.length === 0 && !loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">暂无审批记录</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>匹配规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
<TableHead>决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
|
||||
<TableCell className="text-muted-foreground">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.decided_at ? fmtTime(row.decided_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex min-w-0 flex-1 flex-col p-4 sm:p-6">
|
||||
<ApprovalRecords />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardListIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(value?: string) {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function source(row: InterceptApprovalRow) {
|
||||
if (row.decision_source) return row.decision_source;
|
||||
if (row.rule_id) return "rule";
|
||||
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const labels: Record<string, string> = { pending: "待审批", allowed: "已允许", denied: "已拒绝", timeout: "已超时" };
|
||||
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
|
||||
if (status === "allowed") variant = "default";
|
||||
if (status === "denied") variant = "destructive";
|
||||
if (status === "pending") variant = "secondary";
|
||||
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{source(row) === "model" ? (
|
||||
<Badge variant="outline">
|
||||
<BotIcon />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="truncate">{row.rule_name || "规则未记录或已删除"}</span>
|
||||
)}
|
||||
{showReason ? (
|
||||
<p className="truncate text-muted-foreground text-xs" title={reason}>
|
||||
{reason || "未记录理由"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选择内容复制");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
|
||||
{text ? (
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`复制${label}`} onClick={() => void copy()}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
|
||||
{text || "未记录"}
|
||||
</pre>
|
||||
{truncated ? <p className="text-muted-foreground text-xs">内容已截断,以上为保存的片段。</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const contextLabels: Record<string, string> = {
|
||||
user: "用户消息",
|
||||
assistant: "Agent 消息",
|
||||
text: "Agent 消息",
|
||||
tool_use: "工具请求",
|
||||
tool_result: "工具输出",
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = { allow: "允许", ask: "转人工审批", deny: "拒绝" };
|
||||
const executionLabels: Record<InterceptAudit["execution_status"], string> = {
|
||||
not_started: "尚未执行",
|
||||
not_executed: "未执行",
|
||||
awaiting_result: "已允许,等待执行结果",
|
||||
succeeded: "执行成功",
|
||||
failed: "执行失败",
|
||||
unknown: "执行结果未知",
|
||||
};
|
||||
|
||||
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
|
||||
|
||||
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
|
||||
if (row.status !== "pending") return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
允许
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalDetail({
|
||||
row,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
}: {
|
||||
row: InterceptApprovalRow;
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
}) {
|
||||
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [retry, setRetry] = React.useState(0);
|
||||
const [more, setMore] = React.useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function load() {
|
||||
try {
|
||||
const next = await api.interceptDetail(row.id);
|
||||
if (cancelled) return;
|
||||
setDetail(next);
|
||||
setError("");
|
||||
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
|
||||
timer = setTimeout(() => void load(), 5000);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || "详情加载失败");
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [row.id, row.status, revision, retry]);
|
||||
|
||||
// Row updates are authoritative until the lazy detail has caught up.
|
||||
const current = detail?.status === row.status ? detail : row;
|
||||
const audit = detail?.audit;
|
||||
let execution = audit ? executionLabels[audit.execution_status] : "未记录";
|
||||
if (row.status === "pending") execution = "尚未执行";
|
||||
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow") execution = "未关联执行结果";
|
||||
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
|
||||
let initialLabel = source(row) === "model" ? "模型初判" : "规则初判";
|
||||
if (audit?.model_fallback) initialLabel = "模型异常回退";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
|
||||
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CodeBlock
|
||||
label={`${row.agent_name || row.conv_agent_key || "Agent"} · 工具请求`}
|
||||
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
|
||||
/>
|
||||
{command ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
查看命令内容
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<CodeBlock label="命令内容" text={command} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
执行结果:<span className="text-foreground">{detail ? execution : "加载中…"}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">
|
||||
{current.status === "pending" ? "审查状态" : "审批裁决"}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={current.status} />
|
||||
<MatchCell row={current} showReason={false} />
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
|
||||
{current.reason?.replace(/^\[模型\]\s*/, "") || "未记录审批理由"}
|
||||
</p>
|
||||
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
|
||||
{audit?.effective_action ? <p className="text-sm">最终动作:{actionLabels[audit.effective_action]}</p> : null}
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">来源</dt>
|
||||
<dd className="break-words">
|
||||
{row.conv_title || (row.task_id ? `任务 ${row.task_id}` : row.agent_name || "未记录")}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">申请时间</dt>
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">决定时间</dt>
|
||||
<dd>{fmtTime(current.decided_at)}</dd>
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">规则快照</dt>
|
||||
<dd>{audit.rule_name}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{audit?.profile_id ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">审批模型配置</dt>
|
||||
<dd>#{audit.profile_id}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<DecisionActions row={current} busy={busy} decide={decide} />
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>详情加载失败:{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
|
||||
重试详情
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
|
||||
{detail && !audit ? (
|
||||
<Alert>
|
||||
<AlertDescription>此记录未保存审批详情快照,无法还原当时的上下文、模型初判和执行输出。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{audit ? (
|
||||
<Collapsible open={more} onOpenChange={setMore}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
|
||||
{more ? "收起上下文" : "查看上下文与执行结果"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<CodeBlock label="用户消息 / 当前轮输入" text={audit.user_message} truncated={audit.user_truncated} />
|
||||
<section className="flex min-w-0 flex-col gap-3">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">可见会话上下文</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
保存于 {fmtTime(audit.captured_at)} 的会话记录片段。模型审批目前仅接收工具名称和参数。
|
||||
</p>
|
||||
{audit.context_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">仅保存最近的上下文,部分内容已截断。</p>
|
||||
) : null}
|
||||
{audit.context?.length ? (
|
||||
audit.context.map((entry, index) => (
|
||||
<CodeBlock
|
||||
key={`${entry.kind}-${entry.tool_use_id || index}`}
|
||||
label={`${contextLabels[entry.kind] ?? entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? " · 异常" : ""}`}
|
||||
text={entry.text}
|
||||
truncated={entry.truncated}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">未记录可关联的上下文</p>
|
||||
)}
|
||||
</section>
|
||||
<CodeBlock
|
||||
label={`${initialLabel}:${actionLabels[audit.initial_action] ?? audit.initial_action}`}
|
||||
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
|
||||
/>
|
||||
<CodeBlock
|
||||
label="执行输出"
|
||||
text={audit.output ?? (audit.execution_status === "not_executed" ? "工具未执行。" : "尚无执行输出")}
|
||||
truncated={audit.output_truncated}
|
||||
/>
|
||||
{audit.correlation !== "exact" ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{audit.correlation === "ambiguous"
|
||||
? "存在相同参数的并发调用,无法唯一关联工具调用;本记录不展示推测的执行结果。"
|
||||
: "未记录可唯一关联的工具调用 ID。"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
|
||||
<div>工具调用 ID:{audit.tool_use_id || "未记录"}</div>
|
||||
<div>参数摘要 SHA-256:{audit.input_digest}</div>
|
||||
<div>审查配置指纹 SHA-256:{audit.config_digest || "未记录"}</div>
|
||||
{audit.execution_ended_at ? <div>结果记录时间:{fmtTime(audit.execution_ended_at)}</div> : null}
|
||||
</dl>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hidden cells do not occupy table columns. Keep the detail span in sync so
|
||||
// expansion cannot create empty columns and leave a gap in the selected row.
|
||||
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
|
||||
function subscribeColumns(onChange: () => void) {
|
||||
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
|
||||
for (const query of queries) query.addEventListener("change", onChange);
|
||||
return () => {
|
||||
for (const query of queries) query.removeEventListener("change", onChange);
|
||||
};
|
||||
}
|
||||
function visibleColumnCount() {
|
||||
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
}
|
||||
function serverColumnCount() {
|
||||
return 9;
|
||||
}
|
||||
|
||||
function ApprovalTable({
|
||||
rows,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
label,
|
||||
}: {
|
||||
rows: InterceptApprovalRow[];
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
label: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
|
||||
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
|
||||
const prefix = React.useId();
|
||||
function toggle(id: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Table className="table-fixed" aria-label={label}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">展开详情</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
|
||||
<TableHead className="w-28">工具</TableHead>
|
||||
<TableHead className="hidden md:table-cell">来源</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">匹配规则</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">参数</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead className="hidden w-36 lg:table-cell">申请时间</TableHead>
|
||||
<TableHead className="hidden w-36 xl:table-cell">决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const open = expanded.has(row.id);
|
||||
const panelID = `${prefix}-${row.id}`;
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={open ? "selected" : undefined}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggle(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${open ? "收起" : "展开"}审批 #${row.id}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelID : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(row.id);
|
||||
}}
|
||||
>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="block truncate text-xs">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate" title={row.conv_title || row.task_id}>
|
||||
{row.conv_title || row.task_id || "—"}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.agent_name || row.conv_agent_key}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<MatchCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell">
|
||||
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
|
||||
{fmtTime(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
|
||||
{fmtTime(row.decided_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open ? (
|
||||
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
|
||||
<TableCell colSpan={columns} className="whitespace-normal p-0">
|
||||
<section id={panelID} aria-label={`审批详情 #${row.id}`}>
|
||||
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
|
||||
</section>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const [deciding, setDeciding] = React.useState(false);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
|
||||
const load = React.useCallback(
|
||||
async (manual = false) => {
|
||||
const id = ++request.current;
|
||||
if (manual) setRefreshing(true);
|
||||
try {
|
||||
const items = taskId ? await api.interceptTask(taskId) : await api.interceptHistory();
|
||||
if (id !== request.current) return;
|
||||
setRows(items);
|
||||
setError("");
|
||||
if (manual) setRevision((v) => v + 1);
|
||||
} catch (e) {
|
||||
if (id === request.current) setError((e as Error).message || "加载失败");
|
||||
} finally {
|
||||
if (id === request.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), 5000);
|
||||
return () => {
|
||||
request.current++;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const decide: Decide = async (id, decision) => {
|
||||
if (decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
setDeciding(true);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
// Invalidate a list request started before this decision.
|
||||
request.current++;
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
);
|
||||
setRevision((v) => v + 1);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
await load(true);
|
||||
} finally {
|
||||
decisionLock.current = false;
|
||||
setDeciding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pending = rows.filter((row) => row.status === "pending");
|
||||
const title = taskId ? "拦截审批" : "审批记录";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-xl">{title}</h1>
|
||||
{pending.length ? <Badge variant="secondary">{pending.length} 待审批</Badge> : null}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
|
||||
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{pending.length ? (
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
待处理({pending.length})<span className="text-muted-foreground text-xs">展开后允许或拒绝</span>
|
||||
</div>
|
||||
<ApprovalTable rows={pending} busy={deciding} decide={decide} revision={revision} label="待处理审批" />
|
||||
</section>
|
||||
) : null}
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{taskId ? "全部记录" : "最近记录"}({rows.length})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !rows.length && !error ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>暂无审批记录</EmptyTitle>
|
||||
<EmptyDescription>规则或模型作出审批决定后,记录将显示在这里。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
{rows.length ? (
|
||||
<ApprovalTable rows={rows} busy={deciding} decide={decide} revision={revision} label="审批记录列表" />
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
FindingsPage,
|
||||
IntentAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
JudgeConfig,
|
||||
@@ -957,6 +958,7 @@ export const api = {
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
Finding,
|
||||
FindingAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptAudit,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
LLMPoolStatus,
|
||||
@@ -3469,6 +3470,40 @@ export const interceptHistory: InterceptApprovalRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Approval detail fixtures use harmless report-writing examples.
|
||||
interceptHistory.unshift({
|
||||
id: 94, task_id: "t-acme-web", agent_name: "work#1", tool_name: "Write",
|
||||
tool_input: { path: "reports/summary.md", content: "# 检查摘要\n\n本轮验证已完成,整理已有证据与后续建议。" },
|
||||
status: "allowed", decision_source: "model", reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
|
||||
created_at: T("2026-07-26T08:00:00Z"), decided_at: T("2026-07-26T08:00:02Z"),
|
||||
conv_title: "", conv_agent_key: "", rule_name: "",
|
||||
});
|
||||
|
||||
export const interceptDetails: Record<number, InterceptAudit> = {
|
||||
94: {
|
||||
run_id: "demo-run-report", tool_use_id: "call-write-report", correlation: "exact",
|
||||
input_digest: "a3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458ec1234",
|
||||
user_message: "请整理已经完成的检查,将结论和证据索引写入 reports/summary.md。\n保留待验证项,不修改业务数据。",
|
||||
context: [
|
||||
{ kind: "user", text: "汇总本轮已有证据,生成检查摘要。" },
|
||||
{ kind: "tool_use", tool: "Read", tool_use_id: "call-read-evidence", text: '{"path":"reports/evidence-index.json"}' },
|
||||
{ kind: "tool_result", tool: "Read", tool_use_id: "call-read-evidence", text: "已读取 3 条证据索引。\n记录包含请求时间、结果摘要和本地文件位置。" },
|
||||
],
|
||||
captured_at: T("2026-07-26T08:00:00Z"), initial_action: "allow",
|
||||
initial_reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。", effective_action: "allow",
|
||||
config_digest: "b4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fd1234",
|
||||
profile_id: 1, execution_status: "succeeded", output: "Successfully wrote reports/summary.md\n共写入 68 个字符。",
|
||||
execution_ended_at: T("2026-07-26T08:00:03Z"),
|
||||
},
|
||||
93: {
|
||||
run_id: "demo-run-review", tool_use_id: "call-pending-review", correlation: "exact",
|
||||
input_digest: "c5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670ae1234",
|
||||
user_message: "检查备份表的用途,涉及删除时需要先确认。", context: [],
|
||||
captured_at: T("2026-07-25T15:14:00Z"), initial_action: "ask",
|
||||
initial_reason: "[模型] 疑似备份表,无法确定是否生产数据。", profile_id: 1, execution_status: "not_started",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Conversations (chat) ─────────────────────────────────────────────────────
|
||||
export const conversations: Conversation[] = [
|
||||
{
|
||||
|
||||
@@ -33,6 +33,9 @@ const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
||||
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
||||
const mockInterceptHistory = structuredClone(D.interceptHistory);
|
||||
const mockInterceptPending = structuredClone(D.interceptPending);
|
||||
const mockInterceptDetails = structuredClone(D.interceptDetails);
|
||||
const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
@@ -1981,13 +1984,33 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
|
||||
return { ok: true, enabled: b.enabled ?? true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") {
|
||||
const id = Number(seg[2]);
|
||||
const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id);
|
||||
if (!row || row.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录");
|
||||
if (b.decision !== "allowed" && b.decision !== "denied") throw new Error("无效审批动作");
|
||||
row.status = b.decision;
|
||||
row.decided_at = new Date().toISOString();
|
||||
const detail = mockInterceptDetails[id];
|
||||
if (detail) {
|
||||
detail.effective_action = b.decision === "allowed" ? "allow" : "deny";
|
||||
detail.decision_reason = b.decision === "allowed" ? "人工允许执行" : "人工拒绝执行";
|
||||
detail.execution_status = b.decision === "allowed" ? "unknown" : "not_executed";
|
||||
detail.output = b.decision === "allowed" ? "演示模式未执行工具。" : "";
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "history" && seg.length === 3) {
|
||||
const row = mockInterceptHistory.find((r) => r.id === Number(seg[2]));
|
||||
if (!row) throw new Error("审批记录不存在");
|
||||
return { ...row, audit: mockInterceptDetails[row.id] ?? null };
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||
return D.interceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||
if (path === "/intercept/history") return { items: D.interceptHistory };
|
||||
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||
if (path === "/intercept/history") return { items: mockInterceptHistory };
|
||||
if (seg[0] === "intercept" && seg[1] === "task")
|
||||
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
return { items: mockInterceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
|
||||
if (path === "/intercept/judge" && m === "GET")
|
||||
return {
|
||||
|
||||
@@ -1133,6 +1133,7 @@ export interface InterceptRule {
|
||||
}
|
||||
|
||||
export interface InterceptPending {
|
||||
decision_source?: "rule" | "model" | "unknown" | "";
|
||||
id: number;
|
||||
rule_id?: number;
|
||||
conversation_id?: number;
|
||||
@@ -1246,3 +1247,31 @@ export interface LLMTask {
|
||||
task_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Immutable review snapshot plus separately recorded execution outcome.
|
||||
export interface InterceptAudit {
|
||||
run_id?: string;
|
||||
tool_use_id?: string;
|
||||
correlation: "exact" | "ambiguous" | "unavailable";
|
||||
input_digest: string;
|
||||
user_message: string;
|
||||
user_truncated?: boolean;
|
||||
context: { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[] | null;
|
||||
context_truncated?: boolean;
|
||||
captured_at: string;
|
||||
model_fallback?: boolean;
|
||||
initial_action: "allow" | "ask" | "deny";
|
||||
initial_reason: string;
|
||||
effective_action?: "allow" | "deny";
|
||||
decision_reason?: string;
|
||||
rule_name?: string;
|
||||
config_digest?: string;
|
||||
profile_id?: number;
|
||||
execution_status: "not_started" | "not_executed" | "awaiting_result" | "succeeded" | "failed" | "unknown";
|
||||
output?: string;
|
||||
output_truncated?: boolean;
|
||||
execution_ended_at?: string;
|
||||
}
|
||||
export interface InterceptDetail extends InterceptApprovalRow {
|
||||
audit: InterceptAudit | null;
|
||||
}
|
||||
Reference in new issue
Block a user