diff --git a/db/intercept.go b/db/intercept.go index fb7142b..cfb0416 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -227,9 +227,12 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept } // Keep legacy rows without decision_source consistent with their displayed source. -const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE - WHEN ip.rule_id IS NOT NULL THEN 'rule' - WHEN ip.reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END)` +const approvalDecisionSource = `CASE + WHEN ip.audit->>'decision_reason' LIKE '人工允许%' OR ip.audit->>'decision_reason' LIKE '人工拒绝%' THEN 'manual' + ELSE COALESCE(NULLIF(ip.decision_source,''), CASE + WHEN ip.rule_id IS NOT NULL THEN 'rule' + WHEN ip.reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END) + END` 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, ` + approvalDecisionSource + `, diff --git a/db/intercept_detail.go b/db/intercept_detail.go index 9373f4a..2b43777 100644 --- a/db/intercept_detail.go +++ b/db/intercept_detail.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "encoding/json" + "fmt" "time" ) @@ -71,9 +72,19 @@ func (d *DB) GetInterceptDetail(id int64) (*InterceptDetail, error) { 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) { +// ResolveIntercept atomically settles a pending request. An optional source is +// persisted with the final decision; the HTTP approval path passes "manual", +// while timeout/cancellation callers leave the original rule/model source. +// A timeout cannot overwrite a human decision and repeat decisions cannot +// rewrite history. +func (d *DB) ResolveIntercept(id int64, status, action, reason string, source ...string) (bool, error) { + decisionSource := "" + if len(source) > 0 { + decisionSource = source[0] + } + if decisionSource != "" && decisionSource != "manual" { + return false, fmt.Errorf("无效审批来源 %q", decisionSource) + } execution := "not_executed" if action == "allow" { execution = "awaiting_result" @@ -85,10 +96,11 @@ func (d *DB) ResolveIntercept(id int64, status, action, reason string) (bool, er return false, err } r, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW(), + decision_source=CASE WHEN $5 <> '' THEN $5 ELSE decision_source END, 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) + WHERE id=$1 AND status='pending'`, id, status, patch, action, decisionSource) if err != nil { return false, err } diff --git a/db/intercept_detail_test.go b/db/intercept_detail_test.go index 934e38c..e44873b 100644 --- a/db/intercept_detail_test.go +++ b/db/intercept_detail_test.go @@ -49,7 +49,7 @@ func TestInterceptDetails(t *testing.T) { var wg sync.WaitGroup for range 8 { wg.Go(func() { - ok, err := d.ResolveIntercept(id, "allowed", "allow", "人工允许执行") + ok, err := d.ResolveIntercept(id, "allowed", "allow", "人工允许执行", "manual") if err != nil { t.Error(err) } @@ -70,8 +70,15 @@ func TestInterceptDetails(t *testing.T) { t.Fatal(err) } got, _ := d.GetInterceptDetail(id) - if got.Audit.Output != "" { - t.Fatal("cross-run result attached") + if got.Audit.Output != "" || got.DecisionSource != "manual" { + t.Fatalf("manual decision source or cross-run result wrong: %+v", got) + } + if _, err := d.Exec(`UPDATE intercept_pending SET decision_source='' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + fallback, err := d.GetInterceptDetail(id) + if err != nil || fallback.DecisionSource != "manual" { + t.Fatalf("legacy manual source fallback lost: %+v %v", fallback, err) } if err := d.CompleteIntercept(id, "run", "call", "failed", "permission denied", true); err != nil { t.Fatal(err) diff --git a/db/schema.sql b/db/schema.sql index 4a416e5..a6cad75 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1120,6 +1120,10 @@ 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=''; +-- 审批请求的初判来源可能是规则/模型,但最终由用户允许或拒绝;保留最终人工判定来源。 +UPDATE intercept_pending SET decision_source='manual' + WHERE decision_source <> 'manual' + AND (audit->>'decision_reason' LIKE '人工允许%' OR audit->>'decision_reason' LIKE '人工拒绝%'); -- ===================================================================== -- L. 漏洞发现持久化 diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 172e52c..aa23ed6 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -675,10 +675,18 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error { // Archives predating approval snapshots lack this NOT NULL column. if source, _ := row["decision_source"].(string); source == "" { source = "unknown" - if row["rule_id"] != nil { + if audit, ok := row["audit"].(map[string]any); ok { + if reason, _ := audit["decision_reason"].(string); strings.HasPrefix(reason, "人工允许") || strings.HasPrefix(reason, "人工拒绝") { + source = "manual" + } + } + if source == "unknown" && row["rule_id"] != nil { source = "rule" - } else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[模型]") { - source = "model" + } else if source == "unknown" { + reason, _ := row["reason"].(string) + if strings.HasPrefix(reason, "[模型]") { + source = "model" + } } row["decision_source"] = source } diff --git a/intercept/intercept.go b/intercept/intercept.go index 7499bbd..cdeff9c 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -685,7 +685,7 @@ func (i *Interceptor) Decide(pendingID int64, allowed bool) error { if allowed { action, reason = "allow", "人工允许执行" } - resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason) + resolved, err := i.db.ResolveIntercept(pendingID, status, action, reason, "manual") if err != nil { return err } diff --git a/intercept/review_context_test.go b/intercept/review_context_test.go index a6e4e38..01da246 100644 --- a/intercept/review_context_test.go +++ b/intercept/review_context_test.go @@ -130,6 +130,55 @@ func TestReviewInputCapturesCompleteScript(t *testing.T) { } } +func TestReviewInputDoesNotTreatShellTestAsScript(t *testing.T) { + command := `echo "PWD=$(pwd)"; ls -la /app/data/sessions/conv-473 2>&1 | head -50; if [ -n "${TOKEN:-}" ]; then echo "TOKEN is set"; else echo "TOKEN not set"; fi; env | grep -iE 'token|proxy|traffic|target|10\\.211' | sed 's/=.*/=/'` + args, _ := json.Marshal(map[string]string{"command": command}) + in, err := BuildReviewInput(WithReviewWorkingDirectory(t.Context(), "/tmp"), "Bash", args) + if err != nil { + t.Fatal(err) + } + if in.ScriptCaptureStatus != "not_applicable" || len(in.Scripts) != 0 { + t.Fatalf("plain shell command was misclassified as script: status=%q scripts=%+v", in.ScriptCaptureStatus, in.Scripts) + } +} + +func TestReviewInputShellCommandCombinations(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "check.py"), []byte("print('ok')\n"), 0o600); err != nil { + t.Fatal(err) + } + cases := []struct { + name, command, status string + }{ + {name: "plain", command: "pwd", status: "not_applicable"}, + {name: "pipeline", command: "env | grep TOKEN | sed 's/=.*/redacted/'", status: "not_applicable"}, + {name: "substitution", command: "echo $(pwd)", status: "not_applicable"}, + {name: "conditional", command: `if [ -n "${TOKEN:-}" ]; then echo set; fi`, status: "not_applicable"}, + {name: "shell_inline", command: `bash -c 'echo safe; printf ok'`, status: "not_applicable"}, + {name: "python_inline", command: `python3 -c 'print(1)'`, status: "not_applicable"}, + {name: "stdin_script", command: "python3 -", status: "incomplete"}, + {name: "source_missing", command: ". ./missing.sh", status: "incomplete"}, + {name: "wrapped_script", command: "timeout 5 env MODE=test bash ./missing.sh", status: "incomplete"}, + {name: "dynamic_entry", command: `${RUNNER} ./check.py`, status: "incomplete"}, + {name: "redirect", command: "echo safe > output.txt", status: "not_applicable"}, + {name: "script", command: "python3 ./check.py", status: "complete"}, + {name: "missing_script", command: "bash ./missing.sh", status: "incomplete"}, + {name: "dynamic_script", command: `python3 "$SCRIPT_PATH"`, status: "incomplete"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args, _ := json.Marshal(map[string]string{"command": tc.command}) + in, err := BuildReviewInput(WithReviewWorkingDirectory(t.Context(), dir), "Bash", args) + if err != nil { + t.Fatal(err) + } + if in.ScriptCaptureStatus != tc.status { + t.Fatalf("status=%q want %q scripts=%+v", in.ScriptCaptureStatus, tc.status, in.Scripts) + } + }) + } +} + func TestReviewInputResolvesCdAndSourceScript(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "login.sh") diff --git a/intercept/scripts.go b/intercept/scripts.go index 0459713..d15119f 100644 --- a/intercept/scripts.go +++ b/intercept/scripts.go @@ -30,6 +30,21 @@ func staticWord(w *syntax.Word) (string, bool) { } return staticParts(w.Parts) } + +// staticCommandName is like staticWord, but also accepts the POSIX test +// builtin `[` as a command name. `[` is a valid executable name, while the +// wildcard guard in staticParts intentionally rejects brackets in paths. +func staticCommandName(w *syntax.Word) (string, bool) { + if value, ok := staticWord(w); ok { + return value, true + } + if w != nil && len(w.Parts) == 1 { + if lit, ok := w.Parts[0].(*syntax.Lit); ok && (lit.Value == "[" || lit.Value == "[[") { + return lit.Value, true + } + } + return "", false +} func staticParts(parts []syntax.WordPart) (string, bool) { var b strings.Builder for _, part := range parts { @@ -105,7 +120,7 @@ func scriptReferences(command, dir string, depth int) []scriptReference { if len(args) == 0 { return true } - name, ok := staticWord(args[0]) + name, ok := staticCommandName(args[0]) if !ok { refs = append(refs, scriptReference{problem: "动态命令入口,无法确定是否执行脚本"}) return true @@ -123,7 +138,7 @@ func scriptReferences(command, dir string, depth int) []scriptReference { } args = args[1:] } - name, ok = staticWord(args[0]) + name, ok = staticCommandName(args[0]) if !ok { refs = append(refs, scriptReference{problem: "包装命令的执行入口不确定"}) return true diff --git a/server/intercept.go b/server/intercept.go index be1ce8d..809eac2 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -356,9 +356,9 @@ func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) { return filter, fmt.Errorf("status 必须是 pending、allowed、denied 或 timeout") } switch filter.DecisionSource { - case "", "model", "rule", "unknown": + case "", "model", "rule", "manual", "unknown": default: - return filter, fmt.Errorf("decision_source 必须是 model、rule 或 unknown") + return filter, fmt.Errorf("decision_source 必须是 model、rule、manual 或 unknown") } return filter, nil } diff --git a/server/intercept_filter_test.go b/server/intercept_filter_test.go index c9904db..4f6774f 100644 --- a/server/intercept_filter_test.go +++ b/server/intercept_filter_test.go @@ -30,6 +30,9 @@ func TestInterceptSearchFilterParams(t *testing.T) { if err != nil || filter.CreatedFrom != "2026-10-01" || filter.CreatedTo != "2026-10-12" { t.Fatalf("date filter=%+v err=%v", filter, err) } + if filter, err := interceptFilterParams(url.Values{"decision_source": []string{"manual"}}); err != nil || filter.DecisionSource != "manual" { + t.Fatalf("manual decision source rejected: %+v %v", filter, err) + } for _, values := range []url.Values{ {"from": []string{"2026-10-1"}}, {"to": []string{"not-a-date"}}, diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index 6c92471..ef613f3 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -116,13 +116,16 @@ function StatusBadge({ status }: { status: string }) { function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) { const reason = row.reason?.replace(/^\[模型\]\s*/, ""); + const decisionSource = source(row); return (
- {source(row) === "model" ? ( + {decisionSource === "model" ? ( 模型判定 + ) : decisionSource === "manual" ? ( + 人工判定 ) : ( {row.rule_name || "规则未记录或已删除"} )} @@ -424,7 +427,7 @@ export function ApprovalDetail({ 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" ? "模型初判" : "规则初判"; + let initialLabel = source(row) === "model" ? "模型初判" : source(row) === "manual" ? "人工判定" : "规则初判"; if (audit?.model_fallback) initialLabel = "模型异常回退"; return ( @@ -1027,6 +1030,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { 全部来源 模型判定 规则判定 + 人工判定 来源未知 diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 7743972..90a9241 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2441,6 +2441,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (row?.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录"); if (b.decision !== "allowed" && b.decision !== "denied") throw new Error("无效审批动作"); row.status = b.decision; + row.decision_source = "manual"; row.decided_at = new Date().toISOString(); const detail = mockInterceptDetails[id]; if (detail) { @@ -2465,7 +2466,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const from = q.get("from") || ""; const to = q.get("to") || ""; if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态"); - if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源"); + if (decisionSource && !["model", "rule", "manual", "unknown"].includes(decisionSource)) throw new Error("无效判定来源"); if ([...search].length > 200) throw new Error("搜索关键词不能超过 200 个字符"); if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数"); for (const value of [from, to]) { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 596a1b6..365d779 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1341,7 +1341,7 @@ export interface AssetInterceptRule { } export interface InterceptPending { - decision_source?: "rule" | "model" | "unknown" | ""; + decision_source?: "rule" | "model" | "manual" | "unknown" | ""; id: number; rule_id?: number; conversation_id?: number; @@ -1385,7 +1385,7 @@ export interface JudgeUsage { export interface InterceptApprovalFilter { status?: InterceptPending["status"]; - decision_source?: "rule" | "model" | "unknown"; + decision_source?: "rule" | "model" | "manual" | "unknown"; q?: string; from?: string; to?: string;