fix: avoid false script snapshot reviews

This commit is contained in:
ji ruo committed 2026-10-12 17:31:33 +08:00
1 parent 648bafb593
commit 97213e357b
13 files changed
+129 -23

No files matched your search

+6 -3
View File
@@ -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 + `,
+16 -4
View File
@@ -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
}
+10 -3
View File
@@ -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)
+4
View File
@@ -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. 漏洞发现持久化
+11 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+49
View File
@@ -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/=.*/=<redacted>/'`
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")
+17 -2
View File
@@ -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
+2 -2
View File
@@ -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
}
+3
View File
@@ -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"}},
+6 -2
View File
@@ -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 (
<div className="flex min-w-0 flex-col gap-1">
{source(row) === "model" ? (
{decisionSource === "model" ? (
<Badge variant="outline">
<BotIcon />
模型判定
</Badge>
) : decisionSource === "manual" ? (
<Badge variant="outline">人工判定</Badge>
) : (
<span className="truncate">{row.rule_name || "规则未记录或已删除"}</span>
)}
@@ -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 }) {
<SelectItem value="all">全部来源</SelectItem>
<SelectItem value="model">模型判定</SelectItem>
<SelectItem value="rule">规则判定</SelectItem>
<SelectItem value="manual">人工判定</SelectItem>
<SelectItem value="unknown">来源未知</SelectItem>
</SelectGroup>
</SelectContent>
+2 -1
View File
@@ -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]) {
+2 -2
View File
@@ -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;