diff --git a/strix/interface/cli.py b/strix/interface/cli.py index a8992ce4..482e625a 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -97,7 +97,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 "workspace_files": getattr(args, "workspace_files", None) or [], "scope_mode": getattr(args, "scope_mode", "auto"), "diff_base": getattr(args, "diff_base", None), - "resume_instruction": getattr(args, "user_explicit_instruction", None) or "", + "resume_instruction": getattr(args, "resume_instruction", None) + or getattr(args, "user_explicit_instruction", None) + or "", } report_state = ReportState(args.run_name) diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index f27dfe4e..b21230de 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -24,6 +24,15 @@ from strix.report.runs import list_run_summaries # Severities ``--fail-on`` accepts, most severe first. FAIL_ON_SEVERITIES = ("critical", "high", "medium", "low", "info") +# Injected into a resumed session that was widened past its recorded diff +# scope, so the diff-scope preamble replayed from the original conversation +# stops telling the agents to report findings only inside the old diff. +RESUME_SCOPE_WIDENED_INSTRUCTION = ( + "The diff scope of the original run has been lifted (--scope-mode full): " + "analyze the full repository. Findings are no longer limited to the " + "changes listed in the earlier scope instructions." +) + def get_version() -> str: try: @@ -548,14 +557,24 @@ def load_resume_state(args: argparse.Namespace) -> None: args.scope_mode = persisted_scope_mode if state.get("diff_base") and not args.diff_base: args.diff_base = state.get("diff_base") + # What reaches the resumed session as a fresh message: the runner injects + # scan_config["resume_instruction"], because a replayed session would + # otherwise ignore any new instruction entirely. + args.resume_instruction = args.user_explicit_instruction or "" if args.scope_mode == "full": # An explicit --scope-mode full widens the resume to the full # repository, as --help promises; the flag's "auto" default keeps the - # intent unambiguous. The persisted instruction carries the diff-scope - # preamble, so a widened resume rebuilds it from the clean user text. + # intent unambiguous. + widened = bool(isinstance(args.diff_scope, dict) and args.diff_scope.get("active")) args.diff_scope = {"active": False, "mode": "full"} - if not args.user_explicit_instruction and args.user_instruction: - args.instruction = args.user_instruction + if widened and not args.user_explicit_instruction: + # The instruction recorded for a diff-scoped run carries the + # preamble prepare_run() prepended to it; the widened resume + # records the clean user text instead, and injects a note into + # the replayed session so the old restriction is lifted for + # the agents too, not just in the published record. + args.instruction = state.get("user_instruction") or None + args.resume_instruction = RESUME_SCOPE_WIDENED_INSTRUCTION agents_path = runtime_state_dir(run_dir) / "agents.json" if not agents_path.exists(): raise ResumeError( diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index ae983236..88ab8198 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -99,7 +99,9 @@ class GoTuiRuntime: "workspace_files": getattr(self.args, "workspace_files", None) or [], "scope_mode": self.args.scope_mode, "diff_base": self.args.diff_base, - "resume_instruction": self.args.user_explicit_instruction or "", + "resume_instruction": getattr(self.args, "resume_instruction", None) + or getattr(self.args, "user_explicit_instruction", None) + or "", "workspace_mount": getattr(self.args, "workspace_mount", None) or "", "workspace_subdir": getattr(self.args, "workspace_subdir", None) or "", } diff --git a/tests/test_resume_scope.py b/tests/test_resume_scope.py index 6d8f11bc..91321c4f 100644 --- a/tests/test_resume_scope.py +++ b/tests/test_resume_scope.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any import pytest +from strix.interface.cli_args import RESUME_SCOPE_WIDENED_INSTRUCTION + if TYPE_CHECKING: from pathlib import Path @@ -78,6 +80,7 @@ def test_resume_restores_persisted_scope_over_flag_defaults( assert args.diff_base == "origin/main" assert args.diff_scope == {"active": True, "mode": "diff", "repos": []} assert args.instruction == PREAMBLE + "find auth bugs" + assert args.resume_instruction == "" def test_resume_honors_scope_mode_full(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -96,8 +99,10 @@ def test_resume_honors_scope_mode_full(tmp_path: Path, monkeypatch: pytest.Monke assert args.scope_mode == "full" assert args.diff_scope == {"active": False, "mode": "full"} # The recorded instruction carries the diff-scope preamble; the widened - # resume keeps only the clean user text. + # resume keeps only the clean user text, and injects a note lifting the + # old restriction into the replayed session. assert args.instruction == "find auth bugs" + assert args.resume_instruction == RESUME_SCOPE_WIDENED_INSTRUCTION def test_resume_honors_scope_mode_full_with_new_instruction( @@ -118,12 +123,13 @@ def test_resume_honors_scope_mode_full_with_new_instruction( assert args.scope_mode == "full" assert args.diff_scope == {"active": False, "mode": "full"} assert args.instruction == "check the API" + assert args.resume_instruction == "check the API" def test_resume_honors_scope_mode_full_without_recorded_user_instruction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Widening still takes effect when the run predates user_instruction.""" + """A widened resume drops a preamble-only instruction and injects the note.""" work = tmp_path / "project" work.mkdir() monkeypatch.chdir(tmp_path) @@ -136,7 +142,38 @@ def test_resume_honors_scope_mode_full_without_recorded_user_instruction( args = _resume(monkeypatch, "--scope-mode", "full") assert args.diff_scope == {"active": False, "mode": "full"} - assert args.instruction == PREAMBLE + assert args.instruction is None + assert args.resume_instruction == RESUME_SCOPE_WIDENED_INSTRUCTION + + +def test_resume_with_scope_mode_full_on_a_full_run_injects_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Re-asserting full on a run that was already full is a plain no-op.""" + work = tmp_path / "project" + work.mkdir() + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "instruction": "find auth bugs", + "user_instruction": "find auth bugs", + "scan_mode": "deep", + "scope_mode": "full", + "diff_scope": {"active": False, "mode": "full"}, + "workspace_mount": str(work), + }, + ) + + args = _resume(monkeypatch, "--scope-mode", "full") + + assert args.diff_scope == {"active": False, "mode": "full"} + assert args.instruction == "find auth bugs" + assert args.resume_instruction == "" @pytest.mark.parametrize("extra", [["--scope-mode", "diff"], ["--diff-base", "origin/main"]])