feat: add resume/checkpoint system for interrupted scans

- New strix/telemetry/checkpoint.py: Pydantic CheckpointModel + CheckpointManager
  with atomic writes (.tmp → rename), non-fatal errors, target-hash validation
- base_agent.py: save checkpoint after every iteration (root agents only),
  delete on clean completion, guard against duplicate task message on resume
- main.py: add --run-name, --resume, --new/--force-new CLI flags;
  _setup_checkpoint_on_args() handles load/validate/corrupt-recovery
- cli.py: resume banner, history replay (previous vulns + last 3 thoughts),
  restore AgentState with fresh sandbox + extended max_iterations budget
- tui.py: pre-populate tracer from checkpoint, restore AgentState in agent_config
- README.md: add "Resuming Interrupted Scans" section with usage examples

Original scan behavior is 100% preserved when --run-name is not used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmex000 2026-03-19 07:30:12 +01:00
parent f0f8f3d4cc
commit 8a86a92e48
6 changed files with 454 additions and 5 deletions

View file

@ -177,6 +177,39 @@ strix --target api.your-app.com --instruction "Focus on business logic flaws and
strix --target api.your-app.com --instruction-file ./instruction.md
```
### Resuming Interrupted Scans
Long scans can be interrupted by Ctrl+C, crashes, power loss, or Docker issues.
Strix automatically saves a checkpoint after every agent iteration so you can resume exactly where you left off.
```bash
# First run — starts fresh, saves checkpoint automatically
strix --target https://example.com --run-name my-scan
# If interrupted, run the same command again — auto-resumes from checkpoint
strix --target https://example.com --run-name my-scan
# Explicit resume flag (same effect, makes intent clear)
strix --target https://example.com --run-name my-scan --resume
# Force a completely fresh scan (deletes existing checkpoint)
strix --target https://example.com --run-name my-scan --new
```
**What is restored on resume:**
- Full LLM conversation history (the agent remembers everything it did)
- Discovered vulnerabilities and findings
- Iteration counter — the agent continues from exactly where it stopped
- A fresh Docker sandbox is always created (old containers may be gone)
**Checkpoint location:** `strix_runs/<run-name>/checkpoint.json`
Checkpoints are deleted automatically when a scan completes successfully.
> **Tip:** `--run-name` is optional. If omitted, Strix auto-generates a name like `example-com_a1b2`.
> Auto-resume only works when you re-use the same `--run-name`.
---
### Headless Mode
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag—perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.

View file

@ -78,6 +78,11 @@ class BaseAgent(metaclass=AgentMeta):
self.state.waiting_timeout = 0
self.llm = LLM(self.llm_config, agent_name=self.agent_name)
# Added for Resume Feature - optional, zero impact when absent
self._checkpoint_manager = config.get("checkpoint_manager")
self._scan_config: dict[str, Any] = config.get("scan_config", {})
self._target_hash: str = config.get("target_hash", "")
with contextlib.suppress(Exception):
self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)
self._current_task: asyncio.Task[Any] | None = None
@ -215,6 +220,18 @@ class BaseAgent(metaclass=AgentMeta):
should_finish = await iteration_task
self._current_task = None
# Added for Resume Feature — save checkpoint after every successful
# iteration. Non-fatal: any error is caught inside save().
# Only root agents checkpoint (parent_id is None).
if self._checkpoint_manager and self.state.parent_id is None:
self._checkpoint_manager.save(
self.state,
tracer,
self._scan_config,
self._target_hash,
self.max_iterations,
)
if should_finish is None and self.interactive:
await self._enter_waiting_state(tracer, text_response=True)
continue
@ -224,6 +241,9 @@ class BaseAgent(metaclass=AgentMeta):
self.state.set_completed({"success": True})
if tracer:
tracer.update_agent_status(self.state.agent_id, "completed")
# Added for Resume Feature — clean completion, remove checkpoint
if self._checkpoint_manager:
self._checkpoint_manager.delete()
return self.state.final_result or {}
await self._enter_waiting_state(tracer, task_completed=True)
continue
@ -362,7 +382,12 @@ class BaseAgent(metaclass=AgentMeta):
if not self.state.task:
self.state.task = task
self.state.add_message("user", task)
# Added for Resume Feature: skip adding the initial task message when
# resuming because the full message history is already in state.messages.
# On a fresh start state.messages is always empty here — original behavior
# is 100% unchanged.
if not self.state.messages:
self.state.add_message("user", task)
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
final_response = None

View file

@ -20,11 +20,117 @@ from .utils import (
)
# Added for Resume Feature — helpers for CLI resume banner and history replay
def _print_resume_banner(console: Console, run_name: str, iteration: int) -> None:
"""Print the resume banner so the user knows the scan is continuing."""
resume_text = Text()
resume_text.append("Resuming interrupted scan", style="bold #22c55e")
resume_text.append("\n\n")
resume_text.append("Run name ", style="dim")
resume_text.append(run_name, style="bold white")
resume_text.append("\n")
resume_text.append("Resuming from iteration ", style="dim")
resume_text.append(str(iteration), style="bold white")
console.print(
Panel(
resume_text,
title="[bold white]STRIX",
title_align="left",
border_style="#22c55e",
padding=(1, 2),
)
)
console.print()
def _replay_previous_output(
console: Console, checkpoint_data: Any, display_vulnerability: Any
) -> None:
"""Re-render previous findings and key messages from the checkpoint.
Added for Resume Feature gives the user a sense of what was already
discovered before the scan was interrupted.
"""
# Replay vulnerability reports found so far
for report in checkpoint_data.tracer_vulnerability_reports:
report_id = report.get("id", "unknown")
vuln_text = format_vulnerability_report(report)
console.print(
Panel(
vuln_text,
title=f"[bold red]{report_id.upper()} [dim](from previous session)[/]",
title_align="left",
border_style="dark_red",
padding=(1, 2),
)
)
console.print()
# Print a summary of previous agent activity (last few assistant messages)
chat_msgs = checkpoint_data.tracer_chat_messages
assistant_msgs = [m for m in chat_msgs if m.get("role") == "assistant"]
if assistant_msgs:
last_msgs = assistant_msgs[-3:]
history_text = Text()
history_text.append("Last agent activity before interruption\n\n", style="dim")
for msg in last_msgs:
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
# Truncate very long messages to keep replay readable
snippet = content.strip()[:400]
if len(content.strip()) > 400:
snippet += ""
history_text.append(snippet + "\n\n", style="dim white")
if history_text.plain.strip():
console.print(
Panel(
history_text,
title="[dim]Previous session activity[/]",
title_align="left",
border_style="dim",
padding=(1, 2),
)
)
console.print()
async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console()
# Added for Resume Feature — detect resume and restore state
checkpoint_data = getattr(args, "_checkpoint_data", None)
is_resuming = getattr(args, "resume_from_checkpoint", False) and checkpoint_data is not None
checkpoint_manager = getattr(args, "_checkpoint_manager", None)
target_hash = getattr(args, "_target_hash", "")
resumed_state = None
if is_resuming:
from strix.agents.state import AgentState
resumed_state = AgentState.model_validate(checkpoint_data.agent_state)
# Give the agent a fresh budget from the resume point so it never
# stops just because it hit the original ceiling.
# Added for Resume Feature — extend max_iterations dynamically.
resumed_state.max_iterations = (
resumed_state.iteration + checkpoint_data.original_max_iterations
)
resumed_state.max_iterations_warning_sent = False # Reset warning flag
# Clear sandbox so a fresh container is always created on resume
# (the old container may be gone).
resumed_state.sandbox_id = None
resumed_state.sandbox_token = None
resumed_state.sandbox_info = None
start_text = Text()
start_text.append("Penetration test initiated", style="bold #22c55e")
if is_resuming:
start_text.append("Penetration test resumed", style="bold #22c55e")
else:
start_text.append("Penetration test initiated", style="bold #22c55e")
target_text = Text()
target_text.append("Target", style="dim")
@ -75,7 +181,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
}
llm_config = LLMConfig(scan_mode=scan_mode)
agent_config = {
agent_config: dict[str, Any] = {
"llm_config": llm_config,
"max_iterations": 300,
}
@ -83,9 +189,30 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
if getattr(args, "local_sources", None):
agent_config["local_sources"] = args.local_sources
# Added for Resume Feature — inject checkpoint manager so the agent saves
# state after every iteration.
if checkpoint_manager:
agent_config["checkpoint_manager"] = checkpoint_manager
agent_config["target_hash"] = target_hash
agent_config["scan_config"] = scan_config
# Added for Resume Feature — pass restored state into the agent config
if resumed_state is not None:
agent_config["state"] = resumed_state
tracer = Tracer(args.run_name)
tracer.set_scan_config(scan_config)
# Added for Resume Feature — pre-populate tracer so stats/vulns are correct
if is_resuming and checkpoint_data:
tracer.chat_messages.extend(checkpoint_data.tracer_chat_messages)
tracer.vulnerability_reports.extend(checkpoint_data.tracer_vulnerability_reports)
# Added for Resume Feature — show resume banner + replay previous output
if is_resuming and checkpoint_data:
_print_resume_banner(console, args.run_name, checkpoint_data.iteration)
_replay_previous_output(console, checkpoint_data, None)
def display_vulnerability(report: dict[str, Any]) -> None:
report_id = report.get("id", "unknown")

View file

@ -366,6 +366,35 @@ Examples:
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
# Added for Resume Feature — checkpoint / resume flags
parser.add_argument(
"--run-name",
type=str,
default=None,
dest="run_name_override",
help=(
"Name for this scan run (used for checkpointing/resume). "
"Auto-generated if omitted."
),
)
parser.add_argument(
"--resume",
action="store_true",
default=False,
help=(
"Resume from an existing checkpoint for this run-name + target. "
"If no checkpoint is found, starts a fresh scan."
),
)
parser.add_argument(
"--new",
"--force-new",
action="store_true",
default=False,
dest="force_new",
help="Force a completely fresh scan, deleting any existing checkpoint.",
)
args = parser.parse_args()
if args.instruction and args.instruction_file:
@ -517,6 +546,57 @@ def persist_config() -> None:
save_current_config()
def _setup_checkpoint_on_args(args: argparse.Namespace) -> None:
"""Resolve checkpoint / resume state and attach it to ``args``.
Added for Resume Feature. Sets:
- ``args._checkpoint_manager`` CheckpointManager for this run
- ``args._target_hash`` hash of targets (for validation)
- ``args._checkpoint_data`` loaded CheckpointModel or None
- ``args.resume_from_checkpoint`` True when we should actually resume
"""
from pathlib import Path
from strix.telemetry.checkpoint import CheckpointManager, compute_target_hash
run_dir = Path("strix_runs") / args.run_name
mgr = CheckpointManager(args.run_name, run_dir)
target_hash = compute_target_hash(args.targets_info)
args._checkpoint_manager = mgr
args._target_hash = target_hash
args._checkpoint_data = None
args.resume_from_checkpoint = False
if args.force_new:
mgr.delete()
return
if mgr.exists():
checkpoint = mgr.load()
if checkpoint is None:
# Corrupted checkpoint — warn and start fresh
console = Console()
console.print(
"[bold yellow]Warning:[/] Checkpoint file is corrupted or unreadable. "
"Starting a fresh scan."
)
return
if checkpoint.target_hash != target_hash:
console = Console()
console.print(
"[bold yellow]Warning:[/] Checkpoint target mismatch "
f"(run '{args.run_name}' was for a different target). "
"Starting a fresh scan."
)
return
# Valid checkpoint found — auto-resume (or explicit --resume)
args._checkpoint_data = checkpoint
args.resume_from_checkpoint = True
def main() -> None:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@ -534,7 +614,13 @@ def main() -> None:
persist_config()
args.run_name = generate_run_name(args.targets_info)
# Added for Resume Feature — determine run_name and whether to resume
if args.run_name_override:
args.run_name = args.run_name_override
else:
args.run_name = generate_run_name(args.targets_info)
_setup_checkpoint_on_args(args)
for target_info in args.targets_info:
if target_info["type"] == "repository":

View file

@ -709,6 +709,13 @@ class StrixTUIApp(App): # type: ignore[misc]
self.tracer.set_scan_config(self.scan_config)
set_global_tracer(self.tracer)
# Added for Resume Feature — pre-populate tracer with checkpoint data so
# stats and findings reflect the full scan history including past sessions.
_cp = getattr(args, "_checkpoint_data", None)
if _cp and getattr(args, "resume_from_checkpoint", False):
self.tracer.chat_messages.extend(_cp.tracer_chat_messages)
self.tracer.vulnerability_reports.extend(_cp.tracer_vulnerability_reports)
self.agent_nodes: dict[str, TreeNode] = {}
self._displayed_agents: set[str] = set()
@ -749,7 +756,7 @@ class StrixTUIApp(App): # type: ignore[misc]
scan_mode = getattr(args, "scan_mode", "deep")
llm_config = LLMConfig(scan_mode=scan_mode, interactive=True)
config = {
config: dict[str, Any] = {
"llm_config": llm_config,
"max_iterations": 300,
}
@ -757,6 +764,27 @@ class StrixTUIApp(App): # type: ignore[misc]
if getattr(args, "local_sources", None):
config["local_sources"] = args.local_sources
# Added for Resume Feature — restore agent state and wire up checkpoint
_cp = getattr(args, "_checkpoint_data", None)
if _cp and getattr(args, "resume_from_checkpoint", False):
from strix.agents.state import AgentState
resumed_state = AgentState.model_validate(_cp.agent_state)
# Fresh budget from the resume point
resumed_state.max_iterations = resumed_state.iteration + _cp.original_max_iterations
resumed_state.max_iterations_warning_sent = False
# Always spin up a new sandbox (old container may be gone)
resumed_state.sandbox_id = None
resumed_state.sandbox_token = None
resumed_state.sandbox_info = None
config["state"] = resumed_state
_mgr = getattr(args, "_checkpoint_manager", None)
if _mgr:
config["checkpoint_manager"] = _mgr
config["target_hash"] = getattr(args, "_target_hash", "")
config["scan_config"] = self.scan_config
return config
def _setup_cleanup_handlers(self) -> None:

View file

@ -0,0 +1,150 @@
"""Checkpoint system for Strix scan resume feature.
Added for Resume Feature - Original behavior is 100% unchanged when
checkpoint_manager is not injected into the agent config.
"""
import hashlib
import json
import logging
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
CHECKPOINT_VERSION = "1.0"
class CheckpointModel(BaseModel):
"""Pydantic model for a full scan checkpoint snapshot.
Added for Resume Feature.
"""
version: str = CHECKPOINT_VERSION
run_name: str
target_hash: str # Short SHA-256 of sorted target strings — used for validation
saved_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
# Agent loop progress
iteration: int
original_max_iterations: int # The max that was set when the scan started
# Full AgentState dump (messages, sandbox_id, sandbox_token, etc.)
agent_state: dict[str, Any]
# Tracer state needed to restore stats and replay findings
tracer_chat_messages: list[dict[str, Any]] = Field(default_factory=list)
tracer_vulnerability_reports: list[dict[str, Any]] = Field(default_factory=list)
# Original scan configuration (passed to execute_scan)
scan_config: dict[str, Any] = Field(default_factory=dict)
def compute_target_hash(targets_info: list[dict[str, Any]]) -> str:
"""Return a short stable hash of the target list for checkpoint validation.
Added for Resume Feature.
"""
target_strings = sorted(t.get("original", "") for t in (targets_info or []))
combined = "|".join(target_strings)
return hashlib.sha256(combined.encode()).hexdigest()[:16]
class CheckpointManager:
"""Saves and loads scan checkpoints to ``strix_runs/<run_name>/checkpoint.json``.
All operations are *non-fatal*: any I/O error is logged as a warning and
the scan continues normally.
Added for Resume Feature.
"""
def __init__(self, run_name: str, run_dir: Path) -> None:
self.run_name = run_name
self.run_dir = run_dir
self.checkpoint_path = run_dir / "checkpoint.json"
self._tmp_path = run_dir / "checkpoint.json.tmp"
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def exists(self) -> bool:
"""Return True if a checkpoint file is present."""
return self.checkpoint_path.exists()
def save(
self,
agent_state: Any,
tracer: Any | None,
scan_config: dict[str, Any],
target_hash: str,
original_max_iterations: int,
) -> None:
"""Atomically persist the current scan state.
Writes to a ``.tmp`` file then renames to prevent corruption during a
crash mid-write. All errors are non-fatal (warning only).
"""
try:
self.run_dir.mkdir(parents=True, exist_ok=True)
state_dict: dict[str, Any] = (
agent_state.model_dump() if hasattr(agent_state, "model_dump") else {}
)
tracer_chat_messages: list[dict[str, Any]] = []
tracer_vulnerability_reports: list[dict[str, Any]] = []
if tracer:
tracer_chat_messages = list(getattr(tracer, "chat_messages", []))
tracer_vulnerability_reports = list(
getattr(tracer, "vulnerability_reports", [])
)
checkpoint = CheckpointModel(
run_name=self.run_name,
target_hash=target_hash,
iteration=agent_state.iteration,
original_max_iterations=original_max_iterations,
agent_state=state_dict,
tracer_chat_messages=tracer_chat_messages,
tracer_vulnerability_reports=tracer_vulnerability_reports,
scan_config=scan_config,
)
# Atomic write: .tmp → rename
self._tmp_path.write_text(checkpoint.model_dump_json(indent=2), encoding="utf-8")
os.rename(self._tmp_path, self.checkpoint_path)
except Exception as e: # noqa: BLE001
logger.warning("[Resume] Checkpoint save failed (non-fatal): %s", e)
def load(self) -> "CheckpointModel | None":
"""Load and parse the checkpoint file.
Returns ``None`` and logs a warning on any error (corruption, missing
file, version mismatch).
"""
if not self.checkpoint_path.exists():
return None
try:
raw = json.loads(self.checkpoint_path.read_text(encoding="utf-8"))
return CheckpointModel.model_validate(raw)
except Exception as e: # noqa: BLE001
logger.warning("[Resume] Checkpoint load failed: %s", e)
return None
def delete(self) -> None:
"""Remove the checkpoint file (called when the scan finishes cleanly)."""
try:
if self.checkpoint_path.exists():
self.checkpoint_path.unlink()
if self._tmp_path.exists():
self._tmp_path.unlink()
except Exception as e: # noqa: BLE001
logger.warning("[Resume] Checkpoint delete failed (non-fatal): %s", e)