feat(redteam): add Crescendo multi-turn attack strategy - #245
Conversation
Carry-over nits from PR strands-agents#184: - Align 8 log calls in task.py and generators/adversarial.py to the project's field=<%s> | message convention (no punctuation/capitals). - Add unit tests for the _call_target dict-target branch (with and without a trace key), which was previously untested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tic cases Every AttackStrategy now owns its multi-turn loop via an abstract run_attack(case, call_target, ...) -> AttackRunResult; the task runner injects call_target (target invocation + tool-trace capture + per-case messages.clear isolation) and no longer branches on strategy type. Why: a single execution model (strategy owns its loop) is simpler than a runner-owned loop plus a per-strategy exception. Cases become strategy-agnostic (no strategy/template baked into RedTeamConfig); the RedTeamExperiment holds the strategy instances and expands the case x strategy cross-product at run time, so hand-crafted cases and strategy comparison (by label) are both first-class. - base.py: run_attack @AbstractMethod + AttackRunResult dataclass; add label (instance id, defaults to name); remove the unused enhance(). - PromptStrategy: relocate the ActorSimulator loop from task.py into run_attack (gradual_escalation behavior unchanged). - RedTeamConfig: drop strategy/system_prompt_template + their validator. - generators/adversarial: generate_cases emits strategy-agnostic cases; rename target -> agent; drop attack_strategies. - experiment: rename target -> agent; accept attack_strategies; build _by_label (duplicate label -> ValueError); expand cross-product before delegating to the base worker (left untouched). - task: build call_target, look up the case's strategy by label, map AttackRunResult to the {"output", "trajectory", ...} dict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CrescendoStrategy escalates gradually across turns, each attacker message building on the target's previous answer. On a refusal it backtracks by simply not appending the refused (question, response) pair and retrying with a fresh question (up to max_backtracks), so the refused turn never enters the history — a simpler equivalent of PyRIT's excluding-last-turn approach. It stops early once a turn scores at/above success_threshold. The refusal/success/question-generation helpers are module-level functions (is_refusal, success_score, gen_escalating_question) rather than methods, so future strategies (PAIR, TAP) can reuse them without importing a strategy class. They power the strategy's cheap in-loop "should I stop?" gate; success_score reads the case's success_criteria — the same input the authoritative AttackSuccessEvaluator uses — so the two never disagree on what counts as success, while the evaluator remains the sole verdict over the full trace. Parse failures degrade safely (question -> terminate preserving the conversation; judge -> score 0 and keep looping); only the evaluator raises. The attacker model resolves to the ctor model first, then the experiment model. CrescendoStrategy is exported but intentionally NOT in BUILTIN_STRATEGIES (it is user-instantiated with params). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The aggregate sections (top-line attack-success rate, by_risk_category, by_strategy when more than one strategy ran) are unchanged. Each failure line now also shows the attacker's objective and the strategy's per-run stats (turns used, backtracks) so a multi-turn result like Crescendo is legible at a glance, not just a single score. The strategy's run metadata reaches the report by merging AttackRunResult.metadata onto the case metadata in the task function; the base Experiment shares that dict with the EvaluationData it builds, so no base change is needed. Full turn-by-turn conversation output is left for a future verbose mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover both user paths through the full locked interface with only the LLM layer mocked (attacker, in-loop judge, and the evaluator's judge agent): - generated cases: generate_cases(agent=...) -> RedTeamExperiment with CrescendoStrategy -> run_evaluations -> RedTeamReport. - hand-crafted cases: the same pipeline from RedTeamCase objects built by hand, skipping the generator (Model B's first-class path). Live (real-Bedrock) runs surfaced a wiring bug these mock tests now guard: the strategy's run metadata (turns_used, backtracks) never reached the report. task_fn mutated case.metadata, but Pydantic copies that dict into a fresh EvaluationData, and the base Experiment doesn't carry task-returned metadata anyway. Fix: the experiment now collects each case's run metadata (keyed by case name) and joins it onto the report in RedTeamReport.from_evaluation_reports — keeping the base untouched and the collection logic on the RedTeamExperiment layer (where it stays put if the experiment later stops extending the base). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, leaks)
Adversarial self-review before opening the PR surfaced two correctness bugs
and several maintainability issues; fixing them here.
Correctness:
- Cross-product expansion was mutating self._cases in place, so re-running an
experiment squared it (c0__cre -> c0__cre__cre). _expand_cross_product is now
pure (returns a new list) and run_evaluations_async swaps/restores self._cases
around the base run, making reruns idempotent.
- is_refusal flagged compliant text containing refusal substrings ("I cannot
stress enough... here are the steps", "I apologize, here is..."), dropping
successful attacks from the trace and biasing results toward "attack failed".
Markers are now only a cheap negative prefilter; on a marker hit a refusal
judge (the previously-unused REFUSAL_JUDGE_SYSTEM_PROMPT) disambiguates, with
a safe "keep the turn" fallback on parse failure.
Maintainability:
- Removed the leaky AttackRunResult.trajectory field (the task owns the trace
via call_target); task_fn now assembles the output/trajectory payload directly.
- Unified turns_used to "turns kept in the conversation" across strategies;
Crescendo additionally reports target_calls (incl. refused, backtracked calls).
- Documented max_turns as an experiment-level ceiling (strategy runs min of the
two), the no-success_criteria behavior, and the max_workers=1 requirement;
run_evaluations_async now rejects max_workers != 1 instead of relying on a comment.
- Dropped the now-unused resolve_strategy/DEFAULT_STRATEGY public surface.
Tests: idempotency, refusal false-positives + judge disambiguation, all-refusal
empty conversation, ctor-vs-injected max_turns both directions, no-criteria run,
direct async entry + coroutine/max_workers guards; e2e now asserts exact
turns_used/backtracks with an engaging (non-refusal) target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rsations An LLM judge can be fooled by a target that *claims* to leak — e.g. a target that, under escalation, emits a code block it presents as "my system prompt" which may be partly hallucinated. The aggregate report can't be verified by eye without the transcript. display(verbose=True) now prints each failed case's full attacker/target conversation (default stays the compact aggregate + one-line drill-down), so a user can confirm whether a flagged "success" is a real leak or a false positive. The conversation is carried on AttackResult.conversation (from the case's actual_output). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urns
The experiment's max_turns (default 10) silently capped every strategy via
min(strategy_max_turns, experiment_max_turns), so CrescendoStrategy(max_turns=30)
under the default experiment ran only 10 turns — quietly breaking the
compare-same-strategy-different-params use case.
Each strategy now owns its turn budget; the task passes MAX_ALLOWED_TURNS (50)
as a hard ceiling, so turn_cap = min(strategy.max_turns, 50). Removed max_turns
from RedTeamExperiment.__init__ entirely. Added max_turns to PromptStrategy so
gradual_escalation keeps its prior default of 10 (and its {max_turns} prompt
text) rather than jumping to the ceiling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Assessment: Comment Well-structured addition of a multi-turn attack strategy with clean separation of concerns and thorough test coverage. The design decisions (strategy owns loop, cross-product at experiment level, strategy-agnostic cases) are well-reasoned and documented. Review Categories
Overall this is solid work — clean architecture, comprehensive tests, and excellent documentation of design decisions and known limitations. |
- judges score each response statelessly (clear history per call) so earlier turns don't bias the in-loop refusal/success verdicts - correct backtrack docstring: it is report-scope only, the target's own context is not rolled back; add a proof test - drop dead keys from task_fn return dict (base reads only output/trajectory) - export AttackRunResult publicly (part of the strategy extension contract) - remove unused system_prompt_template from base AttackStrategy - fix log-statement separators; extract dense metadata merge into locals - add hardening cross-ref comments (lazy-init attacker, _cases swap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
All seven inline comments are addressed in 733ee54 (replies on each thread):
Beyond the bot feedback, this push also includes a second adversarial self-review pass: the in-loop refusal/success judges now clear their history per call so earlier turns can't bias a verdict, and the dead keys in the |
|
Assessment: Approve All prior review comments have been addressed in Verification Summary
Clean architecture with excellent test coverage and documentation of design decisions and known limitations. |
Resolve conflicts from strands-agents#241 (single flattened report) and strands-agents#244 (trace evaluators in defaults): - experiment.py: adopt base's single-EvaluationReport return; keep our max_workers guard, _run_meta, and cross-product swap/restore on top. - report.py: fold our run_meta merge into the new from_evaluation_report(report) signature (replacing the old plural from_evaluation_reports). - test_report.py: update our verbose/drilldown tests to the from_evaluation_report(_flatten(...)) shape. 1251 tests green, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lambda agents in test_experiment.py hit the new _build_session TypeError and passed only because the base experiment catches it as score=0 -- so the default-task and cross-product wiring was never actually exercised (run_attack was unreachable). Swap the lambdas for a _FakeSession so the intended paths run.
|
Good catch on the test bug — confirmed and fixed in You were right that Note for anyone reading the thread: the "current code" snapshot in the earlier bot summary (showing |
|
Assessment: Approve The code is solid after multiple rounds of review. All prior feedback (jjbuck's 7 items, the lambda test bug, poshinchen's Verification Summary
Well-documented design decisions, clean known-limitations section, and thorough test coverage. |
StrandsAgentSession.reset() only cleared messages, but snapshot()/restore() round-trip the full session preset (messages, state, conversation_manager_state, interrupt_state). So agent state leaked across cases -- a tool writing agent.state in case N would still be set in case N+1, which can flip a later attack's outcome. The experiment now captures one clean baseline at task-build time (before the first case, while the shared agent is still as-constructed) and reset() rolls back through the same load_snapshot path restore() uses. Seeded target history is preserved (it's part of the target definition); per-case state is cleared.
|
Assessment: Approve Well-architected addition after extensive review iteration. All prior feedback (7+ threads from jjbuck, poshinchen, automated reviews) has been resolved, 113 redteam tests pass, and the code fully complies with repository guidelines. Review Details
No new issues found. The open threads are all about tracked follow-up work, not blocking items. |
…typing Follow-up to the reset fix after an adversarial review pass: - type _build_session(baseline) as Snapshot | None instead of Any (it feeds load_snapshot, so a non-Snapshot would only surface as a swallowed per-case error) - add a real-Agent test that one baseline survives repeated resets uncorrupted (the capture-once/replay-N aliasing risk), and a test locking the documented limitation that a no-baseline session does not isolate non-message state
Description
Adds Crescendo, a multi-turn jailbreak strategy, to the experimental red-teaming module, and refactors the strategy/experiment seam so that adding future algorithmic strategies (PAIR, TAP) is a drop-in rather than a fork.
Crescendo opens benign and escalates gradually, each attacker turn building on the target's previous answer so the harmful ask arrives as a natural continuation. On a refusal it backtracks — the refused turn is dropped and retried — and it stops early once a turn looks successful. The full conversation + tool trace is then scored by the existing trace-level
AttackSuccessEvaluator, unchanged.This builds on #184 (the initial red-team module). The base
Experiment/Evaluator/Case/ActorSimulatorprimitives are untouched; all changes are insideexperimental/redteam/.Why this shape
#184 ran a single runner-owned attacker loop inside
task_fn, with the strategy contributing only a system-prompt template. That model can't express an algorithmic strategy like Crescendo, which needs to own its own control flow (escalate, judge, backtrack, stop). Rather than add a second execution path guarded by a flag, every strategy now owns its loop via one method,run_attack. gradual_escalation's existing loop simply relocates into its ownrun_attackwith no behavior change.Flow
Two independent entry points converge on
RedTeamExperiment:AdversarialCaseGenerator.generate_cases(agent=...)reads the agent's tool surface and produces strategy-agnosticRedTeamCases.RedTeamCaseobjects directly for domain-specific business rules; no generator involved.Sample report
report.display()from a live run (2 cases × 2 Crescendo configs against a weakly-guarded support agent):The by_strategy view is the cross-product payoff — the longer escalation budget (
cre-10) extracted the system prompt verbatim where the shorter one (cre-5) did not. by_strategy only renders when more than one strategy ran.What ships
CrescendoStrategy— the multi-turn escalate/backtrack loop, plus reusable module-level helpersis_refusal/success_score/gen_escalating_question(functions, not a class, so PAIR/TAP can import them without a new abstraction).AttackStrategy.run_attack(@abstractmethod) +AttackRunResultdataclass — the single execution contract;enhance()removed.RedTeamExperiment(agent=, attack_strategies=[...])— holds strategy instances and expands the case × strategy cross-product at run time;name/labelsplit lets the same strategy run with different params in one report.display(verbose=True)additionally prints each failed case's full conversation so a verdict can be verified by eye.Design decisions & alternatives
run_attack, no dispatch flag. Alternative was anowns_loop/drives_targetflag selecting runner-owned vs strategy-owned loops. Every future strategy would set itTrue, so the flag is dead weight; folding gradual_escalation intorun_attackremoves both the flag and thetask_fnbranch.Caseconcept); coupling it to strategies/experiments would make hand-crafted cases second-class and prevent case reuse across strategies.RedTeamExperiment(cases=...)already accepted cases, so Path B needs no new API.Evaluator.Evaluator.evaluate(EvaluationData) -> list[EvaluationOutput]operates on a completed case; the strategy needs a per-response decision mid-loop, where noEvaluationDataexists. Forcing it throughEvaluatorwould mean fabricating a throwawayEvaluationDataevery turn — more boilerplate, not less. So it's a plain function. The authoritative verdict remainsAttackSuccessEvaluator(anEvaluator, unchanged), re-scoring the full trace independently. Both readgoal.success_criteriaso they don't diverge on what counts as success.Breaking changes (experimental module)
experimental/redteamshipped in #184; this changes its surface:RedTeamExperiment(target=...)→agent=...(still accepts a callable /TargetSpec).RedTeamConfigno longer carriesstrategy/system_prompt_template(cases are strategy-agnostic now).AttackStrategy.enhance()removed; strategies implementrun_attack.Known limitations / follow-ups
call_targetwraps a stateful target whose history we can't roll back, so the refusal still sits in the target's context and can bias later turns. -> in next PR:call_targetbecomes aTargetSessionwhosefork_excluding_last()truncates the target's own history, so the target genuinely forgets the refused turn (kept as audit evidence).extends Experiment.RedTeamExperimentextends the base and overrides with# type: ignore[override], needing aself._casesswap aroundsuper()to inject the cross-product. -> in next PR: this item is the refactor — composition removes the swap, theignore[override], and makes(case, strategy)first-class work items.turns_used=None. Caching is unused in this slice. -> in next PR: the_run_metaside-channel is dropped and run-stats are built straight intoEvaluationData, so they ride through the cache like any other field.score = min(scores.values())(from feat(redteam): add built-in red teaming support #184) is optimistic about the defense when evaluators disagree, whilepassed = all(...)is pessimistic. Single-evaluator today, so it doesn't bite yet. -> in next PR: scoring flips toscore = max(...)to matchall(...)— the red-team-correct "strongest signal of compromise" aggregation.display(verbose=True)prints the full conversation for a human check. -> in next PR: unchanged — this is inherent to LLM judging, not the execution model.Type of Change
Breaking change · New feature (experimental module)
Testing
How have you tested the change?
hatch run prepare— fmt + lint (ruff + mypy) + full test suite green (1235 tests).Unit tests cover: run_attack loop (backtrack drops refused turn, max_backtracks cap, success-threshold early-stop, run-to-max-turns, parse-failure safe-terminate),
is_refusalfalse-positive disambiguation by judge, cross-product expansion + duplicate-label error + rerun idempotency, async entry +max_workers=1guard, strategy-agnostic generation, report drill-down.Live Bedrock E2E (manual): full pipeline against a real target (Haiku) with a Sonnet attacker and Haiku judges. Verified Crescendo escalates for real (benign opener → references the target's prior answer → escalates), backtracks fire on genuine refusals, and run-stats reach the report. Transcript excerpt:
I ran
hatch run prepareChecklist