From 9ad323dc4c625c26506c56d04734cd6289355262 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:10:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(intercept):=20=E5=A2=9E=E5=8A=A0=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E8=AE=B0=E5=BD=95=E8=AF=A6=E6=83=85=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 + agent/capture.go | 31 + agent/capture_approval_test.go | 131 ++++ db/intercept.go | 57 +- db/intercept_detail.go | 112 ++++ db/intercept_detail_test.go | 167 +++++ db/schema.sql | 5 + db/task_archives_restore.go | 18 + guard/guard.go | 8 +- intercept/intercept.go | 74 ++- intercept/trace.go | 208 +++++++ intercept/trace_test.go | 106 ++++ server/intercept.go | 33 +- server/intercept_detail_test.go | 72 +++ server/server.go | 1 + .../tasks/detail/_tabs/intercept-tab.tsx | 240 +------ .../system/intercept/approvals/page.tsx | 260 +------- web/src/components/approval-records.tsx | 586 ++++++++++++++++++ web/src/lib/api.ts | 2 + web/src/lib/mock/data.ts | 35 ++ web/src/lib/mock/handler.ts | 33 +- web/src/lib/types.ts | 29 + 22 files changed, 1687 insertions(+), 535 deletions(-) create mode 100644 agent/capture_approval_test.go create mode 100644 db/intercept_detail.go create mode 100644 db/intercept_detail_test.go create mode 100644 intercept/trace.go create mode 100644 intercept/trace_test.go create mode 100644 server/intercept_detail_test.go create mode 100644 web/src/components/approval-records.tsx diff --git a/README.md b/README.md index 8716b1c..8ebd416 100644 --- a/README.md +++ b/README.md @@ -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) 直接同步资产数据,免去重复收集: diff --git a/agent/capture.go b/agent/capture.go index 965a9f3..feb8f1c 100644 --- a/agent/capture.go +++ b/agent/capture.go @@ -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 +} diff --git a/agent/capture_approval_test.go b/agent/capture_approval_test.go new file mode 100644 index 0000000..ac2d447 --- /dev/null +++ b/agent/capture_approval_test.go @@ -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) + } + }) + } +} diff --git a/db/intercept.go b/db/intercept.go index 8a07868..b2d4ee6 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -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) { diff --git a/db/intercept_detail.go b/db/intercept_detail.go new file mode 100644 index 0000000..68d8997 --- /dev/null +++ b/db/intercept_detail.go @@ -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 +} diff --git a/db/intercept_detail_test.go b/db/intercept_detail_test.go new file mode 100644 index 0000000..934e38c --- /dev/null +++ b/db/intercept_detail_test.go @@ -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) + } + } + }) +} diff --git a/db/schema.sql b/db/schema.sql index 4248569..ff34e38 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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. 漏洞发现持久化 diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 71e916a..acf0d3d 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -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 { diff --git a/guard/guard.go b/guard/guard.go index 9e13f9c..b2d67c4 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -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 diff --git a/intercept/intercept.go b/intercept/intercept.go index 0d718cc..0fb7b9a 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -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 } diff --git a/intercept/trace.go b/intercept/trace.go new file mode 100644 index 0000000..ea5caae --- /dev/null +++ b/intercept/trace.go @@ -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 +} diff --git a/intercept/trace_test.go b/intercept/trace_test.go new file mode 100644 index 0000000..b736164 --- /dev/null +++ b/intercept/trace_test.go @@ -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() +} diff --git a/server/intercept.go b/server/intercept.go index 5300aaf..76a34dc 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -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) +} diff --git a/server/intercept_detail_test.go b/server/intercept_detail_test.go new file mode 100644 index 0000000..7ba0881 --- /dev/null +++ b/server/intercept_detail_test.go @@ -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) + } +} diff --git a/server/server.go b/server/server.go index 3f31803..0e0b30c 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx index f566d52..e2a4ee4 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/intercept-tab.tsx @@ -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 —; - const color = - name === "planner" ? "bg-amber-500" : - name === "mainagent" ? "bg-primary" : - "bg-sky-600"; - return ( - - {name} - - ); -} - -function StatusBadge({ status }: { status: string }) { - if (status === "pending") return 待审批; - if (status === "allowed") return 已允许; - if (status === "denied") return 已拒绝; - return 已超时; -} - -function MatchCell({ row }: { row: InterceptApprovalRow }) { - const isModel = !row.rule_id; - const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim(); - return ( -
- {row.rule_name ? ( - {row.rule_name} - ) : isModel ? ( - - - 模型判定 - - ) : ( - — - )} - {reason && ( -

- {reason} -

- )} -
- ); -} - -function InputPreview({ input }: { input: Record }) { - const full = JSON.stringify(input, null, 2); - const s = JSON.stringify(input); - const short = s.length > 100 ? s.slice(0, 100) + "…" : s; - return ( - - - {short} - - -
{full}
-
-
- ); -} - -interface Props { - taskId: string; -} - -export function InterceptTab({ taskId }: Props) { - const [rows, setRows] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [deciding, setDeciding] = React.useState(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 ( -
- -

此任务暂无拦截审批记录

-
- ); - } - - return ( - -
- {/* header */} -
-
- - 拦截审批 - {pending.length > 0 && ( - - {pending.length} 待审批 - - )} -
- -
- - {/* pending block */} - {pending.length > 0 && ( -
-
- - 待处理({pending.length})— 超时 5 分钟后自动拒绝 -
- - - - # - Agent - 工具 - 规则 - 参数 - 时间 - 操作 - - - - {pending.map((row) => ( - - {row.id} - - - {row.tool_name} - - - - {fmtTime(row.created_at)} - -
- - -
-
-
- ))} -
-
-
- )} - - {/* history table */} -
-
- 全部记录({rows.length}) -
- - - - # - Agent - 工具 - 规则 - 参数 - 状态 - 申请时间 - 决定时间 - - - - {rows.map((row) => ( - - {row.id} - - - {row.tool_name} - - - - - {fmtTime(row.created_at)} - - {row.decided_at ? fmtTime(row.decided_at) : "—"} - - - ))} - -
-
-
-
- ); +export function InterceptTab({ taskId }: { taskId: string }) { + return ; } diff --git a/web/src/app/(main)/system/intercept/approvals/page.tsx b/web/src/app/(main)/system/intercept/approvals/page.tsx index 7dbbd5b..665272a 100644 --- a/web/src/app/(main)/system/intercept/approvals/page.tsx +++ b/web/src/app/(main)/system/intercept/approvals/page.tsx @@ -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 ( -
-
{row.task_id}
-
{row.agent_name || "—"}
-
- ); - } - if (row.conversation_id) { - const agent = row.conv_agent_key || "—"; - const title = row.conv_title || `对话 #${row.conversation_id}`; - return ( -
-
{title}
-
Agent: {agent}
-
- ); - } - return —; -} - -// 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 ( -
- {row.rule_name ? ( - {row.rule_name} - ) : isModel ? ( - - - 模型判定 - - ) : ( - — - )} - {reason && ( -

- {reason} -

- )} -
- ); -} - -function StatusBadge({ status }: { status: string }) { - if (status === "pending") return 待审批; - if (status === "allowed") return 已允许; - if (status === "denied") return 已拒绝; - return 已超时; -} - -function InputPreview({ input }: { input: Record }) { - const full = JSON.stringify(input, null, 2); - const s = JSON.stringify(input); - const short = s.length > 120 ? s.slice(0, 120) + "…" : s; - return ( - - - - {short} - - - -
{full}
-
-
- ); -} - -// ---- main component --------------------------------------------------------- +import { ApprovalRecords } from "@/components/approval-records"; export default function ApprovalsPage() { - const [rows, setRows] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [deciding, setDeciding] = React.useState(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 ( - -
- {/* header */} -
-
- -

审批记录

- {pending.length > 0 && ( - {pending.length} 待审批 - )} -
- -
- - {/* pending section */} - {pending.length > 0 && ( -
-
- - 待处理 ({pending.length}) -
- - - - # - 工具 - 来源 - 匹配规则 - 参数 - 时间 - 操作 - - - - {pending.map((row) => ( - - {row.id} - - {row.tool_name} - - - - - {fmtTime(row.created_at)} - -
- - -
-
-
- ))} -
-
-
- )} - - {/* history section */} -
-
- 全部记录 ({rows.length}) -
- {rows.length === 0 && !loading ? ( -
暂无审批记录
- ) : ( - - - - # - 工具 - 来源 - 匹配规则 - 参数 - 状态 - 申请时间 - 决定时间 - - - - {rows.map((row) => ( - - {row.id} - - {row.tool_name} - - - - - - {fmtTime(row.created_at)} - - {row.decided_at ? fmtTime(row.decided_at) : "—"} - - - ))} - -
- )} -
+
+
- ); } diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx new file mode 100644 index 0000000..12d6c18 --- /dev/null +++ b/web/src/components/approval-records.tsx @@ -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 = { 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 {labels[status] ?? status}; +} + +function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) { + const reason = row.reason?.replace(/^\[模型\]\s*/, ""); + return ( +
+ {source(row) === "model" ? ( + + + 模型判定 + + ) : ( + {row.rule_name || "规则未记录或已删除"} + )} + {showReason ? ( +

+ {reason || "未记录理由"} +

+ ) : null} +
+ ); +} + +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 ( +
+
+

{label}

+ {text ? ( + + ) : null} +
+
+        {text || "未记录"}
+      
+ {truncated ?

内容已截断,以上为保存的片段。

: null} +
+ ); +} + +const contextLabels: Record = { + user: "用户消息", + assistant: "Agent 消息", + text: "Agent 消息", + tool_use: "工具请求", + tool_result: "工具输出", +}; + +const actionLabels: Record = { allow: "允许", ask: "转人工审批", deny: "拒绝" }; +const executionLabels: Record = { + not_started: "尚未执行", + not_executed: "未执行", + awaiting_result: "已允许,等待执行结果", + succeeded: "执行成功", + failed: "执行失败", + unknown: "执行结果未知", +}; + +type Decide = (id: number, decision: "allowed" | "denied") => Promise; + +function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) { + if (row.status !== "pending") return null; + return ( +
+ + +
+ ); +} + +function ApprovalDetail({ + row, + busy, + decide, + revision, +}: { + row: InterceptApprovalRow; + busy: boolean; + decide: Decide; + revision: number; +}) { + const [detail, setDetail] = React.useState(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 | 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 ( +
+
+
+ + {command ? ( + + + + + + + + + ) : null} +

+ 执行结果:{detail ? execution : "加载中…"} +

+
+
+

+ {current.status === "pending" ? "审查状态" : "审批裁决"} +

+
+ + +
+

+ {current.reason?.replace(/^\[模型\]\s*/, "") || "未记录审批理由"} +

+ {audit?.decision_reason ?

{audit.decision_reason}

: null} + {audit?.effective_action ?

最终动作:{actionLabels[audit.effective_action]}

: null} +
+
来源
+
+ {row.conv_title || (row.task_id ? `任务 ${row.task_id}` : row.agent_name || "未记录")} +
+
申请时间
+
{fmtTime(row.created_at)}
+
决定时间
+
{fmtTime(current.decided_at)}
+ {audit?.rule_name ? ( + <> +
规则快照
+
{audit.rule_name}
+ + ) : null} + {audit?.profile_id ? ( + <> +
审批模型配置
+
#{audit.profile_id}
+ + ) : null} +
+ +
+
+ {error ? ( + + +
+ 详情加载失败:{error} + +
+
+
+ ) : null} + {!detail && !error ? : null} + {detail && !audit ? ( + + 此记录未保存审批详情快照,无法还原当时的上下文、模型初判和执行输出。 + + ) : null} + {audit ? ( + + + + + +
+ +
+

可见会话上下文

+

+ 保存于 {fmtTime(audit.captured_at)} 的会话记录片段。模型审批目前仅接收工具名称和参数。 +

+ {audit.context_truncated ? ( +

仅保存最近的上下文,部分内容已截断。

+ ) : null} + {audit.context?.length ? ( + audit.context.map((entry, index) => ( + + )) + ) : ( +

未记录可关联的上下文

+ )} +
+ + + {audit.correlation !== "exact" ? ( + + + {audit.correlation === "ambiguous" + ? "存在相同参数的并发调用,无法唯一关联工具调用;本记录不展示推测的执行结果。" + : "未记录可唯一关联的工具调用 ID。"} + + + ) : null} +
+
工具调用 ID:{audit.tool_use_id || "未记录"}
+
参数摘要 SHA-256:{audit.input_digest}
+
审查配置指纹 SHA-256:{audit.config_digest || "未记录"}
+ {audit.execution_ended_at ?
结果记录时间:{fmtTime(audit.execution_ended_at)}
: null} +
+
+
+
+ ) : null} +
+ ); +} + +// 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>(() => 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 ( + + + + + 展开详情 + + # + 工具 + 来源 + 匹配规则 + 参数 + 状态 + 申请时间 + 决定时间 + + + + {rows.map((row) => { + const open = expanded.has(row.id); + const panelID = `${prefix}-${row.id}`; + return ( + + toggle(row.id)} + > + + + + {row.id} + + {row.tool_name} + + +
+ + {row.conv_title || row.task_id || "—"} + + + {row.agent_name || row.conv_agent_key} + +
+
+ + + + + {JSON.stringify(row.tool_input)} + + + + + + {fmtTime(row.created_at)} + + + {fmtTime(row.decided_at)} + +
+ {open ? ( + + +
+ +
+
+
+ ) : null} +
+ ); + })} +
+
+ ); +} + +export function ApprovalRecords({ taskId }: { taskId?: string }) { + const [rows, setRows] = React.useState([]); + 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 ( +
+
+
+ +

{title}

+ {pending.length ? {pending.length} 待审批 : null} +
+ +
+

展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。

+ {error ? ( + + 记录加载失败:{error}。请点击刷新重试。 + + ) : null} + {pending.length ? ( +
+
+ + 待处理({pending.length})展开后允许或拒绝 +
+ +
+ ) : null} +
+
+ {taskId ? "全部记录" : "最近记录"}({rows.length}) +
+ {loading ? ( +
+ + + +
+ ) : null} + {!loading && !rows.length && !error ? ( + + + + + + 暂无审批记录 + 规则或模型作出审批决定后,记录将显示在这里。 + + + ) : null} + {rows.length ? ( + + ) : null} +
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 87eac85..bf8ebca 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -38,6 +38,7 @@ import type { FindingsPage, IntentAsset, InterceptApprovalRow, + InterceptDetail, InterceptPending, InterceptRule, JudgeConfig, @@ -957,6 +958,7 @@ export const api = { interceptGetOne: (id: number) => get(`/intercept/pending/${id}`), interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), + interceptDetail: (id: number) => get(`/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)), diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index be0e186..3cc86b5 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -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 = { + 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[] = [ { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 3eaab34..f3fd597 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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 { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index fdfffd5..e326530 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; +}