Skip to content

Fix/the config cannot name a provider that does not exist - #292

Merged
evkir merged 4 commits into
mainfrom
fix/the-config-cannot-name-a-provider-that-does-not-exist
Sep 21, 2026
Merged

evkir merged 4 commits into
mainfrom
fix/the-config-cannot-name-a-provider-that-does-not-exist

Conversation

@evkir

@evkir evkir commented Sep 21, 2026

Copy link
Copy Markdown
Owner

What this changes

How it was measured

Checklist

  • ruff format --check cyberai/ tests/ and ruff check cyberai/ tests/ pass
  • pytest -W ignore::DeprecationWarning -m "not slow and not smoke" passes
  • New behaviour is covered by a test that fails without the change
  • I have read CLA.md and I hereby sign the CLA

CYBERAI_LLM_PROVIDER=gemini produced a config whose provider was the literal
string "gemini". from_env passed str into a field declared Literal, and
cyberai/core/config.py was outside [tool.mypy] files, so the checker never
saw the assignment either. The name was answered at call time, with
api_key_for resolving a credential for a provider that does not exist: a run
asked to use one vendor could reach another under a key nobody named.

_env_provider narrows at the boundary instead of raising, per the rule
already stated on _env_bool: a value outside the declared set is nobody
having chosen, and garbage in a variable must not abort a scan at startup.
Nine call sites reach from_env, one of them inside an MCP tool where an
exception is not a message anybody reads.

The declared set is the Literal itself, read through get_args, so the
checker and the runtime reader answer from one list. A hand-written copy
would be a rule that can disagree with itself; the architecture test that
compares the credential map to the Literal now covers this reader too.

The module enters the typing scope in the same commit, which is what makes
the assignment visible to CI. Two more errors went with it: phase_models was
an unparameterised dict, and save() had no return annotation.

Measured before the change: adding the file to the scope costs three errors
and one module, not an import closure. Measured after: 101 of 172 modules,
280 errors outside, drift none. Both crossing counts fell -- 22 modules
reaching 29 became 20 reaching 28 -- because the edge moved inward, not
because an import went away. docs/architecture/typing-scope.md says so, and
its per-module table was remeasured while it was open: async_agent.py read
17 and reads 12.

Mutation: five mutants, five killed. The fallback needed an assertion where
the branch and the default differ -- from_env passes "openai", which is also
the field default, so a reader that ignored its argument passed every test
until one read with a different default.
Two boundaries were left after the environment reader. --provider assigned
its string onto config.llm.provider, a field declared Literal, and the
router put RoutingConfig.air_gapped_provider, declared str, into a replace()
on the same field. Both files were outside [tool.mypy] files, so neither
assignment was visible to CI.

The two boundaries get different answers on purpose. The environment reader
narrows silently: a stale variable in a .env file must not abort a scan. A
flag was typed seconds ago by someone who can read the reply, so click.Choice
refuses it and names the set. One declared list stands behind both -- the
Choice is built from the Literal, not from a second list written by hand.

The router's credential line was typed wrong in a way that mattered. api_key
was inferred str from the cloud branch while api_key_for returns str | None,
and None is the correct answer for the air-gapped provider, which takes no
key. The test asserting exactly that has passed since the day it was
written; the annotation was the part that disagreed.

Also in this commit, because the checker reaches them together: Provider
moved above RoutingConfig so both dataclasses can name it, and
air_gapped_provider carries the type instead of str.

Measured before the change: two errors in model_router.py, one in
__main__.py, three modules and no import closure. Measured after: 103 of 172
modules, 277 errors outside, drift none. The crossing counts rose to 22
reaching 31 -- the opposite direction from the previous commit on the same
day, because an entry point brings its own imports to the edge. __main__.py
reaches cli/audit_verify.py, cli/detector_eval.py and cli/scope.py, which
nothing else in the scope touches. The page says so.

Mutation: four mutants, four killed, and one of them only by mypy. Widening
air_gapped_provider back to str leaves every test green -- a type is not
executed, so pytest cannot see it. That mutant would have survived both
channels before this commit, which is the argument for the scope entry
rather than for the annotation alone.
…epted

validate_exploit_scope is public API, re-exported from agents.exploit, and
declared authorized_scope: List[str] = None with attack_paths the same way.
PEP 484 prohibits the implicit Optional and mypy rejects it by default, so
the two lines were two errors inherited by everyone who imported the module
-- and it sat outside [tool.mypy] files, so nobody was ever shown them.

The behaviour does not move. The body has always read both arguments through
a falsiness check: `if authorized_scope` for the scope, `attack_paths or []`
for the paths. None was accepted before and is accepted now; the declaration
was the part that disagreed with the code under it.

Measured rather than assumed: the tail said nine call sites in four test
files. There are more than twenty, and two of them are production --
orchestrator.py:174 and :396, both passing positional lists that are never
None. The public signature was wrong for callers who do not exist yet, which
is the only kind of caller a library API can be wrong for.

The guard reads the signature, not a call. A test that passed None and got a
result would have passed before this commit too, because the body already
handled it; only inspect.signature plus get_type_hints can see the defect. A
control asserts the rule is not vacuous by naming the two arguments that
default to None.

Measured after: 104 of 172 modules, 275 errors outside, drift none. Only one
crossing count moved, reached 31 to 30: this module imports nothing outside
the scope, so it was never a crosser and simply stopped being reached. A
leaf pays one counter where an entry point pays both.

Mutation: four mutants, three killed, one survived by design. Rewriting
Optional[List[...]] as List[...] | None survives, and should: the two spell
one type. Restoring the implicit Optional dies in both channels, and
removing the `or []` guard dies in both as well.
…undary

Risk 24: configuration naming a provider that does not exist. The row names
a test per boundary -- the environment reader and the CLI parser -- rather
than one test for both, because they answer differently on purpose and a
single row would hide which half is guarded. Per the lesson risk 20 taught,
each named test measures behaviour: what the config ends up holding and what
the parser does with an unknown name, not whether a field exists.

The README said `# openai | anthropic` in the configuration example while
`ollama` has been a declared provider throughout, is named in the example
command two hundred lines above, and is the one provider deliberately
without a credential. The page disagreed with itself; the comment now names
all three.

The environment table says what an unrecognised value does. This is the
cheap half of the refusal-behaviour gap: a reader who sets
CYBERAI_LLM_PROVIDER to something the tool does not know had no page telling
them what happens, and now has one sentence per boundary -- the variable
falls back and the run continues, the flag is refused and the names are
listed. The general case, a page describing every refusal and when it
fires, is still not written.

No numbers moved: the badges already read 2970 and 104/172 before this
commit, and the scripts confirmed rather than wrote them.
@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@evkir
evkir merged commit 7ee6818 into main Sep 21, 2026
10 checks passed
@evkir
evkir deleted the fix/the-config-cannot-name-a-provider-that-does-not-exist branch September 21, 2026 06:15
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