Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions guard/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`)
Expand Down
62 changes: 62 additions & 0 deletions guard/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"

"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
Expand Down Expand Up @@ -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
Loading