Fail loud when false-positive filtering is unavailable (remove the fixed-model probe) - #131
simonstudios wants to merge 2 commits into
Conversation
…model probe)
FindingsFilter opened with validate_api_access(), a probe call whose model id was
hard-coded to claude-3-5-haiku-20241022 and not settable by any input — claude-model
sets the scan model only. That id retired on 2026-02-19, so on every run since:
1. the probe raised not_found_error,
2. findings_filter.py caught it, set use_claude_filtering = False and dropped the
client, and
3. the audit emitted hard-rules-only output through the normal success path —
byte-identical in shape to output the Claude filter had actually passed.
The scan itself still ran; only the false-positive filter stopped, silently. One fleet
using this action logged 36 failed probe requests in a single day before anyone noticed,
and only because the API provider emailed about the failures.
Swapping the pinned probe id would just move the same trap to the next retirement.
Instead:
- Delete validate_api_access() and the startup probe entirely. The filter's own first
real call is the validation, and it uses the CONFIGURED model.
- Add ClaudeFilteringUnavailableError, raised on a configuration-class API error
(unknown/retired model id, bad or revoked key, no access to the model). Raised on
the FIRST failure, with no retries — retrying an unknown model id only multiplies
failed requests.
- github_action_audit.py catches it and exits EXIT_CONFIGURATION_ERROR with
{"error": "FALSE_POSITIVE_FILTER_UNAVAILABLE: ...", "filter_unavailable": true},
so a caller can tell "filter could not run" from a normal result.
The classifier is deliberately narrow: rate limits, overload, timeouts and spend/usage-cap
400s are NOT configuration errors and still degrade gracefully through the existing retry
and per-finding fallback paths. test_filter_fail_loud.py covers both directions.
Also refreshes model ids that had reached their published retirement date:
constants.py's DEFAULT_CLAUDE_MODEL fallback was claude-opus-4-1-20250805 (retired
2026-08-05), plus the example id in action.yml and the default documented in README.md.
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
After the retry budget is exhausted, rate-limit/overload/spend-cap/connection failures still return success=False, and filter_findings() keeps the finding with confidence 10.0 and returns overall success. That means a run where Claude filtering never succeeded can still emit unfiltered results as normal filtered output. Please mark filtering incomplete/fail after retries are exhausted rather than silently promoting those findings.
… the total-failure hole Two review findings, both instances of the same defect this branch exists to fix, found one layer out from where it was first patched. 1. action.yml swallowed the auditor's exit code (`|| CLAUDECODE_EXIT_CODE=$?` followed by only a ::warning::), so `sys.exit(EXIT_CONFIGURATION_ERROR)` was inert: the step exited 0 with findings_count=0 and the job stayed green. "Fail the job when filtering is unavailable" was therefore not a property of this action at all — it held only for callers that parse the results JSON themselves. Exit 2 now fails the step; exit 1 stays swallowed, because that is the auditor's "found a HIGH severity finding" signal, which is a result rather than a failure. The failure is raised AFTER the outputs and the workspace copies, so artifact upload and PR commenting still see everything. 2. A persistent transient failure (429 / 529 / timeout) was invisible. Every finding took the per-finding fallback, got `justification: "Claude API failed: …"`, and the run returned success — output no consumer can tell apart from a filtered pass, which is exactly what ClaudeFilteringUnavailableError's own docstring says must never happen. Now: the count is tracked and reported as `claude_api_failures` in the filtering summary, so a PARTIAL failure is visible rather than assumed away; and a TOTAL one — every finding unjudged, i.e. the filter produced no verdict at all — raises. The line is drawn at "nothing was filtered", not at "an error occurred", on purpose. A transient blip must not fail a whole security job across a fleet; a filter that returned no verdicts did not run, whatever the reason. Worth recording: the raise was first written one block too far down and landed inside the `else:` branch (filtering disabled), where the counter is always 0 — it never fired. The new tests caught it. A test that only asserted "no exception on the happy path" would not have. pytest claudecode/: 187 passed (test_eval_engine.py's 11 failures are pre-existing on unmodified upstream in this sandbox — it writes to a hard-coded ~/code path).
|
Pushed two more commits after review, both closing gaps in the original change rather than adding scope. Updating the PR description would have buried them, so they are here. 1.
Exit 2 now fails the step. Exit 1 stays swallowed — that is the auditor's "found a HIGH severity finding" signal, which is a result, not a failure, and callers gate on it themselves. The failure is raised after the outputs and workspace copies, so artifact upload and PR commenting still see everything. 2. A total transient filter failure was invisible. On a persistent 429/529/timeout, every finding took the per-finding fallback, got Now: The line is drawn at "nothing was filtered", not at "an error occurred", deliberately. A transient blip must not fail a whole security job; a filter that returned no verdicts did not run, whatever the reason. Transient errors still go down the retry path, and the configuration classifier stays as narrow as before — spend-cap 400s, 429s, 529s and timeouts are all still non-configuration. One thing worth flagging for reviewers: the raise was first written one block too far down and landed inside the
|
The defect
FindingsFilter.__init__opens withClaudeAPIClient.validate_api_access(), whose probe call hard-codesmodel="claude-3-5-haiku-20241022"(claudecode/claude_api_client.py:62onmain). That id is not settable by any action input —claude-modelsets the scan model only.claude-3-5-haiku-20241022retired on 2026-02-19. Since then, on every run of this action:not_found_error;findings_filter.py:187-192catches it, logs awarning, setsuse_claude_filtering = Falseand drops the client;filter_findingstakes theelsebranch and stamps every findingconfidence_score: 10.0, justification: 'Claude filtering disabled';github_action_audit.pyemits that through the normal success path.The scan still runs. Only the false-positive filter stops — and the output is indistinguishable in shape from output the filter actually passed. A fleet running this action logged 36 failed probe requests in a single day, and found out only because Anthropic emailed about the failed calls.
constants.py:8'sDEFAULT_CLAUDE_MODELfallback has the same problem:claude-opus-4-1-20250805reached its published retirement date on 2026-08-05.Why not just bump the probe's model id
That moves the identical trap to the next retirement. The probe's only job is to find out whether the API works — which the filter's own first call already does, against the model actually configured.
The change
validate_api_access()and the startup probe. The filter's first real call is the validation, and it uses the configured model.ClaudeFilteringUnavailableError, raised on a configuration-class API error — unknown/retired model id, bad or revoked key, no access to the model. Raised on the first failure with no retries: retrying an unknown model id only multiplies failed requests.github_action_audit.pyexitsEXIT_CONFIGURATION_ERRORwith{"error": "FALSE_POSITIVE_FILTER_UNAVAILABLE: ...", "filter_unavailable": true}, so a caller can distinguish "the filter could not run" from a normal result.DEFAULT_CLAUDE_MODEL→claude-opus-5, plus the example inaction.ymland the documented default inREADME.md.The classifier is deliberately narrow —
not_found_error,authentication_error,permission_error, invalid-api-key, andError code: 401/403/404. Rate limits, overload, timeouts and spend/usage-cap400s are not configuration errors and still degrade gracefully through the existing retry and per-finding fallback paths. That matters: a workspace hitting its usage cap should not start failing security jobs.Tests
claudecode/test_filter_fail_loud.py(16 cases) covers both directions — anot_found_errorraises after exactly one call and names the configured model; rate-limit and spend-cap errors still retry and still keep findings; construction makes zero API calls;validate_api_accessis gone.pytest claudecode/: 173 passed. (test_eval_engine.py's 11 failures are pre-existing on unmodifiedmainin this environment — it writes to a hard-coded~/codepath.)Note for maintainers
We are running this as a fork pin at
simon-studios/claude-code-security-reviewuntil this lands, and will drop the fork when it does.