feat(scan): backend fallback chain (issues #7, #35) - #36
Merged
manticore-projects merged 1 commit intoJun 23, 2026
Merged
manticore-projects merged 1 commit into
manticore-projects merged 1 commit into
Conversation
…icore-projects#35) Try every configured backend before failing closed, instead of running only the first. Environment-derived backends (now ALL auto-detected, not just the first available) are tried in order, then any ~/.config/aurscan/llmN.conf entries in numeric order, de-duplicated. On any per-backend failure (transport/exec error, timeout, or non-genuine output) aurscan warns and tries the next; the first genuine OK/SUSPICIOUS/MALICIOUS verdict wins; only when the whole chain is exhausted does it fall closed to SUSPICIOUS, exactly as before. The change is strictly additive: with no llmN.conf and a healthy backend, behaviour is byte-for-byte identical (only the first backend is ever called on success, and a lone backend failing stays silent on stderr). This resolves manticore-projects#35 (a failed Claude CLI now falls through to Codex/local instead of immediately failing closed) and the multi-backend half of manticore-projects#7 (a rate-limited subscription falls through to a local model). - scan: extend Backend with per-entry Model/URL/Fallback/APIKey and a secret-redacting String(); add envBackends/Backends/dedupe, BackendsFromConfig, CallBackend; make the five callX backend functions spec-aware (spec field overrides the legacy env lookup, empty == env). - scan: decide chain fall-through on a STRUCTURAL "genuine verdict" signal (real JSON yielding a known verdict) rather than the model-controlled "(fail-closed)" summary substring the old code keyed on, so a backend that returns junk or an unknown verdict is retried. - config: add LLMConfigs(), a flat key=value reader for llmN.conf (numeric file ordering, literal values, first-'=' split; warns when an api_key-bearing file is group/other-readable). No new dependency. - docs: document the fallback chain, the llmN.conf schema, secret handling and the multiplicative-timeout caveat. Tested: go vet + go test (new internal/config/config_test.go and internal/scan/chain_test.go, plus a pipeline no-backend regression), and a live matrix on the compiled binary (openai->openai and openai->claude fall-through both produce a real verdict; single-backend failure fails closed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 23, 2026
Owner
|
Thank you much, though I am going to add the following test to internal/scan/chain_test.go (mirrors TestGenuineConfidenceZeroStopsChain): // TestGenuineSuspiciousStopsChain pins the subtle invariant: a GENUINE
// SUSPICIOUS from an early backend must stop the chain and must NOT be
// overridden by a later backend's OK. (MALICIOUS is covered by
// TestGenuineConfidenceZeroStopsChain; SUSPICIOUS is the trickier case because
// it reads as "fail-ish" and a naive chain might keep trying for an OK.)
func TestGenuineSuspiciousStopsChain(t *testing.T) {
first, _ := startStub(t, 200, openAIEnvelope(`{"verdict":"SUSPICIOUS","summary":"unverified source"}`))
second, secondHits := startStub(t, 200, openAIEnvelope(`{"verdict":"OK"}`))
t.Setenv("AURSCAN_BACKEND", "openai")
t.Setenv("AURSCAN_OPENAI_URL", first)
t.Setenv("AURSCAN_OPENAI_MODEL", "")
setExtraBackends(t, Backend{Kind: "openai", URL: second})
res := Scan("pkg", testFiles, Signals{})
if res.V.Verdict != "SUSPICIOUS" || res.Failed {
t.Fatalf("verdict=%q failed=%v, want SUSPICIOUS/false (genuine SUSPICIOUS must win)", res.V.Verdict, res.Failed)
}
if n := atomic.LoadInt32(secondHits); n != 0 {
t.Fatalf("second backend called %d time(s); a genuine SUSPICIOUS must stop the chain", n)
}
} |
Contributor
Author
|
Yep, indeed that is something to look out for. Thanks for including the fallback code 🎉 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The README advertises a 6-tier backend preference order, but the code never walked it:
PickBackendreturned exactly one backend andScanfailed closed toSUSPICIOUSthe moment it errored. This implements the backend fallback chain discussed in #7 and reported in #35.How it works
The chain is environment-derived backends first (a pinned
AURSCAN_BACKEND, otherwise all auto-detected backends in the documented order), then~/.config/aurscan/llm1.conf … llmN.confin numeric order, de-duplicated. On any per-backend failure — transport/exec error, timeout, or non-genuine output — aurscan prints a one-lineWARNING: backend <x> failed; trying nextand moves on. The first genuine verdict (real JSON yielding a knownOK/SUSPICIOUS/MALICIOUS) wins. Only when the whole chain is exhausted does it fall closed toSUSPICIOUS— unchanged from today.Strictly additive. With no
llmN.confand a healthy backend, behaviour is byte-for-byte identical: only the first backend is called on success, total failure still fails closed with the same verdict/exit code, and a lone backend failing prints no "trying next" noise. The chain only ever adds attempts on failure.llmN.confschema (flatkey = value, no new dependency)backendclaude·codex·api·openai· or a/path/to/exe(custom command, mirroringAURSCAN_BACKEND)modelapi/codex/openaiurlopenai/chat/completions, or an Anthropic-compatible/v1/messagesgateway forapi)fallbackopenaiURL (intra-backend)api_keyx-api-keyfor this backendValues are literal;
#/;start a comment only at the start of a line. Secrets are better kept in env vars — aurscan warns on startup if anapi_key-bearing file is group/other-readable.Notable design point
Chain fall-through is decided by a structural "genuine verdict" signal (the parser reports whether real JSON produced a known verdict string), not the model-controlled
"(fail-closed)"summary substring the previous code sniffed. Otherwise a backend returning{"verdict":"unsure"}(downgraded toSUSPICIOUS) would wrongly stop the chain.Testing
make test(go vet ./...+go test ./...) — green. New:internal/config/config_test.go(parser edge cases: numeric ordering, BOM/CRLF,=-in-value, literal values, unreadable-file skip, perm warning),internal/scan/chain_test.go(transport/parse/unknown-verdict fall-through, genuine-verdict-stops-chain, cmd fall-through, claude→codex auto-detect, spec-URL precedence, secret-no-leak, exhaustion, single-backend silence), and a pipeline no-backend regression. All pass under-race.openai→openai(fake key → real key) andopenai→claudefall-through each produced a real verdict; a single fake-key backend failed closed toSUSPICIOUS; secret redaction confirmed (hasKey:yes, no key in--debugoutput).Deferred to follow-ups (flagged for discussion)
--update-checkresume. This PR fixes the per-invocation rate-limit case (fast-fail then fall through). The bulk--update-checkworkload still re-scans every package each run; a content-hash result cache (the other half of Resume feature for dealing with rate limits #7) is the real fix and is intentionally out of scope here.callXstill reads its own legacy env var); the new unified per-field schema applies only tollmN.conf. Unifying env onto that schema — and native Azure/Bedrock support (theapibackend is Anthropic-header-only today) — could be a follow-up.SUSPICIOUSto stay minimal and avoid touching the gate/hook/score logic. A future option could degrade to the static-rules verdict on total failure (the "rules as last resort" idea), with the automated build hooks kept strict (requireINSTALL/ fail closed on a degraded scan) while the standalone CLI stays lenient — happy to add this if you'd prefer it.Open question: in
llmN.conf, abackend=/pathvalue is treated as a custom command (mirroringAURSCAN_BACKEND=/path); should a typo'd kind be validated/rejected instead of silently treated as a command path?🤖 Generated with Claude Code