Skip to main content

envdoctor

Adversarial QA for LLM-RL environments. It tries to get paid without doing the task.

An RL environment is a grader, and graders have bugs. The expensive ones are quiet: a format reward that pays for empty <answer></answer> tags, a substring match that accepts every option at once, a reward function that throws on an empty completion and gets silently scored 0.0 by the framework. You find out after the policy has spent a week learning the bug instead of the task.

envdoctor attacks the grader directly with policies that never look at the question, and grades the results against what a correct answer actually earns.

No model, no API key, no credits. A scan is a few hundred local function calls.

pip install rl-envdoctor
envdoctor scan envdoctor.demo::gameable

The distribution is rl-envdoctor; the command and the import package are envdoctor.

envdoctor  gameable-mcq
  8 cases x 26 policies
  reward ceiling 1.000 via oracle_answer_tag, payout threshold 0.500

  policy                     kind      mean   max    pay
  -------------------------  --------  -----  -----  ----
  oracle_adaptive            baseline  1.000  1.000  100%
  oracle_corrupted_adaptive  probe     1.000  1.000  100%
  mc_shotgun                 exploit   0.700  0.700  100%
  markdown_skeleton          exploit   0.350  0.700  50%
  copy_prompt                exploit   0.300  0.300  0%
  empty_answer_tag           exploit   0.300  0.300  0%
  empty                      exploit   0.000  0.000  0%
  ...

!! [critical] `mc_shotgun` earns 0.700 mean reward (70% of ceiling)
     Emits every multiple-choice letter, so substring matching always hits.
     repro: envdoctor scan envdoctor.demo::gameable --policy mc_shotgun --n 5 --show-trials

!  [high] `empty_answer_tag` earns 0.300 mean reward (30% of ceiling)
     Emits empty <answer></answer> tags.

!! [critical] Grader accepts corrupted answers (100% of full reward)
     The grader is matching on shape, not on the answer.

Exit code is 1 when anything at or above --fail-on (default high) is found, so envdoctor scan works as a CI gate with no wrapper.

Does this find anything real?

We scanned 149 environments sampled from the 1,512 public environments on the Prime Intellect Environments Hub, then had an independent agent re-derive every finding from the environment's source and try to refute it.

count of
scanned model-free 87 149 sampled
confirmed reward defect 28 87 scanned (32%)
a task-blind policy earns at least what a correct completion earns 16 87 scanned (18%)
refuted as an envdoctor artifact 13 41 flagged (32%)

Confirmed root causes: format rewards paid for scaffolding (12), substring and fuzzy matching (5), constraints that an empty string satisfies vacuously (4), a reward function that never reads the completion (1), a reward function that raises and is silently scored 0.0 (1).

Three of them, in the authors' own code:

  • bhogan94/q-programming-language keys its test-case lookup on the raw prompt string, which stops matching once prompts are formatted as chat, so a fallback pays a flat 0.5 to any non-empty output. The constant "42" and the reference Q solution both score 0.500 and the Q interpreter never runs.
  • Six maziyar/OpenMed_* environments print a worked example ending in \boxed{B} in their own system prompt. Echoing the prompt is parsed as the answer B and collects full credit on every row whose gold answer is B.
  • maziyar/OpenMed_MedKnowledge delivers its answer column as an int, so both correctness reward functions raise AttributeError on every rollout. The framework swallows that as 0.0, leaving only content-blind format rewards: an echoed prompt scores 0.275 while a correct \boxed{C} scores 0.150.

The full write-up, including the 13 cases where this tool was wrong and why, is in audit/AUDIT.md. Per-environment records are in audit/results/.

How well does it work?

Detection quality is a claim, so it is measured. envdoctor selftest scores the checks against a zoo of environments whose defects are known by construction: every defect class the Hub audit confirmed, plus the healthy shapes most likely to be flagged by mistake.

$ envdoctor selftest
ok   pays_for_empty            empty_ties_correct     detected
ok   worked_example_in_prompt  prompt_leaks_answer    detected
ok   dense_but_correct         healthy                quiet
...
defects caught 15/15 (recall 100%), false positives 0/8

The healthy half is the half that matters. Building it caught a real false positive: on a partial-credit grader, an empty <answer></answer> tag collects 0.44 of the ceiling from string similarity alone, which is the metric's floor rather than a defect. Dense graders now require a policy to take half of what a correct answer earns before it is reported.

What it looks for

check what it means
empty_ties_correct Nothing outscores a blank page. The strongest thing this tool says.
completion_independent_reward The reward never reads the completion, so no policy differs from any other.
exploit_pays_out A policy that ignores the task earned reward.
grader_accepts_wrong A wrong answer, built by perturbing the right one, still scores.
prompt_leaks_answer The prompt hands the answer over, so echoing it is a strategy.
judge_pays_for_tone Confident phrasing beats hedged phrasing at the same length and no content.
reward_saturation Every completion gets the same reward, so there is no gradient.
nondeterministic_reward The same completion scored twice gave two answers.
grader_crash The reward function raised. Frameworks swallow this and return 0.0.
invalid_reward NaN, infinity, or a reward outside [0, 1].
slow_grader Grading alone is slow enough to pace a training run.
baseline_sanity A correct answer scored nothing, so nothing else here is calibrated.
empty_scan No cases were graded, so a clean result would mean nothing was tested.

envdoctor checks lists them; envdoctor policies lists the attacks.

Calibration, and why the tool says "uncalibrated"

"The empty string scored 0.3" means nothing on its own. Every scan replays the dataset's own answer to find the reward ceiling, and reports exploits as a share of it.

Environments state their answer format in the prompt: <reversed_text> tags, \boxed{}, #### 42. envdoctor reads that instruction out of the prompt and wraps the ground truth the same way, which is how it calibrates environments whose format it has never seen.

If a correct answer still scores nothing, the run says so rather than reporting a clean bill of health. That environment probably needs a real rollout, with tools, multiple turns or code execution, and its grader has not been tested by this scan.

Scanning a verifiers environment

pip install 'rl-envdoctor[verifiers]'
envdoctor scan gsm8k --n 20
envdoctor scan my-env --env-arg num_examples=200 --env-arg use_think=true

Both generations work and the right one is picked automatically. A module exposing load_environment() is scanned through its rubric; a module exposing a Taskset is scanned through its tasks' @vf.reward methods, using Task.score(trace, runtime=None). Either way it is a dataset row in, a synthetic completion graded, a reward out, with no rollout and no model.

That is why it is free, and it is also the limit. Environments scored during a rollout come back uncalibrated, and on v1 the rewards that need a container are skipped silently by the framework, so envdoctor records which ones it could not exercise:

skipped_runtime_rewards: ["executes"]
scored_model_free: []

An environment in that state has not really been scanned, and the report says so instead of reporting that nothing paid out.

Scanning a whole collection

One scan tells you about one environment. A sweep is what turns this into a claim about a collection, which is how the Hub audit was run:

envdoctor sweep env_a env_b env_c --json sweep.json
envdoctor sweep --from-file environments.txt --fail-on critical
target                         worst     findings  ceiling  calibrated  top exploit
-----------------------------  --------  --------  -------  ----------  ----------------
envdoctor.demo::gameable  critical  6         1.0      yes         mc_shotgun 0.700
envdoctor.demo::healthy   clean     0         1.0      yes         -

1 of 2 failed at high or worse

A target that fails to load is recorded and the sweep continues, because one broken environment should not cost you the other forty nine.

Scanning an Inspect AI task

pip install 'rl-envdoctor[inspect]'
envdoctor scan mypkg.evals::my_task

Inspect scorers have the same failure modes as RL graders, because they are the same thing under a different name. includes() accepts the target anywhere in the output, so a completion that lists every option satisfies it without answering.

Model-graded scorers are skipped rather than scored, because grading with a model costs money and is not reproducible. The report names the ones it skipped instead of returning a zero that looks like a strict grader.

Scanning anything else

Wrap the grader and scan it in your own test suite:

from envdoctor import Case, FunctionTarget, run_checks, scan

target = FunctionTarget(my_reward_fn, [Case(id="0", prompt=..., answer=...), ...])
report = run_checks(scan(target))
assert not [f for f in report.findings if f.severity == "critical"]

Or point the CLI at it: envdoctor scan mypkg.envs::target.

In CI

- uses: really-notabot/envdoctor@v0
  with:
    target: my-env
    fail-on: high

- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: envdoctor.sarif

The SARIF upload puts each finding in the Security tab and on the pull request diff, with a stable fingerprint so a finding is tracked across runs rather than reopened every time the wording changes.

An environment with findings already in it should not fail on day one, or the gate gets switched off. Record what you know about and fail only on what is new:

envdoctor scan my-env --json envdoctor-baseline.json   # once, then commit it
envdoctor scan my-env --baseline envdoctor-baseline.json
baseline: 0 new, 6 known, 1 no longer found
  fixed, drop from the baseline: exploit_pays_out:copy_prompt

In your test suite

Installing the package registers a pytest plugin, so gameability can be an ordinary assertion next to your other tests:

from envdoctor.pytest_plugin import assert_not_gameable

def test_my_env_is_not_gameable(envdoctor_scan):
    assert_not_gameable(envdoctor_scan("mypkg.envs::target"))

def test_my_reward_function(envdoctor_scan_function):
    report = envdoctor_scan_function(my_reward_fn, my_cases)
    assert_not_gameable(report, fail_on="medium")

The failure message carries the repro command for the policy that scored.

Scanning environments you do not trust

Scanning an environment runs its code. Installing the package runs its build, importing the module runs everything at module scope, and load_environment() or a Taskset constructor runs whatever the author wrote. envdoctor does not sandbox any of that and cannot: calling the grader is the entire technique.

So treat envdoctor scan some-hub-env as equivalent to running an untrusted program, because it is. For anything off a public hub, run it in a container or a throwaway VM, with no credentials in the environment. The Hub audit in audit/ ran every environment in a subprocess with a timeout, which contains a hang or a crash; it does not contain a hostile author, and nothing here should be read as claiming otherwise.

Prior art

Environment quality is a known problem, mostly written up rather than tooled. arXiv:2606.16062 found that 28.5% of sampled SWE-bench-Verified tasks accept incorrect patches; verifiers v1 ships a per-task validate() hook for checking that a task's own verifier accepts the gold answer. envdoctor is the adversarial counterpart, and it is meant to be run on every commit rather than once for a paper.

Credits

Built with Claude Code.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rl_envdoctor-0.1.0.tar.gz (67.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rl_envdoctor-0.1.0-py3-none-any.whl (62.6 kB view details)

Uploaded Python 3

File details

Details for the file rl_envdoctor-0.1.0.tar.gz.

File metadata

  • Download URL: rl_envdoctor-0.1.0.tar.gz
  • Upload date:
  • Size: 67.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for rl_envdoctor-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d370e5e4454263285655ddfd25dbebdd88d64e162017ee4b7e3fb4f822009966
MD5 3b96a26ba65bb52a3023e27dded92906
BLAKE2b-256 d7fb0f6b626cd3b71f4f74f3aeed9a14ea375397094877047cc5c2fad1f6f726

See more details on using hashes here.

Provenance

The following attestation bundles were made for rl_envdoctor-0.1.0.tar.gz:

Publisher: release.yml on really-notabot/envdoctor

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file rl_envdoctor-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: rl_envdoctor-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 62.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for rl_envdoctor-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f7008482b1eec70e3962f8dd52e607ca97664105c89465ce2150a4b657334665
MD5 08eb64b0a48df1b60b6479e1a51c29c3
BLAKE2b-256 4d39534d97a29c542eb1319d4e9070a2e3ea2cb75e2b3557d78da7dc100d2362

See more details on using hashes here.

Provenance

The following attestation bundles were made for rl_envdoctor-0.1.0-py3-none-any.whl:

Publisher: release.yml on really-notabot/envdoctor

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page