diff --git a/guard/guard.go b/guard/guard.go index e8152687..42aaa490 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -252,7 +252,7 @@ var targetGuidance = map[string]string{ // always final (an approval can still arrive, a lookup can succeed later), so // it does not end with "This operation is forbidden." func TargetBlockMessage(res intercept.TargetResult) string { - reason := res.Reason + reason := capBlockReason(res.Reason) if res.Host != "" && !strings.Contains(reason, res.Host) { target := res.Host if res.Port != 0 { @@ -277,7 +277,27 @@ func TargetBlockMessage(res intercept.TargetResult) string { // model-facing tool_result carries this framing. func systemBlockMessage(reason string) string { return "[ARTEX platform control, not target defense] This call was intercepted by the platform. " + - "Reason: " + reason + ". This operation is forbidden." + "Reason: " + capBlockReason(reason) + ". This operation is forbidden." +} + +// maxBlockReasonRunes bounds the reason a block message carries back to the +// model. Rule messages are operator text and judge or target reasons can embed +// command input, so a reason has no natural bound; 4000 runes keeps every +// realistic reason whole while one oversized reason cannot flood the context. +// Only the model-facing text is capped: Interceptor.Log stores the raw reason. +const maxBlockReasonRunes = 4000 + +// capBlockReason returns s unchanged when it has at most maxBlockReasonRunes +// runes, and otherwise its first maxBlockReasonRunes runes and a marker. +func capBlockReason(s string) string { + n := 0 + for i := range s { + if n == maxBlockReasonRunes { + return s[:i] + " …(truncated)" + } + n++ + } + return s } var reBlocked = regexp.MustCompile(`(?i)\b(403|forbidden|waf|blocked|rate.?limit|429|captcha|denied)\b`) diff --git a/guard/guard_test.go b/guard/guard_test.go index 03f2b2c1..f4d58014 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" @@ -150,4 +151,65 @@ func TestTargetOnlyGuardAppliesBuiltinDenyRules(t *testing.T) { } } +// A deny rule with an oversized message reaches the model capped, with the +// platform framing and the guidance intact. +func TestDenyRuleReasonIsCapped(t *testing.T) { + ic := intercept.New(nil) + ic.SetRulesForTest([]db.InterceptRule{ + {ID: 1, Name: "Huge message", Enabled: true, Priority: 50, + MatchTarget: "tool_input", MatchType: "string", Action: "deny", + Pattern: "whoami", Message: strings.Repeat("日", 20000)}, + }) + block, msg := pre(NewWithInterceptor(ic), context.Background(), "Bash", `{"command":"whoami"}`) + if !block { + t.Fatal("the deny rule did not block") + } + limit := maxBlockReasonRunes + utf8.RuneCountInString(systemBlockMessage("")+" …(truncated)") + if n := utf8.RuneCountInString(msg); n > limit { + t.Fatalf("message has %d runes, want at most %d", n, limit) + } + if !utf8.ValidString(msg) || + !strings.HasPrefix(msg, "[ARTEX platform control, not target defense] This call was intercepted by the platform. Reason: ") || + !strings.HasSuffix(msg, strings.Repeat("日", maxBlockReasonRunes)+" …(truncated). This operation is forbidden.") { + t.Errorf("capped message lost its framing or guidance: %q...%q", msg[:120], msg[len(msg)-120:]) + } +} + +// A target block with an oversized reason is capped the same way, and keeps the +// target and the guidance of its code. +func TestTargetBlockReasonIsCapped(t *testing.T) { + res := intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, + Reason: strings.Repeat("é", 20000)} + msg := TargetBlockMessage(res) + empty := res + empty.Reason = "" + limit := maxBlockReasonRunes + utf8.RuneCountInString(TargetBlockMessage(empty)+" …(truncated)") + if n := utf8.RuneCountInString(msg); n > limit { + t.Fatalf("message has %d runes, want at most %d", n, limit) + } + if !utf8.ValidString(msg) || + !strings.HasPrefix(msg, "[ARTEX platform control, not target defense] TARGET_DENIED: target app.example.com:443: ") || + !strings.HasSuffix(msg, strings.Repeat("é", maxBlockReasonRunes)+" …(truncated). "+targetGuidance[intercept.TargetDenied]) { + t.Errorf("capped message lost its target or guidance: %q...%q", msg[:120], msg[len(msg)-120:]) + } +} + +// A reason within the cap passes through byte for byte. +func TestShortBlockReasonUnchanged(t *testing.T) { + for _, s := range []string{"", "Destructive command rejected", strings.Repeat("日", maxBlockReasonRunes)} { + if got := capBlockReason(s); got != s { + t.Errorf("capBlockReason changed a reason of %d runes", utf8.RuneCountInString(s)) + } + } + if got, want := systemBlockMessage("Destructive command rejected"), + "[ARTEX platform control, not target defense] This call was intercepted by the platform. Reason: Destructive command rejected. This operation is forbidden."; got != want { + t.Errorf("systemBlockMessage = %q, want %q", got, want) + } + res := intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, Reason: "the request was denied"} + if got, want := TargetBlockMessage(res), + "[ARTEX platform control, not target defense] TARGET_DENIED: target app.example.com:443: the request was denied. "+targetGuidance[intercept.TargetDenied]; got != want { + t.Errorf("TargetBlockMessage = %q, want %q", got, want) + } +} + var _ = hook.PreToolUse