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] 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() {
+