Skip to content

refactor: unify agent and mcp attack loops behind one attack runner#152

Merged
jithin23-kv merged 3 commits into
masterfrom
refactor-attack-runner
Jul 2, 2026
Merged

refactor: unify agent and mcp attack loops behind one attack runner#152
jithin23-kv merged 3 commits into
masterfrom
refactor-attack-runner

Conversation

@prasanth-nair-kv

@prasanth-nair-kv prasanth-nair-kv commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What & why

The attack turn loop existed twicerunAgentAttack and a 134-line
runMcpAttack embedded in runAll
— sharing a control flow but free to drift.
This introduces a Template Method: runAttack owns the invariant loop
(build → execute → record → shouldEarlyStop, then finalize once), and an
AttackDriver fills the kind-specific holes.

  • attackRunner.tsAttackDriver<TInput,TOutput> + runAttack (a stateless
    function; no needless class).
  • agentAttackDriver.tsAgentAttackDriver, agent logic moved verbatim.
  • mcpAttackDriver.tsMcpAttackDriver + runMcpAttack, extracted out of
    runAll
    (985 → ~840 lines). The MCP mid-turn and final judge blocks
    (previously duplicated verbatim) are consolidated into one judge() helper.
  • runAgentLoop.tsrunAgentAttack is now a thin wrapper with an
    unchanged signaturerunAll/runAllBrowser/SDK/extension untouched.

Behavior-preserving

Agent target-error early-stop + judge-once + target.close(); MCP mid-turn judge
only when turns>1 && t<turns with priorTurns = history.slice(0,-1),
early-stop-on-FAIL, sanitizeJudgeResult, empty-tool short-circuit, and result
shapes are all reproduced. Guarded by the runAll.smoke + orchestrator.equivalence
drift tests, plus new attackRunner (Template-Method contract) and
mcpAttackDriver tests — the MCP loop had zero coverage before.

Clean-code (hardened after review)

  • Drivers take narrowly-injected deps (run-level attacker model, judge config)
    rather than the whole RunConfig; no per-attack model rebuild.
  • Agent driver tracks the previous technique as a scalar, not a per-turn meta
    array (dead lastReplyHook + alignment ceremony removed).
  • The Judge SPI is intentionally deferred — each driver owns its judging, and
    the agent/MCP judges take different model types (LanguageModel vs LlmConfig);
    unifying that is hunt-work (PR13).

Deferred (logged in the plan)

Pre-existing latent target.close() lifecycle (shared-target / no try-finally) and
fake-LLM test-harness consolidation.

Verification

full suite 117 tests, 0 fail (3 skips) · typecheck 0 · lint 0 · full build (incl.
browser bundle) passes.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced shared multi-turn attack execution with resumable runs, per-turn judging, and early stopping on target errors.
    • Added dedicated execution support for agent-based and MCP tool-call attacks, including adaptive turn generation and consistent final verdicts.
    • Enhanced judge observability by attaching trace context when trace enrichment is enabled.
  • Tests
    • Added and expanded tests covering attack execution order, resuming behavior, MCP single/multi-turn outcomes, and early-stop logic.

The turn loop existed twice — runAgentAttack and a 134-line runMcpAttack embedded
in runAll — sharing a control flow but drifting in detail. Introduce a Template
Method: runAttack owns the invariant loop (build -> execute -> record ->
shouldEarlyStop, then finalize once), and an AttackDriver fills the kind-specific
holes.

- attackRunner.ts: AttackDriver<TInput,TOutput> interface + runAttack (a stateless
  function; no needless class).
- agentAttackDriver.ts: AgentAttackDriver — agent logic moved verbatim.
- mcpAttackDriver.ts: McpAttackDriver + runMcpAttack — extracted out of runAll
  (runAll: 985 -> ~840 lines). The MCP mid-turn and final judge blocks, previously
  duplicated verbatim, are consolidated into one private judge() helper.
- runAgentLoop.ts: runAgentAttack is now a thin wrapper with an unchanged
  signature, so runAll/runAllBrowser/SDK/extension callers are untouched.

Behavior-preserving: agent target-error early-stop + judge-once + target.close();
MCP mid-turn judge only when turns>1 && t<turns with priorTurns=history.slice(0,-1),
early-stop-on-FAIL, sanitizeJudgeResult, empty-toolName short-circuit, and result
shapes are all reproduced. Guarded by the runAll.smoke + orchestrator.equivalence
drift tests plus new attackRunner + mcpAttackDriver tests (the MCP loop had no
coverage before).

Drivers take narrowly-injected dependencies (the run-level attacker model, the
judge config) rather than the whole RunConfig; the agent driver tracks the
previous technique as a scalar instead of a full per-turn meta array. The Judge
SPI is intentionally deferred — each driver owns its judging, and the agent/MCP
judges take different model types, which the SPI will unify in the hunt work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jithin23-kv, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d82e1043-475a-4306-9208-332b5176b492

📥 Commits

Reviewing files that changed from the base of the PR and between 106f837 and ed1fdfa.

📒 Files selected for processing (2)
  • core/src/execute/agentAttackDriver.ts
  • core/tests/agentAttackDriver.test.ts

Walkthrough

This PR extracts a shared attack runner, moves agent and MCP attack orchestration into dedicated drivers, rewires the existing entry points to use them, and adds runner and MCP driver tests.

Changes

Attack Runner Refactor

Layer / File(s) Summary
AttackDriver interface and template method
core/src/execute/attackRunner.ts
Defines AttackDriver<TInput, TOutput> and runAttack, which loops turns through build, execute, record, early-stop, and finalize hooks.
AgentAttackDriver implementation and runAgentLoop wiring
core/src/execute/agentAttackDriver.ts, core/src/execute/runAgentLoop.ts
Adds AgentAttackContext and AgentAttackDriver for adaptive agent turns, target execution, history tracking, target-error early stop, judge finalization, and telemetry-backed observability; runAgentAttack becomes a thin wrapper.
McpAttackDriver implementation and runAll wiring
core/src/execute/mcpAttackDriver.ts, core/src/execute/runAll.ts
Adds McpAttackDriver and runMcpAttack for MCP tool-call attacks, updates judge model selection and MCP dispatch in runAll.ts, and removes the old inline MCP runner.
Tests
core/tests/attackRunner.test.ts, core/tests/mcpAttackDriver.test.ts
Adds tests for runAttack lifecycle behavior and runMcpAttack empty-tool, single-turn, early-stop, and continuation cases.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant runAttack
  participant AttackDriver
  participant Target
  participant Judge

  Caller->>runAttack: runAttack(driver)
  loop each turn
    runAttack->>AttackDriver: buildTurn(turn)
    runAttack->>AttackDriver: execute(input)
    AttackDriver->>Target: send/callTool
    Target-->>AttackDriver: response/result
    runAttack->>AttackDriver: record(turn, input, output)
    runAttack->>AttackDriver: shouldEarlyStop(...)
    alt early stop
      runAttack->>runAttack: break loop
    end
  end
  runAttack->>AttackDriver: finalize()
  AttackDriver->>Judge: judgeResponse/judgeToolResponse
  Judge-->>AttackDriver: verdict
  AttackDriver-->>Caller: AttackResult
Loading

Possibly related PRs

Suggested reviewers: jithin23-kv, achuvyas-kv, arunSunnyKVS

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main refactor: unifying agent and MCP attack loops behind a shared runner.
Description check ✅ Passed The description is detailed and covers problem, solution, changes, and testing, but it does not follow the template headings and omits a clear Issue section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor-attack-runner

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.

@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: 3

🧹 Nitpick comments (3)
core/tests/mcpAttackDriver.test.ts (1)

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

Gap: no test exercises turn continuation (mid-turn PASS → turn 2).

All three tests either single-turn or early-stop at turn 1, so buildTurn's turn>1 branch (generateNextMcpTurn call, judgeHint update) and the populated priorTurns branch in judge() (mcpAttackDriver.ts Line 148) are never exercised. A 2-turn scenario where turn 1 returns PASS and turn 2 is judged would cover this.

Given the existing local server already backs both attackModel and judgeLlm (deps()), extending its handler to also serve a turn-generation response looks feasible without a real attacker LLM.

🤖 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 `@core/tests/mcpAttackDriver.test.ts` around lines 149 - 180, The MCP attack
driver tests are missing coverage for the continued multi-turn path, so add a
2-turn case that keeps turn 1 as PASS and then exercises turn 2. Update the
existing test setup in mcpAttackDriver.test.ts using the current deps()/local
server so the attack model can return a generated next turn, then assert that
buildTurn() takes the turn > 1 branch, generateNextMcpTurn() is used, judgeHint
is updated, and judge() receives populated priorTurns. This should complement
the existing single-turn and early-stop tests without changing their assertions.
core/src/execute/runAll.ts (1)

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

Duplicate config.judgeLlm ?? config.attackerLlm computation.

Same raw fallback expression is computed independently at Line 100 (baseline scans) and Lines 215-216 (MCP dispatch), in different block scopes. Hoisting it once near Line 69 avoids drift if the fallback logic changes.

♻️ Proposed refactor
   const attackModel = resolveModel(config.attackerLlm);
   const judgeModel = resolveModel(config.judgeLlm ?? config.attackerLlm);
+  const judgeLlmConfig = config.judgeLlm ?? config.attackerLlm;
-      const judgeModelConfig = config.judgeLlm ?? config.attackerLlm;
+      const judgeModelConfig = judgeLlmConfig;
               ? await runMcpAttack(
                   attack,
                   mcpTarget!,
                   attackModel,
-                  config.judgeLlm ?? config.attackerLlm
+                  judgeLlmConfig
                 )

Also applies to: 211-216

🤖 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 `@core/src/execute/runAll.ts` at line 100, The fallback selection for the judge
model is duplicated as config.judgeLlm ?? config.attackerLlm in both the
baseline scans path and the MCP dispatch path. Hoist this logic into a single
shared variable in runAll near the top of the function, then reuse that variable
wherever judgeModelConfig is needed so the fallback behavior stays consistent
and only needs to be changed in one place.
core/src/execute/mcpAttackDriver.ts (1)

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

Non-actionable error messages.

"no toolName in attack spec" and "no turns completed" describe the failure but not the fix. Per coding guidelines, error messages should be actionable.

✏️ Proposed fix
-      toolError: "no toolName in attack spec",
-      judge: mcpErrorJudge("no toolName in attack spec"),
+      toolError: "MCP attack spec is missing a toolName — set `toolName` to one of the target's discovered tools.",
+      judge: mcpErrorJudge(
+        "MCP attack spec is missing a toolName — set `toolName` to one of the target's discovered tools."
+      ),
-      finalJudge = mcpErrorJudge("no turns completed");
+      finalJudge = mcpErrorJudge(
+        "MCP attack completed no turns — verify `attack.turns` is a positive integer."
+      );

Note: core/tests/mcpAttackDriver.test.ts (Line 154) asserts the exact string "no toolName in attack spec", so the test would need updating alongside this change.

As per coding guidelines, "Error messages must be actionable: tell the user what to fix, not just what went wrong."

Also applies to: 163-181

🤖 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 `@core/src/execute/mcpAttackDriver.ts` around lines 93 - 106, The error strings
used by mcpAttackDriver are not actionable, so update the messages in finalize()
and the related attack-spec validation path to tell the user what to fix instead
of only stating the failure. Adjust the mcpErrorJudge callers and any validation
that currently emits "no toolName in attack spec" or "no turns completed" to
include the missing requirement or next step. Update
core/tests/mcpAttackDriver.test.ts to match the new actionable messages,
especially the assertion around the toolName validation.

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 `@core/src/execute/agentAttackDriver.ts`:
- Around line 38-39: The resumed attack flow in AgentAttackDriver is not
preserving prior turn records, so final results can report missing turns or fail
with no turns completed. Update the AgentAttackDriver state and finalization
logic to seed this.turns from the resumed ConversationHistory/initialHistory, or
derive the returned turns from the full history before calling errorJudge and
building the result. Make sure the logic in the constructor/state setup and the
completion path that returns turns uses the combined transcript, not only turns
executed in the current invocation.
- Line 145: The no-turns branch in agentAttackDriver is too vague, so update the
error passed through errorJudge("no turns completed") to tell the user what to
correct or retry. Use the no-turns path in execute/agentAttackDriver and make
the message actionable by naming the likely fix in the current flow, so the
caller gets guidance instead of only a failure state.

In `@core/src/execute/attackRunner.ts`:
- Around line 13-29: The attack runner currently skips cleanup when buildTurn,
execute, record, or shouldEarlyStop throws, so AgentAttackDriver.finalize() is
never reached and target.close() is not guaranteed. Add a dedicated cleanup
method on AttackDriver/AgentAttackDriver for resource shutdown, move
target.close() out of finalize(), and call the new cleanup from a finally block
in the runner so it always executes even on failed turns.

---

Nitpick comments:
In `@core/src/execute/mcpAttackDriver.ts`:
- Around line 93-106: The error strings used by mcpAttackDriver are not
actionable, so update the messages in finalize() and the related attack-spec
validation path to tell the user what to fix instead of only stating the
failure. Adjust the mcpErrorJudge callers and any validation that currently
emits "no toolName in attack spec" or "no turns completed" to include the
missing requirement or next step. Update core/tests/mcpAttackDriver.test.ts to
match the new actionable messages, especially the assertion around the toolName
validation.

In `@core/src/execute/runAll.ts`:
- Line 100: The fallback selection for the judge model is duplicated as
config.judgeLlm ?? config.attackerLlm in both the baseline scans path and the
MCP dispatch path. Hoist this logic into a single shared variable in runAll near
the top of the function, then reuse that variable wherever judgeModelConfig is
needed so the fallback behavior stays consistent and only needs to be changed in
one place.

In `@core/tests/mcpAttackDriver.test.ts`:
- Around line 149-180: The MCP attack driver tests are missing coverage for the
continued multi-turn path, so add a 2-turn case that keeps turn 1 as PASS and
then exercises turn 2. Update the existing test setup in mcpAttackDriver.test.ts
using the current deps()/local server so the attack model can return a generated
next turn, then assert that buildTurn() takes the turn > 1 branch,
generateNextMcpTurn() is used, judgeHint is updated, and judge() receives
populated priorTurns. This should complement the existing single-turn and
early-stop tests without changing their assertions.
🪄 Autofix (Beta)

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

Run ID: 0d51ee90-b2e2-4260-a6d0-051269969425

📥 Commits

Reviewing files that changed from the base of the PR and between 51d3817 and d3fb2a2.

📒 Files selected for processing (7)
  • core/src/execute/agentAttackDriver.ts
  • core/src/execute/attackRunner.ts
  • core/src/execute/mcpAttackDriver.ts
  • core/src/execute/runAgentLoop.ts
  • core/src/execute/runAll.ts
  • core/tests/attackRunner.test.ts
  • core/tests/mcpAttackDriver.test.ts

Comment thread core/src/execute/agentAttackDriver.ts
Comment thread core/src/execute/agentAttackDriver.ts
Comment thread core/src/execute/attackRunner.ts
@jithin23-kv

Copy link
Copy Markdown
Collaborator

Changes made

  • (dup judge-llm fallback): hoisted config.judgeLlm ?? config.attackerLlm into a single judgeLlmConfig in runAll, reused at the baseline-scan and MCP-dispatch sites. This PR had newly added a 3rd copy of the expression.
  • (missing multi-turn test): added a continuation test (turn-1 PASS → turn-2 generator → judge last turn) that covers buildTurn's turn>1 branch and the populated priorTurns path — previously untested. Fake server now routes generator vs judge requests by a marker unique to the attacker prompt.

@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.

🧹 Nitpick comments (1)
core/tests/mcpAttackDriver.test.ts (1)

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

Use a shared marker for the mock routing.
Matching on the literal "escalate the attack" ties this test to the current prompt text in generateNextMcpTurn, so a wording tweak can misroute generator calls to the judge path. Export a shared marker constant or use a dedicated header instead.

🤖 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 `@core/tests/mcpAttackDriver.test.ts` around lines 60 - 83, Use a shared
routing marker for the mock endpoint instead of matching the literal prompt text
in mcpAttackDriver.test.ts. The current body check in the test server depends on
the exact wording from generateNextMcpTurn, so update the test and the
turn-generation path to share a dedicated constant or request header that
clearly identifies attacker turn-generation calls. Keep the routing logic in the
server handler and the generateNextMcpTurn prompt aligned through that shared
symbol.
🤖 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.

Nitpick comments:
In `@core/tests/mcpAttackDriver.test.ts`:
- Around line 60-83: Use a shared routing marker for the mock endpoint instead
of matching the literal prompt text in mcpAttackDriver.test.ts. The current body
check in the test server depends on the exact wording from generateNextMcpTurn,
so update the test and the turn-generation path to share a dedicated constant or
request header that clearly identifies attacker turn-generation calls. Keep the
routing logic in the server handler and the generateNextMcpTurn prompt aligned
through that shared symbol.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: deb32197-fc49-4c42-8d27-cacb5fa1706b

📥 Commits

Reviewing files that changed from the base of the PR and between d3fb2a2 and 106f837.

📒 Files selected for processing (2)
  • core/src/execute/runAll.ts
  • core/tests/mcpAttackDriver.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/src/execute/runAll.ts

@jithin23-kv

Copy link
Copy Markdown
Collaborator

Additional change

(resume drops prior turns): live bug via the extension's pause/resume — on resume the driver dropped all pre-pause turns from the reported transcript (and a resume-at-last-turn returned "no turns completed"). AgentAttackDriver now seeds turns[] from initialHistory. Added agentAttackDriver.test.ts covering both cases.

@jithin23-kv
jithin23-kv merged commit 682c466 into master Jul 2, 2026
7 of 8 checks passed
@jithin23-kv
jithin23-kv deleted the refactor-attack-runner branch July 2, 2026 06:02
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.

3 participants