diff --git a/intercept/prompt.go b/intercept/prompt.go index 342597a..e7390ef 100644 --- a/intercept/prompt.go +++ b/intercept/prompt.go @@ -136,11 +136,34 @@ type Verdict struct { Reason string } +// stripCodeFence unwraps a fenced reply (```json … ```) before strict parsing. +// This is a deterministic unwrap, not a repair: the payload still goes through +// ParseVerdict unchanged, so truncated, ambiguous or prose replies stay +// unparseable. A reply cut off at MaxTokens has no closing fence and is left +// alone on purpose — completing it would invent a verdict the model never gave. +// +// It exists because the fail action defaults to allow: without it a model that +// merely wraps its JSON in markdown turns a DENY into a silent allow. +func stripCodeFence(text string) string { + t := strings.TrimSpace(text) + if len(t) <= 6 || !strings.HasPrefix(t, "```") || !strings.HasSuffix(t, "```") { + return t + } + t = strings.TrimSpace(t[3 : len(t)-3]) + if !strings.HasPrefix(t, "{") { + // Drop the opening fence's language tag line (```json). + if _, rest, ok := strings.Cut(t, "\n"); ok { + t = strings.TrimSpace(rest) + } + } + return t +} + // ParseVerdict requires a complete verdict and explanation for every action. // Never extract a decision keyword from prose, arguments, or a broken JSON // reply. Invalid/incomplete responses follow the configured model-failure path. func ParseVerdict(text string) Verdict { - d := json.NewDecoder(strings.NewReader(text)) + d := json.NewDecoder(strings.NewReader(stripCodeFence(text))) if tok, err := d.Token(); err != nil || tok != json.Delim('{') { return Verdict{} } diff --git a/intercept/prompt_test.go b/intercept/prompt_test.go index 28cf0a2..562a56e 100644 --- a/intercept/prompt_test.go +++ b/intercept/prompt_test.go @@ -31,7 +31,12 @@ func TestParseVerdictRejectsIncompleteOrAmbiguousReplies(t *testing.T) { strings.Replace(valid, ";命中规则:A5", "", 1), strings.Replace(valid, `"decision":"allow"`, `"decision":"deny","decision":"allow"`, 1), strings.Replace(valid, `"decision":"allow"`, `"extra":true,"decision":"allow"`, 1), - valid + valid, "```json\n" + valid + "\n```", valid[:len(valid)-1], + valid + valid, valid[:len(valid)-1], + // A fence the model never closed is what a reply truncated at MaxTokens + // looks like; completing it would invent a verdict. + "```json\n" + valid[:len(valid)-1], + "```json\n" + valid + "\n```\n此外我建议后续人工复核。", + "我的裁决是:\n" + valid, } { if got := ParseVerdict(reply); got.Action != "" { t.Errorf("accepted incomplete/ambiguous verdict: %q => %+v", reply, got) @@ -39,6 +44,24 @@ func TestParseVerdictRejectsIncompleteOrAmbiguousReplies(t *testing.T) { } } +// Wrapping JSON in markdown is the one deviation models make routinely. Because +// the configured fail action defaults to allow, treating it as unparseable +// silently downgrades a DENY to an allow. +func TestParseVerdictUnwrapsCodeFence(t *testing.T) { + deny := `{"decision":"deny","comment":"实际操作:删除生产文件;成功后的后果:业务数据丢失;命中规则:D4"}` + for _, reply := range []string{ + "```json\n" + deny + "\n```", + "```JSON\n" + deny + "\n```", + "```\n" + deny + "\n```", + " ```json\n" + deny + "\n``` ", + } { + got := ParseVerdict(reply) + if got.Action != "deny" || !strings.HasSuffix(got.Reason, "命中规则:D4") { + t.Errorf("fenced verdict lost: %q => %+v", reply, got) + } + } +} + func TestParseVerdictKeepsCompleteChineseExplanation(t *testing.T) { reason := "实际操作:" + strings.Repeat("写入报告", 30) + ";成功后的后果:只保存文件;命中规则:A2" raw, _ := json.Marshal(map[string]string{"decision": "allow", "comment": reason})