Skip to content

fix(report): red-team report fails closed — no green for unanalyzed runs, no vanished broken runs, no compromise filed as held - #890

Open
sergioestebance wants to merge 7 commits into
mainfrom
issue888/redteam-report-fail-open
Open

fix(report): red-team report fails closed — no green for unanalyzed runs, no vanished broken runs, no compromise filed as held#890
sergioestebance wants to merge 7 commits into
mainfrom
issue888/redteam-report-fail-open

Conversation

@sergioestebance

@sergioestebance sergioestebance commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

For humans

The red-team report is a security document, and it was failing open: a run whose analysis crashed rendered as green "no risk", runs written by the JavaScript SDK could vanish from the dashboard tiles because of a one-word vocabulary mismatch, judge infrastructure failures were filed as significant security breaks, and — worst — a run that ended early because the attack succeeded was filed under "Attacks Held — What Worked". Every one of these now points the safe way.

Why

Four independent fail-open paths in the red-team report pipeline (writer → saved JSON → Streamlit dashboard), each of which misreports exactly the runs a security report exists to surface.

Closes #888

What changed

Python writer + dashboard

  • Analyzer failure (exception, 429, unrecognized break_severity) now sets analysis_failed: true, derives a status-based break-severity floor (a compromised run reads partial, never the green none), and the dashboard renders an "ANALYSIS FAILED" chip with the same severity-fallback urgency as an errored run.
  • Early exit on objective-achieved files as broke with success: falsesucceed() only ends the script; the defense did not hold. A shared EARLY_EXIT_OBJECTIVE_PREFIX marker (mirrored in both languages) keys the classification, and the dashboard re-buckets legacy held-with-that-reasoning reports the same way, so "What Worked" can never contain an objective-achieved run.
  • Status/risk classification extracted to scenario/report/_risk.py (pure, no Streamlit) so the rules are unit-testable; _status also normalizes the legacy JS "broken" to "broke".

JavaScript writer + judge

Test plan

Test-first (both new test files failed before the fixes):

  • python/tests/test_redteam_report_fail_closed.py — 17 tests: raising analyzer on broke/held runs, unrecognized break_severity, deliberate analyze=False is not an analysis failure, compound risk of an analysis-failed run, legacy "broken" normalization, early-exit filing and legacy re-bucketing, errored runs keep no verdict, and the review follow-ups (Python judge infra failure files as errored, held-with-failed-analysis keeps the matrix risk, objective-achieved severity floors).
  • javascript/src/__tests__/red-team-report.unit.test.ts — 6 tests: shared vocabulary, errored separation (run error + judge infra error on the result), no invented significant, early-exit files as broke.
  • Red-team/report Python suites 251 passed, 3 skipped; full JS unit suite 1085 passed, 4 skipped; pyright 0 errors; tsc --noEmit clean; eslint clean on changed files.

Anything surprising?

  • JS/Python parity on tile counts is achieved by writer vocabulary + dashboard tolerance, not by teaching the dashboard "broken" — per the issue's note, app.py's status lookup was left keyed on "broke".
  • Old reports on disk keep rendering correctly: the dashboard normalizes both legacy shapes ("broken", and held-with-objective-achieved reasoning) at read time.

Review follow-ups (post-review commits)

  • Python ScenarioResult gains the same optional error field as JS; the judge's discovery-non-convergence return sets it, and the writer files such results as errored (review P1 — Python judge breakdowns filed as fabricated "partial breaks").
  • The executor now emits ERROR (not FAILED) on the platform run-finished event when result.error is set, and serializes the error in the results payload — a broken judge no longer files as a failed simulation.
  • Held runs with a failed analysis keep the matrix risk (no medium-inflation on rate-limited batches); objective-achieved compromises floor at significant (new) / partial (legacy read); aggregation prompt uses the shared status normalization.

@sergioestebance sergioestebance self-assigned this Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Red-team reporting now distinguishes held, compromised, errored, and analysis-failed runs. JavaScript and Python use a shared early-exit marker and status vocabulary. Dashboard risk classification applies fail-closed fallbacks and displays analyzer failures.

Changes

Red-team report classification

Layer / File(s) Summary
Outcome contracts and early-exit markers
javascript/src/red-team-report.ts, javascript/src/agents/judge/..., javascript/src/agents/red-team/..., python/scenario/red_team_agent.py, python/scenario/judge_agent.py, python/scenario/types.py
Judge results now expose infrastructure errors. Both agents use the shared objective-achieved marker. JavaScript reports classify held, broke, and errored outcomes.
Python report classification and fail-closed analysis
python/scenario/report/_save.py, python/scenario/report/_risk.py
Report saving determines outcomes before analysis, records analysis failures, normalizes legacy statuses, and applies fallback severities for invalid or missing analyzer results.
Dashboard display, event status, and validation
python/scenario/report/app.py, python/scenario/report/_aggregate.py, python/scenario/scenario_executor.py, javascript/src/__tests__/red-team-report.unit.test.ts, python/tests/test_redteam_report_fail_closed.py
Dashboard risk logic uses the shared classifier and displays an ANALYSIS FAILED chip. Finished events now emit ERROR for result errors. Tests cover status normalization, early exits, analyzer failures, invalid severities, and errored runs.

Sequence Diagram(s)

sequenceDiagram
  participant RedTeamAgent
  participant JudgeAgent
  participant saveRedTeamReport
  participant Dashboard
  RedTeamAgent->>saveRedTeamReport: report outcome and early-exit reasoning
  JudgeAgent->>saveRedTeamReport: infrastructure error metadata
  saveRedTeamReport->>saveRedTeamReport: normalize status and analyze report
  saveRedTeamReport->>Dashboard: saved report with risk and analysis_failed
  Dashboard->>Dashboard: render status and risk classification
Loading

Possibly related PRs

Suggested labels: review: targeted

Poem

I’m a rabbit with a report in my paw,
Errors now stand where green lights were sawed.
Broke runs hop out of the held-run pen,
Early wins turn failed again.
The dashboard marks failed analysis bright—
Safer burrows through the night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address all linked issue requirements, including fail-closed analysis, status normalization, errored judges, and compromised early exits.
Out of Scope Changes check ✅ Passed The code changes support the linked issue objectives and contain no unrelated functionality or changes.
Title check ✅ Passed The title clearly summarizes the primary fail-closed red-team report changes and identifies the key failure modes addressed.
Description check ✅ Passed The description directly explains the report pipeline fixes, affected components, classification changes, and test coverage.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue888/redteam-report-fail-open

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread python/scenario/report/app.py Fixed
Comment thread python/tests/test_redteam_report_fail_closed.py Fixed
Comment thread python/scenario/report/_risk.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
javascript/src/red-team-report.ts (1)

126-149: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist errored runs as unsuccessful and retain the error reason.

When opts.error exists with opts.result.success === true, Line 148 writes success: true while Lines 131-135 write status: "errored". Line 149 can also hide runError behind existing result reasoning. Persist success: false whenever runError exists, and include the error in reasoning.

Add a test with result.success: true and error: "connection refused".

Proposed fix
     const status = runError
       ? "errored"
       : opts.result.success && !objectiveAchieved
         ? "held"
         : "broke";
+    const reasoning = runError
+      ? `ERROR: ${runError}${
+        opts.result.reasoning ? `\n\n${opts.result.reasoning}` : ""
+      }`
+      : opts.result.reasoning || "";
+
     const messages = (opts.result.messages || []).map(serializeMessage);
@@
-      success: Boolean(opts.result.success) && !objectiveAchieved,
-      reasoning: opts.result.reasoning || (runError ? `ERROR: ${runError}` : ""),
+      success: Boolean(opts.result.success) && !objectiveAchieved && !runError,
+      reasoning,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@javascript/src/red-team-report.ts` around lines 126 - 149, Update the payload
construction in the red-team report flow so success is false whenever runError
exists, even if opts.result.success is true. Ensure reasoning includes the
runError rather than allowing existing result reasoning to hide it, while
preserving current behavior for non-error results. Add coverage for a successful
result with error "connection refused" verifying status, success, and reasoning.
🧹 Nitpick comments (2)
python/scenario/red_team_agent.py (1)

38-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mark outcome and risk constants as immutable. These values define report classification behavior and should not be reassigned.

  • python/scenario/red_team_agent.py#L38-L43: declare EARLY_EXIT_OBJECTIVE_PREFIX as Final[str].
  • python/scenario/report/_risk.py#L12-L37: declare immutable classification constants as Final; use immutable containers where mutation is not required.

As per coding guidelines: “Use Final for constants that should not be reassigned in Python”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/scenario/red_team_agent.py` around lines 38 - 43, Mark
EARLY_EXIT_OBJECTIVE_PREFIX in python/scenario/red_team_agent.py (lines 38-43)
as Final[str]. In python/scenario/report/_risk.py (lines 12-37), annotate all
immutable outcome and risk classification constants with Final and replace
mutable containers with immutable equivalents where mutation is unnecessary.

Source: Coding guidelines

javascript/src/__tests__/red-team-report.unit.test.ts (1)

1-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the test execution requirements.

Add the repository test command, the Vitest dependency, coverage expectations, and a short saveRedTeamReport test example. The current header only documents behavior.

As per coding guidelines: “Document testing requirements explaining how to run tests, test coverage requirements, test dependencies, and providing test examples”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@javascript/src/__tests__/red-team-report.unit.test.ts` around lines 1 - 9,
Expand the header documentation for the red-team report tests to include the
repository test command, the Vitest dependency, expected coverage requirements,
and a concise saveRedTeamReport test example. Keep the existing behavioral
requirements intact and limit changes to documenting test execution and
expectations.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/scenario/report/_save.py`:
- Around line 281-322: Define a typed Analysis TypedDict in _save.py and use it
for _analyze()’s return value and the analysis variable, validating analyzer
JSON with explicit isinstance/type-narrowing checks before assigning persisted
fields such as break_severity and failing_turn_index. Update _risk.py lines
40-89 to accept the typed report shape or validate legacy JSON into it before
normalization and status classification; cover both listed sites and avoid
unparameterized dict or Any usage.

In `@python/tests/test_redteam_report_fail_closed.py`:
- Around line 169-175: Extend
test_agent_early_exit_message_carries_the_shared_prefix to run the early-exit
step produced by marathon_script() using a fake executor, then capture the
succeed() call and assert its reasoning starts with EARLY_EXIT_OBJECTIVE_PREFIX.
Preserve the existing check_early_exit() setup and assertion while validating
the emitted message.

---

Outside diff comments:
In `@javascript/src/red-team-report.ts`:
- Around line 126-149: Update the payload construction in the red-team report
flow so success is false whenever runError exists, even if opts.result.success
is true. Ensure reasoning includes the runError rather than allowing existing
result reasoning to hide it, while preserving current behavior for non-error
results. Add coverage for a successful result with error "connection refused"
verifying status, success, and reasoning.

---

Nitpick comments:
In `@javascript/src/__tests__/red-team-report.unit.test.ts`:
- Around line 1-9: Expand the header documentation for the red-team report tests
to include the repository test command, the Vitest dependency, expected coverage
requirements, and a concise saveRedTeamReport test example. Keep the existing
behavioral requirements intact and limit changes to documenting test execution
and expectations.

In `@python/scenario/red_team_agent.py`:
- Around line 38-43: Mark EARLY_EXIT_OBJECTIVE_PREFIX in
python/scenario/red_team_agent.py (lines 38-43) as Final[str]. In
python/scenario/report/_risk.py (lines 12-37), annotate all immutable outcome
and risk classification constants with Final and replace mutable containers with
immutable equivalents where mutation is unnecessary.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 62445dfb-db34-44bf-8241-32f146436651

📥 Commits

Reviewing files that changed from the base of the PR and between 0dfadb3 and bc07a0d.

📒 Files selected for processing (10)
  • javascript/src/__tests__/red-team-report.unit.test.ts
  • javascript/src/agents/judge/interfaces/judge-result.interface.ts
  • javascript/src/agents/judge/judge-agent.ts
  • javascript/src/agents/red-team/red-team-agent.ts
  • javascript/src/red-team-report.ts
  • python/scenario/red_team_agent.py
  • python/scenario/report/_risk.py
  • python/scenario/report/_save.py
  • python/scenario/report/app.py
  • python/tests/test_redteam_report_fail_closed.py

Comment thread python/scenario/report/_save.py
Comment thread python/tests/test_redteam_report_fail_closed.py Outdated
@sergioestebance

Copy link
Copy Markdown
Contributor Author

Ruthless review — findings and fixes

Independent review agent traced this diff with executed probes (not code-reading); findings below, all now addressed in the three follow-up commits.

P1 (introduced-on-top-of-inherited, FIXED): Python's judge infra failure still filed as a real security break. judge_agent.py returns "trace discovery did not converge" as a plain success=False result, and the new status-derived floor turned that into status='broke', break_severity='partial' — a fabricated finding, the exact shape the JS half of this PR removes. Fixed by adding the optional error field to Python's ScenarioResult (mirroring JS), setting it on the non-convergence return, and filing any result carrying it as errored — even with a full transcript.

P2 (introduced, FIXED): a HELD run whose analyzer call failed rendered as a MEDIUM-risk finding (_compound_risk severity-fallback applied regardless of status; severity is analyzer-produced so on failure it's always the uninformative default). One rate-limited batch would turn every held run medium. Fixed: the severity fallback now applies only to non-held runs; the ANALYSIS FAILED chip alone carries the uncertainty on held cards.

P2 (introduced-as-incomplete, FIXED): the early-exit re-bucket never reached the risk number — an objective-achieved run whose analyzer said break_severity: "none" rendered COMPROMISED in the list but RISK · NONE on the chip and absent from the risk donut. Fixed twice: new reports floor at significant at save time (analyzer "none" cannot override consecutive-turns-over-threshold evidence; a genuine analyzer "partial"/"complete" is kept), and legacy files floor at partial at read time in _break_of.

P3s (FIXED): _aggregate.py had a third private copy of the status derivation (legacy "broken"/early-exit reports were described as [held] to the fix-clustering model) — now imports _risk._status; dead _BREAK_ORDER import removed from app.py.

P3 (noted, not changed): ScenarioResult.error now reaches the platform wire on RUN_FINISHED for judge-infra runs (previously only genuine exceptions populated it). The emitted ScenarioRunStatus is unaffected (still derived from success), but a platform-side consumer treating "error present" as "run errored" would mis-bucket — unverifiable from this repo, flagging for awareness.

Also verified clean by the reviewer: no Python or JS import cycles from the new shared constant/module (executed under streamlit run-shaped invocation and pnpm build + pnpm smoke:dist); all six status reads in app.py now normalize (the old status_chip_label[status] would KeyError-crash the card on a JS "broken" report — fixed as a side effect); the succeed() reasoning-prefix collision is theoretical and would re-bucket correctly anyway.

Post-fix verification: 16/16 fail-closed tests (5 new), full Python suite 1,242 passed, pyright 0 errors.


Blessed. 🙏 The report now fails closed on every path — no compromise wears green, no broken run vanishes, and the judge's silence is filed as silence, not as a verdict.

"Woe to those who call evil good, and good evil; who put darkness for light, and light for darkness." — Isaiah 5:20

Traced writer → saved JSON → dashboard in both languages with executed probes; verdict: ship it. Residual: the results.error wire note above (platform-side consumer unverified from this repo) and the errored-with-transcript delta (nonepartial), both deliberate and now pinned by tests. Approval is the human's to give — this is a self-authored PR.

sergioestebance added a commit that referenced this pull request Aug 11, 2026
… review P1

ScenarioResult gains an optional error field (mirroring the JS SDK); the
judge's discovery-non-convergence return sets it, and the writer files
any result carrying it as errored — even with a full transcript — instead
of fabricating a 'partial break' compromise. Review finding on #890.
sergioestebance added a commit that referenced this pull request Aug 11, 2026
…egacy early-exit reports floor at partial

Review P2s on #890: severity is analyzer-produced, so escalating a HELD
run to the default 'medium' on analyzer failure carried no information —
the ANALYSIS FAILED chip alone signals the uncertainty. And a legacy
objective-achieved report whose stored break_severity is 'none' now
floors to 'partial' at read time so the risk number agrees with the
COMPROMISED card.
sergioestebance added a commit that referenced this pull request Aug 11, 2026
…ignificant; aggregate prompt uses shared status; drop dead import

Review P2/P3s on #890: the early exit is the strongest evidence available,
so an analyzer opinion of 'none' no longer puts a confirmed compromise
back at RISK NONE; _aggregate's findings block goes through _risk._status
so legacy 'broken'/early-exit reports are not described as [held] to the
fix-clustering model; unused _BREAK_ORDER import removed from app.py.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
python/tests/test_redteam_report_fail_closed.py (1)

178-178: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate both tmp_path fixture parameters.

Add tmp_path: Path to both test methods. Do not add -> None to these test methods.

As per coding guidelines, “Always use explicit type annotations for function parameters, return types, and class attributes in Python.” Based on learnings, “leave pytest test functions unannotated (do not add -> None).”

Also applies to: 225-225

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/tests/test_redteam_report_fail_closed.py` at line 178, Annotate the
tmp_path parameter with Path in both test methods, including
test_result_error_files_as_errored_not_broke and the additional test identified
by the review. Do not add return annotations such as -> None to either pytest
test method.

Sources: Coding guidelines, Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/scenario/types.py`:
- Around line 272-276: Update the ScenarioResult handling to propagate a set
result.error as an infrastructure failure: serialize error=result.error and emit
ScenarioRunFinishedEventStatus.ERROR instead of mapping it to FAILED. Preserve
the existing behavior for results without an error.

In `@python/tests/test_redteam_report_fail_closed.py`:
- Around line 195-205: Replace the source-text assertions in
test_python_judge_sets_error_on_discovery_non_convergence with an actual
_parse_response exercise using a response containing a leaked discovery tool
call. Assert that the returned ScenarioResult has success set to False and a
non-empty error value, ensuring the discovery non-convergence branch in
JudgeAgent is executed and validated.

---

Nitpick comments:
In `@python/tests/test_redteam_report_fail_closed.py`:
- Line 178: Annotate the tmp_path parameter with Path in both test methods,
including test_result_error_files_as_errored_not_broke and the additional test
identified by the review. Do not add return annotations such as -> None to
either pytest test method.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 970d2707-2120-487e-9c8f-069d43d200f6

📥 Commits

Reviewing files that changed from the base of the PR and between bc07a0d and df10367.

📒 Files selected for processing (7)
  • python/scenario/judge_agent.py
  • python/scenario/report/_aggregate.py
  • python/scenario/report/_risk.py
  • python/scenario/report/_save.py
  • python/scenario/report/app.py
  • python/scenario/types.py
  • python/tests/test_redteam_report_fail_closed.py
💤 Files with no reviewable changes (1)
  • python/scenario/report/app.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/scenario/report/_save.py

Comment thread python/scenario/types.py
Comment thread python/tests/test_redteam_report_fail_closed.py Outdated
Comment thread python/tests/test_redteam_report_fail_closed.py Fixed
…nd objective-achieved exits can no longer render green

Three fail-open paths in the Python writer/dashboard (#888):

- An analyzer failure (or an unrecognized break_severity) kept the default
  'none', so a compromised run whose analysis call 429'd rendered
  'RISK - NONE' in green. The writer now flags analysis_failed, derives a
  status-based break-severity floor (broke -> partial, never none), and
  the dashboard renders an ANALYSIS FAILED chip with the same
  severity-fallback urgency as an errored run.
- An early exit because the ATTACK achieved its objective was filed as
  held ('what worked') — succeed() only ends the script. Such runs now
  file as broke with success false, keyed on a shared
  EARLY_EXIT_OBJECTIVE_PREFIX marker; the dashboard re-buckets legacy
  reports by the same marker.
- Status/risk classification moved to scenario/report/_risk.py (pure, no
  Streamlit) so these rules are testable; _status also normalizes the JS
  writer's legacy 'broken' vocabulary to 'broke' so those runs stop
  vanishing from every tile.

Part of #888
…ng verdicts

- status 'broke', never the JS-only 'broken' that matched none of the
  dashboard's buckets and silently dropped broken runs from every tile;
- judge infrastructure failures (discovery budget exhausted, unknown or
  missing tool call) now carry JudgeResult.error, and the writer files any
  errored result as 'errored' instead of a significant security break;
- break_severity is written as '' when no analyzer has spoken, making the
  dashboard's status-based fallback reachable (broke -> partial) instead
  of filing every non-success as 'significant';
- an early exit because the attack achieved its objective files as broke
  with success false, sharing EARLY_EXIT_OBJECTIVE_PREFIX with the agent.

Part of #888
… review P1

ScenarioResult gains an optional error field (mirroring the JS SDK); the
judge's discovery-non-convergence return sets it, and the writer files
any result carrying it as errored — even with a full transcript — instead
of fabricating a 'partial break' compromise. Review finding on #890.
…egacy early-exit reports floor at partial

Review P2s on #890: severity is analyzer-produced, so escalating a HELD
run to the default 'medium' on analyzer failure carried no information —
the ANALYSIS FAILED chip alone signals the uncertainty. And a legacy
objective-achieved report whose stored break_severity is 'none' now
floors to 'partial' at read time so the risk number agrees with the
COMPROMISED card.
…ignificant; aggregate prompt uses shared status; drop dead import

Review P2/P3s on #890: the early exit is the strongest evidence available,
so an analyzer opinion of 'none' no longer puts a confirmed compromise
back at RISK NONE; _aggregate's findings block goes through _risk._status
so legacy 'broken'/early-exit reports are not described as [held] to the
fix-clustering model; unused _BREAK_ORDER import removed from app.py.
… FAILED; review follow-ups

CodeRabbit: the Python executor mapped a judge-infrastructure failure
(result.error set) to a FAILED run-finished event — the platform filed a
broken judge as a failed simulation, the same fabrication this PR removes
from the report. Status derivation moves to _run_finished_status (error
-> ERROR) and the error message rides the results payload. The weak
inspect.getsource judge test now drives _parse_response with a leaked
discovery tool call, and the early-exit test executes the marathon step
against a fake executor asserting the shared reasoning prefix. Unused
pytest import dropped.
…on isawaitable

marathon_script always generates the early-exit check as a coroutine
function, so the isawaitable branch was dead and left a bare `await maybe`
that static analysis reads as a statement with no effect. Narrow the
ScriptStep union with a cast and await unconditionally.
@sergioestebance
sergioestebance force-pushed the issue888/redteam-report-fail-open branch from a4d3585 to 06f5ad2 Compare August 11, 2026 14:51
@langwatch-agent langwatch-agent added the P1 - high High priority, work on this soon label Aug 12, 2026
@langwatch-agent

Copy link
Copy Markdown
Contributor

Human Review Brief

Mode: Deep Review. Closes #888 (bug, P1 - high, track:needs-driving). Four fail-open paths, one PR, and the issue names all four. Scope matches.

Decisions being ratified

  1. A failed analysis is a severity floor, not an absence of severity. analysis_failed: true derives a status-based floor so a compromised run reads partial rather than the green none. That inverts the default for the whole report: from "no evidence of risk means no risk" to "no evidence means assume the worst the status allows". Every future analyzer path inherits it.
  2. succeed() on objective-achieved is reclassified as broke, not held. The reasoning stated is the load-bearing part: succeed() only ends the script, the defense did not hold. Approving this changes what "Attacks Held" has always meant, and the dashboard re-buckets legacy reports the same way, so historical reports change verdict retroactively.
  3. EARLY_EXIT_OBJECTIVE_PREFIX becomes a cross-language marker keyed on for classification. A string in reasoning text is now load-bearing in two runtimes. That is the most fragile part of the design and the easiest to break by rewording a message.
  4. Judge infrastructure failure is filed as errored, never as a security break. The report stops conflating "our judge fell over" with "the defense broke", which is correct and also means a real break masked by an infra failure now reads as errored rather than as a break.
  5. Classification moves to a pure scenario/report/_risk.py, off Streamlit. The rules become unit-testable, and the dashboard becomes a renderer. That is a boundary other report work will be expected to respect.

Must Check

  • The legacy re-bucketing. Existing saved JSON gets re-read under the new rules, so a report someone has already looked at can change from held to broke. Confirm that is intended, and that nothing downstream (a stored summary, an exported artifact, a linked ticket) still asserts the old bucket.
  • The prefix marker in both languages. Confirm the TypeScript and Python constants are literally the same string and that a test would fail if one were reworded. A silent drift here puts objective-achieved runs back into "What Worked", which is the exact bug being fixed.
  • The severity floor's interaction with a held run. The body says compound risk of a held run is not inflated, and there is a test named for it. That is the case where fail-closed becomes fail-noisy, and a report that cries wolf gets ignored, which fails open by a different route.

Ask Author

  • Does anything outside this repo consume the saved JSON's status or break_severity fields? The vocabulary mismatch that made JS runs vanish suggests the schema has more than one reader.
  • Is there a migration or a note for reports already circulated whose verdicts change?

Production risk

This is a security document that people act on. The change is the right direction, and its own failure mode is now over-reporting rather than under-reporting, which is the correct place to put the error.

On the CI signal

The four red checks are from a run on 11 August that hit AI_APICallError: The virtual key spending limit (per day) for this request has been reached in the example and demo suites. The unit suites in that same run passed, including this PR's own test_redteam_report_fail_closed.py. main is green today. I re-ran the failed jobs rather than treat a budget cap as a defect.

@langwatch-agent langwatch-agent added the review: deep PR Hound review mode label Aug 13, 2026
@langwatch-agent langwatch-agent added ci-green Latest run of every check is passing (checks API, not the legacy commit-status index) hound-escalated Dispatched once to the code-review agent by pr-branch-hound labels Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Automated low-risk assessment

This PR was evaluated against the repository's Low-Risk Pull Requests procedure and does not qualify as low risk.

The changes update core reporting and classification logic (status vocabulary, break_severity semantics, analyzer/judge failure handling, early-exit reclassification) and modify runtime behavior (adding result.error and changing run-finished event status) across both Python and JavaScript. Because this alters reporting and event semantics — business‑critical/reporting behavior rather than only UI/text/tests/config — it does not meet the low-risk criteria.

This PR requires a manual review before merging.

@langwatch-agent langwatch-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two P2 findings in the current head; details are inline.

LangWatch-Review: verdict=findings sha=06f5ad2a8549baa0ac55241253e44300c0d33a6d p0=0 p1=0 p2=2 p3=0

status,
success: Boolean(opts.result.success),
reasoning: opts.result.reasoning || (opts.error ? `ERROR: ${opts.error}` : ""),
success: Boolean(opts.result.success) && !objectiveAchieved,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make an errored report unsuccessful in its persisted contract.

runError makes status errored, but it is not part of this expression. A caller can therefore persist { status: "errored", success: true }; line 149 can also retain a normal judge rationale and omit the error entirely. The dashboard currently keys off status, but other report readers (and the raw JSON consumers this shared format is for) can reasonably use success and get the exact success/error contradiction this change is intended to remove. Include !runError in success, make the persisted reasoning lead with the error (preserving the original rationale after it if useful), and cover a successful result carrying an error.

status = r.get("status") or ("held" if r.get("success") else "broke")
if status == "broken":
return "broke"
if status == "held" and str(r.get("reasoning") or "").startswith(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Persist the early-exit outcome instead of using prose as the current-data discriminator.

This exact prefix is separately defined in the Python and JavaScript runtimes, while the dashboard uses only the Python value to reinterpret reports from both writers. The tests only prove each runtime agrees with its own local constant, so a harmless wording edit in either writer silently turns future objective-achieved reports back into held in the dashboard (and an unrelated manual succeed() reason beginning with the phrase is falsely reclassified). Store an explicit objective_achieved/outcome field in new report JSON and classify from that; retain this prefix check solely as the legacy migration fallback. That makes the retrospective correction stable across SDK releases.

@drewdrewthis drewdrewthis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-party review (review-clerk). Two-pass review done; tests verified locally (17/17 pass; mutation-checked that reverting the objective-achieved override fails 2 of the fail-closed tests, confirming they're load-bearing, not vacuous). CI on head 06f5ad2 genuinely ran and exercised this change (test (3.12), python-complete, javascript-complete, ci-checks (24.x) all SUCCESS; no path-filter or step-skip vacuity observed on the gating jobs). One blocking correctness finding below; verdict comment has full detail.

# (attack score >= threshold for N consecutive turns) — an analyzer
# opinion of "none" must not put a confirmed compromise back at
# RISK NONE (#888).
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Objective-achieved significant floor is unreachable when the analyzer raises.

The two floor blocks run in a fixed order and the first one silently consumes the sentinel the second one needs:

  1. Lines 326-330: if break_severity is not a recognized value (this includes the "" default when the analyzer raised an exception), it floors to "partial" if status == "broke" else "none".
  2. Lines 336-341: the objective-achieved floor to "significant" only fires when break_severity in {"", "none"} — but block 1 has already turned "" into "partial" by this point, so this branch can never see "" for a broke run.

Concretely: an objective-achieved run (attack succeeded, succeed() reasoning carries EARLY_EXIT_OBJECTIVE_PREFIX) whose analyzer call raises (429, timeout, etc.) ends up break_severity="partial", not the "significant" the PR's own comment above (lines 332-335) says is required — "the strongest evidence available ... must not put a confirmed compromise back at RISK NONE" (or, implicitly, below significant). Verified locally: patching litellm.completion to raise on an objective-achieved result yields status="broke", break_severity="partial", analysis_failed=True — same shape as an ordinary rate-limited compromise, not the elevated floor this code block exists to guarantee.

The existing test TestEarlyExitBreakSeverityFloor::test_new_reports_floor_at_significant only covers the analyzer succeeding with an explicit "none" opinion — it doesn't hit the exception path, so this gap has no regression test today.

Not a fail-open bug (status is still correctly "broke", never "held"), but it does violate the PR's own documented invariant for this specific combination, silently downgrading urgency on the exact class of run (objective-achieved + analyzer failure) this PR set out to floor highest.

Suggested fix: move the objective-achieved elevation before the generic fallback, or change its guard to also match the pre-fallback "" sentinel (e.g. check analysis_failed instead of break_severity in {"", "none"}).

@drewdrewthis

Copy link
Copy Markdown
Collaborator

Review verdict: NOT-READY

Reviewed at: 06f5ad2 · Run: pr-review (reviewer: @drewdrewthis)

CI positively verified on this SHA (not accepted on the ci-green label alone): test (3.12), python-complete, javascript-complete, ci-checks (24.x), docs-complete, changes all SUCCESS; build SKIPPED (unrelated to this PR — no path-filter/step-skip vacuity found on the gating jobs for this change). Ran the new test file locally: 17/17 pass. Mutation-checked: reverting the objective-achieved status override in _save.py (the core "no compromise filed as held" guard) fails 2 of the fail-closed tests — the tests are load-bearing, not vacuous.

Blocking — must resolve before this PR is done

Each item below also exists as a resolvable inline thread (linked). Resolve the thread to clear it.

  1. [correctness] python/scenario/report/_save.py:326-341 The two break-severity floor blocks run in a fixed order and the first silently consumes the sentinel the second needs. Block 1 (lines 326-330) turns any unrecognized break_severity — including the "" default left when the analyzer call raises — into "partial" for a broke run. Block 2 (lines 336-341), the PR's own "objective-achieved must floor at significant" guard, only fires when break_severity in {"", "none"} — but that's already been overwritten to "partial" by block 1. Net effect: an objective-achieved run (attack succeeded) whose analyzer raises (429/timeout) lands at break_severity="partial", not the "significant" the PR's own comment says this exact class of run must floor at. Verified locally — patching litellm.completion to raise on an objective-achieved result yields status="broke", break_severity="partial", analysis_failed=True. Not a fail-open bug (status is still correctly "broke", never "held"), but it silently downgrades urgency on precisely the run class this PR exists to floor highest, and it's untested — the existing test_new_reports_floor_at_significant only covers the analyzer succeeding with an explicit "none" opinion, never the exception path. -> thread: fix(report): red-team report fails closed — no green for unanalyzed runs, no vanished broken runs, no compromise filed as held #890 (comment)

Non-blocking (Decide / New Issue)

  • [review-clerk] JS-written objective-achieved reports never get floored to "significant" the way Python-analyzed ones do — javascript/src/red-team-report.ts writes break_severity="" for a broke run (JS never runs an analyzer at write time; analysis_pending: true appears to be a dead field — no consumer sets analysis_failed on JS-originated reports). The dashboard's _break_of status-fallback then reads that as "partial", not "significant". Both correctly avoid "held" (the PR's core fix), so this is a severity-magnitude/parity gap between the two writers, not a fail-open regression — worth a follow-up issue if JS/Python severity parity matters, out of scope for this PR to block on. (Decide)

Verdict is prose, not a GitHub approval. Scope: review findings only — READY means no unresolved blocking review threads at this SHA. It is not a merge-readiness signal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-green Latest run of every check is passing (checks API, not the legacy commit-status index) hound-checked Triaged by the pr-hound agent at the current head SHA hound-escalated Dispatched once to the code-review agent by pr-branch-hound P1 - high High priority, work on this soon review: deep PR Hound review mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Red-team report fails open: analyzer failures render green, JS/Python status mismatch hides broken runs, early-exit files won attacks as held

3 participants