diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index 15b7355c..d354106a 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -390,15 +390,21 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser args.workspace_mount = workspace_mount # Replace the workspace files the run started with, unless this resume names - # its own. A file deleted between runs is dropped rather than fatal: it is - # context for the agent, not scope. + # its own. The persisted record is revalidated like a fresh flag, so an + # edited run.json cannot widen what a resume places. A file deleted between + # runs is dropped rather than fatal: it is context for the agent, not scope. if not getattr(args, "workspace_files", None): - args.workspace_files = [ - workspace_file + restored = [ + f"{source_path}:{workspace_path}" for workspace_file in state.get("workspace_files") or [] if isinstance(workspace_file, dict) - and Path(str(workspace_file.get("source_path", ""))).is_file() + and (source_path := Path(str(workspace_file.get("source_path") or ""))).is_file() + and (workspace_path := str(workspace_file.get("workspace_path") or "")) ] + try: + args.workspace_files = resolve_workspace_files(restored) + except ValueError as error: + parser.error(f"--resume {args.resume}: invalid workspace file: {error}") if workspace_mount: if not Path(workspace_mount).expanduser().is_dir(): parser.error( diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index ce5f15f7..9372ce5e 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -128,6 +128,68 @@ def test_resume_restores_a_target_less_workspace_mount( assert args.instruction == "audit the auth flow" +def test_resume_revalidates_persisted_workspace_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Resume places the same files again, and drops ones that went away.""" + work = tmp_path / "project" + work.mkdir() + kept = tmp_path / "wordlist.txt" + kept.write_text("admin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(work), + "workspace_files": [ + {"source_path": str(kept), "workspace_path": "/workspace/lists/words.txt"}, + {"source_path": str(tmp_path / "gone.txt"), "workspace_path": "/workspace/g.txt"}, + ], + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + args = cli_main.parse_arguments() + + assert args.workspace_files == [ + {"source_path": str(kept), "workspace_path": "/workspace/lists/words.txt"} + ] + + +def test_resume_rejects_an_edited_workspace_file_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A hand-edited record cannot place a file outside the workspace.""" + work = tmp_path / "project" + work.mkdir() + source = tmp_path / "wordlist.txt" + source.write_text("admin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(work), + "workspace_files": [ + {"source_path": str(source), "workspace_path": "/etc/cron.d/payload"} + ], + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + assert "invalid workspace file" in capsys.readouterr().err + + def test_resume_reports_a_missing_workspace_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: