From 97213e357b6b49edb3a5c760c235658e2ca30981 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:31:33 +0800 Subject: [PATCH 1/3] fix: avoid false script snapshot reviews --- db/intercept.go | 9 +++-- db/intercept_detail.go | 20 ++++++++-- db/intercept_detail_test.go | 13 +++++-- db/schema.sql | 4 ++ db/task_archives_restore.go | 14 +++++-- intercept/intercept.go | 2 +- intercept/review_context_test.go | 49 +++++++++++++++++++++++++ intercept/scripts.go | 19 +++++++++- server/intercept.go | 4 +- server/intercept_filter_test.go | 3 ++ web/src/components/approval-records.tsx | 8 +++- web/src/lib/mock/handler.ts | 3 +- web/src/lib/types.ts | 4 +- 13 files changed, 129 insertions(+), 23 deletions(-) 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; From dc72b5b4a9e834ed3d2d357f9175af863af80931 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:01:49 +0800 Subject: [PATCH 2/3] feat: configure incomplete script review path --- intercept/intercept.go | 52 ++++++++++++-------- intercept/review_context_test.go | 23 +++++++++ server/intercept.go | 8 +++ web/src/app/(main)/system/intercept/page.tsx | 13 +++++ web/src/components/approval-records.tsx | 4 +- web/src/lib/mock/handler.ts | 1 + web/src/lib/types.ts | 1 + 7 files changed, 79 insertions(+), 23 deletions(-) diff --git a/intercept/intercept.go b/intercept/intercept.go index cdeff9c..a7b6a27 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -308,6 +308,7 @@ const ( settingJudgeFailAction = "llm_judge_fail_action" settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds" settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action" + settingJudgeIncompleteScript = "llm_judge_incomplete_script_action" ) // Judge default values. @@ -321,13 +322,14 @@ const ( // JudgeConfig is the resolved LLM-fallback-judge configuration. Prompt is always // non-empty (falls back to DefaultJudgePrompt). type JudgeConfig struct { - Enabled bool `json:"enabled"` - ProfileID int64 `json:"profile_id"` // 0 = follow active/default - Prompt string `json:"prompt"` - TimeoutSeconds int `json:"timeout_seconds"` - FailAction string `json:"fail_action"` // allow|ask|deny - AskTimeoutSeconds int `json:"ask_timeout_seconds"` - AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny + Enabled bool `json:"enabled"` + ProfileID int64 `json:"profile_id"` // 0 = follow active/default + Prompt string `json:"prompt"` + TimeoutSeconds int `json:"timeout_seconds"` + FailAction string `json:"fail_action"` // allow|ask|deny + AskTimeoutSeconds int `json:"ask_timeout_seconds"` + AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny + IncompleteScriptAction string `json:"incomplete_script_action"` // ask|model } // judgeConfig reads the judge configuration from settings, applying defaults for @@ -335,12 +337,13 @@ type JudgeConfig struct { // follows dwarfs a few KV reads, and freshness avoids a cache-invalidation path. func (i *Interceptor) judgeConfig() JudgeConfig { c := JudgeConfig{ - Enabled: i.db.GetBool(settingJudgeEnabled, false), - ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)), - TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs), - FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"), - AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs), - AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"), + Enabled: i.db.GetBool(settingJudgeEnabled, false), + ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)), + TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs), + FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"), + AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs), + AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"), + IncompleteScriptAction: i.getSettingChoice(settingJudgeIncompleteScript, "ask", "ask", "model"), } // Prompt: stored value if non-empty, else the built-in template. if v, ok, _ := i.db.GetSetting(settingJudgePrompt); ok && strings.TrimSpace(v) != "" { @@ -417,6 +420,9 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error { if err := i.db.SetSetting(settingJudgeAskTimeoutAction, c.AskTimeoutAction); err != nil { return err } + if err := i.db.SetSetting(settingJudgeIncompleteScript, c.IncompleteScriptAction); err != nil { + return err + } return nil } @@ -458,14 +464,10 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw if err != nil { out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()} } - // A script invocation whose source could not be captured is never safe to - // auto-allow: the model must not infer that an opaque or partial script has - // no side effects. Preserve an explicit deny, but convert an allow (including - // a configured fail-open response) into human review. - if input.ScriptCaptureStatus == "incomplete" && out.Action == "allow" { - out.Action = "ask" - out.Message = "脚本未能完整附加,无法排除隐藏副作用,需要人工确认" - } + // By default an incomplete script snapshot requires human review. Operators + // may explicitly choose model review; in that mode the model receives the + // read errors/partial snapshot and its verdict is honored. + out = applyIncompleteScriptPolicy(input, out, cfg.IncompleteScriptAction) switch out.Action { case "allow", "ask", "deny": // valid verdict @@ -497,6 +499,14 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw return out, true } +func applyIncompleteScriptPolicy(input ReviewInput, out Decision, policy string) Decision { + if input.ScriptCaptureStatus == "incomplete" && policy != "model" && out.Action == "allow" { + out.Action = "ask" + out.Message = "脚本未能完整附加,无法排除隐藏副作用,需要人工确认" + } + return out +} + func judgeActionLabel(action string) string { switch action { case "allow": diff --git a/intercept/review_context_test.go b/intercept/review_context_test.go index 01da246..fc4833c 100644 --- a/intercept/review_context_test.go +++ b/intercept/review_context_test.go @@ -179,6 +179,29 @@ func TestReviewInputShellCommandCombinations(t *testing.T) { } } +func TestIncompleteScriptPolicy(t *testing.T) { + incomplete := ReviewInput{ScriptCaptureStatus: "incomplete"} + for _, tc := range []struct { + name, policy, action, wantAction string + }{ + {name: "default human review", policy: "ask", action: "allow", wantAction: "ask"}, + {name: "model review", policy: "model", action: "allow", wantAction: "allow"}, + {name: "model deny remains deny", policy: "model", action: "deny", wantAction: "deny"}, + {name: "complete allow remains allow", policy: "ask", action: "allow", wantAction: "allow"}, + } { + t.Run(tc.name, func(t *testing.T) { + input := incomplete + if tc.name == "complete allow remains allow" { + input.ScriptCaptureStatus = "complete" + } + got := applyIncompleteScriptPolicy(input, Decision{Action: tc.action}, tc.policy) + if got.Action != tc.wantAction { + t.Fatalf("action=%q want %q", got.Action, tc.wantAction) + } + }) + } +} + func TestReviewInputResolvesCdAndSourceScript(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "login.sh") diff --git a/server/intercept.go b/server/intercept.go index 809eac2..d5fc6af 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -466,6 +466,14 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request) writeErr(w, 400, "ask_timeout_action 必须是 allow 或 deny") return } + switch req.IncompleteScriptAction { + case "": + req.IncompleteScriptAction = "ask" + case "ask", "model": + default: + writeErr(w, 400, "incomplete_script_action 必须是 ask 或 model") + return + } if err := s.m.interceptor.SetJudgeConfig(req); err != nil { writeErr(w, 500, err.Error()) return diff --git a/web/src/app/(main)/system/intercept/page.tsx b/web/src/app/(main)/system/intercept/page.tsx index 3075472..dc0d1d6 100644 --- a/web/src/app/(main)/system/intercept/page.tsx +++ b/web/src/app/(main)/system/intercept/page.tsx @@ -203,6 +203,7 @@ const defaultJudge = (): JudgeConfig => ({ fail_action: "allow", ask_timeout_seconds: 300, ask_timeout_action: "deny", + incomplete_script_action: "ask", }); function JudgeCard() { @@ -396,6 +397,18 @@ function JudgeCard() { + + +
diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index ef613f3..e44f9cd 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -209,7 +209,7 @@ function ScriptSnapshotSummary({ input }: { input: InterceptReviewInput }) {

审查脚本快照

- {input.script_capture_status === "incomplete" ? "读取不完整 · 需人工确认" : "已附加"} + {input.script_capture_status === "incomplete" ? "读取不完整" : "已附加"}
{files.map((file) => ( @@ -311,7 +311,7 @@ function ModelReviewContext({ input }: { input: InterceptReviewInput }) { ))} {input.script_capture_status === "incomplete" ? ( - 无法完整读取当前执行脚本,平台要求人工确认。 + 无法完整读取当前执行脚本;默认转人工审查,也可以在模型设置中继续交给审查模型。 ) : null} diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 90a9241..4f359bb 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2514,6 +2514,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re fail_action: "allow", ask_timeout_seconds: 300, ask_timeout_action: "deny", + incomplete_script_action: "ask", }; if (path === "/intercept/judge" && m === "PUT") return { ok: true }; if (path === "/intercept/judge/usage" && m === "GET") diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 365d779..7cc09fa 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1365,6 +1365,7 @@ export interface JudgeConfig { fail_action: "allow" | "ask" | "deny"; // 模型出错/超时/不可解析时的回退 ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时 ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作 + incomplete_script_action: "ask" | "model"; // 脚本快照不完整时:人工审查或继续模型审查 } // JudgeUsage: 模型兜底审批(judge 通道)的累计 token 用量 + 近 N 天每日序列。 From 139e997ba8be5b081232ec374def913d34c70a4a Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:58:40 +0800 Subject: [PATCH 3/3] fix: remove empty approval detail space --- web/src/components/approval-records.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index e44f9cd..0ae8c94 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -442,7 +442,7 @@ export function ApprovalDetail({ "为什么这次被放行或拦截?", "脚本中有哪些未确定行为?", ]} - className="min-h-[460px]" + className={cn(side.open ? "min-h-[460px]" : "min-h-0")} mainClassName="overflow-visible" >