mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #6 from RuoJi6/codex/fix-command-review-snapshot
修复命令误判、空白布局并支持脚本审查策略配置
This commit is contained in:
14 files changed
+209
-47
No files matched your search
+6
-3
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. 漏洞发现持久化
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+32
-22
@@ -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":
|
||||
@@ -685,7 +695,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
|
||||
}
|
||||
|
||||
@@ -130,6 +130,78 @@ 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 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")
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="脚本快照不完整时">
|
||||
<Select
|
||||
value={cfg.incomplete_script_action}
|
||||
onValueChange={(v) => patch({ incomplete_script_action: v as JudgeConfig["incomplete_script_action"] })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">转人工审查(默认)</SelectItem>
|
||||
<SelectItem value="model">继续交给模型审查</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -206,7 +209,7 @@ function ScriptSnapshotSummary({ input }: { input: InterceptReviewInput }) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm">审查脚本快照</h3>
|
||||
<Badge variant={input.script_capture_status === "incomplete" ? "destructive" : "secondary"}>
|
||||
{input.script_capture_status === "incomplete" ? "读取不完整 · 需人工确认" : "已附加"}
|
||||
{input.script_capture_status === "incomplete" ? "读取不完整" : "已附加"}
|
||||
</Badge>
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
@@ -308,7 +311,7 @@ function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
|
||||
))}
|
||||
{input.script_capture_status === "incomplete" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>无法完整读取当前执行脚本,平台要求人工确认。</AlertDescription>
|
||||
<AlertDescription>无法完整读取当前执行脚本;默认转人工审查,也可以在模型设置中继续交给审查模型。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
@@ -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 (
|
||||
@@ -439,7 +442,7 @@ export function ApprovalDetail({
|
||||
"为什么这次被放行或拦截?",
|
||||
"脚本中有哪些未确定行为?",
|
||||
]}
|
||||
className="min-h-[460px]"
|
||||
className={cn(side.open ? "min-h-[460px]" : "min-h-0")}
|
||||
mainClassName="overflow-visible"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) {
|
||||
@@ -2513,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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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 天每日序列。
|
||||
@@ -1385,7 +1386,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;
|
||||
|
||||
Reference in new issue
Block a user