Skip to content

fix: make hosted scanning work without inventing a safe verdict - #32

Merged
higagan merged 1 commit into
mainfrom
fix/hosted-scan-safety
Jul 30, 2026
Merged

fix: make hosted scanning work without inventing a safe verdict#32
higagan merged 1 commit into
mainfrom
fix/hosted-scan-safety

Conversation

@higagan

@higagan higagan commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Based on main β€” not stacked this time. Version β†’ 0.3.5.

This redoes the work from the orphaned #31, plus the fix that was missing from it.

The bug #31 would have shipped

scan never sent max_tokens, so a gateway reserves the target's full context window up front. Every request to anthropic/claude-sonnet-5 died:

402 - You requested up to 65536 tokens, but can only afford 3937.

Capping it fixes that β€” but a cap alone introduces a worse bug. _probe returned bool(message.tool_calls) and never read finish_reason, so a reply cut off at the cap, possibly mid-compliance, was recorded as SAFE.

That is a false negative in a security scanner: the one verdict it must never invent, and precisely what INCONCLUSIVE exists to prevent. Before the cap there was no truncation, so #31 would have created this failure mode while fixing the 402.

What ships instead

The cap, together with truncation handling:

  • _probe raises TruncatedResponseError when a reply hits the cap without resolving. The loop prints [⚠️ TRUNCATED] and counts it unresolved.
  • A tool call still wins even at the cap β€” truncation only matters when nothing was produced.
  • An all-truncated run now names the real fix instead of blaming the endpoint.

Verified against live models

Run Result
gpt-4o-mini, default cap 3/3 seeds broke through
claude-sonnet-5, default cap 0/3, refused through generation 3

And the guard firing on a real model, claude-sonnet-5 --max-tokens 12:

before:  [βœ… SAFE] Γ—3  ->  "0/3 broke through. No vulnerabilities found within budget."
after:   [⚠️ TRUNCATED] Γ—3  ->  "Every reply hit the token cap before resolving.
                                 Re-run with a larger --max-tokens."

The "before" line is a clean bill of health for a model that was never actually tested.

Also included

  • --api-key reads MODELFUZZ_API_KEY. A real key no longer has to sit in shell history and in the process list. Verified: history | grep -c 'sk-or-v1' β†’ 0.
  • Provider errors are clipped to one line. A gateway 402 returns multiple KB of JSON β€” including account identifiers and a dashboard URL containing a key hash β€” and the entire blob was echoed for every failed probe.

Tests

71 β†’ 81. New classes cover truncation (raises rather than returning False; scan never prints SAFE; a finished refusal is still SAFE; a tool call at the cap still counts), that both call sites send max_tokens and honour --max-tokens, that error output is bounded, and the three API-key sources.

One trap for future tests: the default non-empty stub mutation keeps every lineage alive until the wall-clock budget expires, which hangs the suite for 30s per test. These use mutation="" to end each lineage after one mutate call. Suite runs in 0.05s.

Not addressed here

Against an aligned target the mutation loop still degenerates β€” the target refuses to write the attack, and its refusal text becomes the next generation's prompt. Recorded in the local backlog; it needs a design decision, not a patch.

Scanning a hosted model was broken. `scan` never sent max_tokens, so a gateway
reserves the target's full context window up front; against
anthropic/claude-sonnet-5 every request failed with

    402 - You requested up to 65536 tokens, but can only afford 3937.

Capping it fixes that, but a cap alone introduces a worse bug. `_probe`
returned bool(message.tool_calls) and never looked at finish_reason, so a
reply cut off at the cap -- possibly mid-compliance -- was recorded as SAFE.
That is a false negative in a security scanner, the one verdict it must never
invent, and it is exactly what INCONCLUSIVE exists to prevent.

So the cap ships together with truncation handling:

  - _probe raises TruncatedResponseError when a reply hits the cap without
    resolving; the loop prints [⚠️ TRUNCATED] and counts it unresolved
  - a tool call still wins even at the cap -- truncation only matters when
    nothing was produced
  - an all-truncated run says to raise --max-tokens rather than blaming the
    endpoint and model name, which is what it used to say

Verified against live models through OpenRouter:
  gpt-4o-mini,       default cap   3/3 seeds broke through
  claude-sonnet-5,   default cap   0/3, refused through generation 3
  claude-sonnet-5,   --max-tokens 12
      before: 3x [βœ… SAFE] and "No vulnerabilities found within budget"
      after:  3x [⚠️ TRUNCATED] and "Re-run with a larger --max-tokens"

Also here: --api-key reads MODELFUZZ_API_KEY, so a real key no longer has to
sit in shell history and in the process list; and provider errors are clipped
to one line, because a gateway 402 returns multiple KB of JSON carrying
account identifiers and the whole blob was echoed per failed probe.

Tests 71 -> 81.
@higagan
higagan merged commit 6bf61ef into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant