Skip to content

feat: graceful shutdown on ctrl+c during opfor run#211

Open
arunSunnyKVS wants to merge 3 commits into
masterfrom
feat/graceful-shutdown-on-sigint
Open

feat: graceful shutdown on ctrl+c during opfor run#211
arunSunnyKVS wants to merge 3 commits into
masterfrom
feat/graceful-shutdown-on-sigint

Conversation

@arunSunnyKVS

@arunSunnyKVS arunSunnyKVS commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

opfor run has no SIGINT handler. When a user presses Ctrl+C mid-run, Node terminates immediately — all progress is lost, no partial report is written, the runAll finally block (MCP target cleanup) may not execute, and the --events NDJSON stream is not flushed.

Solution

Thread an AbortSignal through the core engine so the evaluator loop checks for cancellation at safe points (between evaluators, between attacks). The in-flight attack is allowed to finish, completed results are preserved, and a partial report is written with stopReason: "user-interrupted".

The CLI wires SIGINT to the abort controller with two-press behavior:

  • First Ctrl+C: abort signal fires, engine finishes current work, writes a partial report, exits cleanly.
  • Second Ctrl+C: process.exit(130) — immediate force kill.

The signal lives on RunAllOptions (not RunConfig), keeping the core engine transport-agnostic — the SDK can reuse it for programmatic cancellation.

Changes

  • core/src/execute/runAll.tssignal?: AbortSignal added to RunAllOptions, passed through to runEvaluatorAttacks
  • core/src/execute/evaluatorLoop.ts — Two abort checkpoints: before each evaluator, before each attack within an evaluator
  • runners/cli/src/commands/run.ts — SIGINT handler with two-press behavior, partial report messaging, handler cleanup in finally
  • tests/e2e/agents/customer-support/Dockerfile — Fixed broken build (referenced workspace not in root package.json); simplified to standalone install
  • tests/e2e/agents/customer-support/docker-compose.yml — Build context scoped to agent directory
  • docs/cli.md — Added "Graceful shutdown (Ctrl+C)" section
  • AGENTS.md — Added cancellation note to run loop docs

Issue

Closes #209

How to test

  1. Start the customer-support test agent:
    cd tests/e2e/agents/customer-support && ./scripts/start.sh
  2. Run opfor and press Ctrl+C during an attack:
    opfor run --config tests/e2e/agents/customer-support/opfor.config.json
    # wait for an attack to start, then press Ctrl+C
  3. Verify:
    • Message: "Caught interrupt — finishing in-flight attack, skipping remaining evaluators…"
    • In-flight attack completes and is judged
    • Partial report is written to .opfor/reports/
    • Message: "Run interrupted — results are partial. Re-run for a complete assessment."
  4. Run again without interrupting to confirm no regression.
  5. Press Ctrl+C twice quickly to verify force-kill on second press.

Screenshots

[attacker] turn=3/3 technique=authority-claim hook=…

Caught interrupt — finishing in-flight attack, skipping remaining evaluators…
Press Ctrl+C again to force quit.

 ✓ PASS (score 10/10)

⏹ Run interrupted — skipping remaining evaluators

Writing partial report…

Results: 1 passed, 0 failed, 0 errors

⚠️  Run interrupted — results are partial. Re-run for a complete assessment.
Safety score: 100%

Report: .opfor/reports/run-report-…/customer-support-report.html
   JSON: .opfor/reports/run-report-…/customer-support-report.json

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Added graceful cancellation for evaluations through the SDK and CLI.
    • The first Ctrl+C finishes the active attack, skips remaining work, and produces a partial report.
    • A second Ctrl+C force-stops the run.
    • Partial reports identify the interruption and include completed evaluator results.
  • Documentation

    • Documented cancellation behavior, CLI shutdown controls, and partial report handling.
  • Chores

    • Updated customer-support end-to-end test container build configuration.

arunSunnyKVS and others added 3 commits July 21, 2026 17:00
Thread an AbortSignal through RunAllOptions → EvaluatorLoopContext so
the evaluator loop checks for cancellation between evaluators and
between attacks. The in-flight attack finishes, completed results are
kept, and a partial report is written with stopReason: "user-interrupted".

CLI behavior:
- First Ctrl+C: abort signal fires, engine finishes current work,
  writes a partial report, exits cleanly.
- Second Ctrl+C: process.exit(130) — immediate force kill.
- Handler is cleaned up in a finally block.

Also fixes the customer-support test agent Dockerfile which referenced
a workspace not registered in the root package.json.

Closes #209

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds graceful Ctrl+C cancellation to opfor run, propagating AbortSignal through the execution engine and producing partial reports marked user-interrupted. The customer-support E2E Docker build also switches to a local build context.

Changes

Graceful run cancellation

Layer / File(s) Summary
Engine cancellation propagation
core/src/execute/evaluatorLoop.ts, core/src/execute/runAll.ts
AbortSignal is accepted and forwarded through the execution loop, which finishes in-flight work, skips remaining attacks and evaluators, and returns partial results with stopReason: "user-interrupted".
CLI shutdown and reporting
runners/cli/src/commands/run.ts, docs/cli.md, AGENTS.md
The first Ctrl+C requests graceful cancellation, the second exits with code 130, and interrupted runs log and document partial-report behavior.

Customer-support E2E build

Layer / File(s) Summary
Local Docker build configuration
tests/e2e/agents/customer-support/Dockerfile, tests/e2e/agents/customer-support/docker-compose.yml
The service uses its local Docker context and installs dependencies from root manifests before copying agent files into /app.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant runAll
  participant EvaluatorLoop
  CLI->>runAll: start with AbortSignal
  runAll->>EvaluatorLoop: execute evaluators and attacks
  CLI->>AbortSignal: abort on first Ctrl+C
  EvaluatorLoop-->>runAll: return partial results
  runAll-->>CLI: partial report with user-interrupted stopReason
Loading

Possibly related PRs

Suggested reviewers: jithin23-kv

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: graceful Ctrl+C shutdown for opfor run.
Description check ✅ Passed The description matches the template and covers problem, solution, changes, issue, testing, and screenshots.
Linked Issues check ✅ Passed The changes implement graceful SIGINT cancellation, partial reporting, and second-press exit for #209.
Out of Scope Changes check ✅ Passed The docs and customer-support Docker changes are directly related to the shutdown feature and test setup.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/graceful-shutdown-on-sigint

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
tests/e2e/agents/customer-support/Dockerfile

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

tests/e2e/agents/customer-support/docker-compose.yml

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@arunSunnyKVS
arunSunnyKVS marked this pull request as ready for review July 21, 2026 11:40
@arunSunnyKVS
arunSunnyKVS requested a review from jithin23-kv July 21, 2026 11:42
@arunSunnyKVS arunSunnyKVS self-assigned this Jul 21, 2026
@arunSunnyKVS
arunSunnyKVS requested a review from achuvyas-kv July 21, 2026 11:42

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

Caution

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

⚠️ Outside diff range comments (1)
core/src/execute/evaluatorLoop.ts (1)

237-238: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate stopReason if interrupted during the last evaluator.

If the user interrupts the run during the final evaluator's attacks, the inner loop breaks, and the outer loop finishes its final iteration. The function then reaches this return statement without returning a stopReason or emitting the run_stopped event. This causes the CLI to silently treat the cancelled run as completed.

Check the signal state at the end of the function to capture this scenario.

🐛 Proposed fix
-  return { evaluatorResults };
+  if (signal?.aborted) {
+    const stopReason = "user-interrupted";
+    notify({ type: "run_stopped", reason: stopReason });
+    return { evaluatorResults, stopReason };
+  }
+
+  return { evaluatorResults };
🤖 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/evaluatorLoop.ts` around lines 237 - 238, Update the
evaluator loop function before its final return to check the signal/interruption
state after the last evaluator completes. If interrupted, propagate the
appropriate stopReason and emit the run_stopped event using the existing
interruption-handling path; otherwise preserve the current evaluatorResults
return behavior.
🧹 Nitpick comments (2)
tests/e2e/agents/customer-support/Dockerfile (1)

7-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider using npm ci instead of npm install for reproducible builds.

If a package-lock.json is available in this local directory, using npm ci is recommended in CI/CD and Docker environments to ensure a clean, reproducible installation matching the lockfile exactly.

♻️ Proposed refactor
-RUN npm install --ignore-scripts
+RUN npm ci --ignore-scripts
🤖 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 `@tests/e2e/agents/customer-support/Dockerfile` around lines 7 - 11, Update the
Dockerfile’s dependency installation command from npm install to npm ci while
preserving the existing --ignore-scripts option, so the build uses the
package-lock.json deterministically.
docs/cli.md (1)

210-212: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Specify a language for the fenced code block.

Adding a language tag (like text) resolves markdown linter warnings and improves rendering consistency.

♻️ Proposed fix
-```
+```text
 ⚠️  Run interrupted — results are partial. Re-run for a complete assessment.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

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

In @docs/cli.md around lines 210 - 212, Update the fenced code block containing
the interrupted-run message in the CLI documentation to specify the text
language, using a text fence while preserving the message content unchanged.


</details>

<!-- cr-comment:v1:8cbc233c1a71209bd5541851 -->

_Source: Linters/SAST tools_

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

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 @runners/cli/src/commands/run.ts:

  • Around line 222-223: Update the isPartial calculation in the run command to
    return true only when report.stopReason equals "user-interrupted", rather than
    for any truthy stop reason. Preserve the existing conditional flow so
    target-related stop reasons continue to reach the later unreachable-target
    warnings.

Outside diff comments:
In @core/src/execute/evaluatorLoop.ts:

  • Around line 237-238: Update the evaluator loop function before its final
    return to check the signal/interruption state after the last evaluator
    completes. If interrupted, propagate the appropriate stopReason and emit the
    run_stopped event using the existing interruption-handling path; otherwise
    preserve the current evaluatorResults return behavior.

Nitpick comments:
In @docs/cli.md:

  • Around line 210-212: Update the fenced code block containing the
    interrupted-run message in the CLI documentation to specify the text language,
    using a text fence while preserving the message content unchanged.

In @tests/e2e/agents/customer-support/Dockerfile:

  • Around line 7-11: Update the Dockerfile’s dependency installation command from
    npm install to npm ci while preserving the existing --ignore-scripts option, so
    the build uses the package-lock.json deterministically.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: Organization UI

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `77c2dc9d-02cc-4afa-b89b-e71be93b73a7`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c8c9f72c4d7246716208804457226a687dc31a89 and db11d39f273258c9dd9d97310ade5b8f942e275e.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `AGENTS.md`
* `core/src/execute/evaluatorLoop.ts`
* `core/src/execute/runAll.ts`
* `docs/cli.md`
* `runners/cli/src/commands/run.ts`
* `tests/e2e/agents/customer-support/Dockerfile`
* `tests/e2e/agents/customer-support/docker-compose.yml`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +222 to +223
const isPartial = !!(report as { stopReason?: string }).stopReason;
if (isPartial) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Narrow isPartial to avoid suppressing target-error warnings.

The engine can also populate stopReason with target-related error messages (e.g., when a TargetStopError halts the run early). Checking for any truthy stopReason will falsely flag error-halted runs as "user-interrupted" and suppress the helpful else if warnings at the bottom about the target potentially being unreachable.

Check specifically for "user-interrupted".

🐛 Proposed fix
-        const isPartial = !!(report as { stopReason?: string }).stopReason;
+        const isPartial = (report as { stopReason?: string }).stopReason === "user-interrupted";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isPartial = !!(report as { stopReason?: string }).stopReason;
if (isPartial) {
const isPartial = (report as { stopReason?: string }).stopReason === "user-interrupted";
if (isPartial) {
🤖 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 `@runners/cli/src/commands/run.ts` around lines 222 - 223, Update the isPartial
calculation in the run command to return true only when report.stopReason equals
"user-interrupted", rather than for any truthy stop reason. Preserve the
existing conditional flow so target-related stop reasons continue to reach the
later unreachable-target warnings.

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.

feat: graceful shutdown on Ctrl+C during opfor run

1 participant