diff --git a/strix/interface/main.py b/strix/interface/main.py index 2403200df..b82310eff 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -563,6 +563,21 @@ def parse_arguments() -> argparse.Namespace: ), ) + parser.add_argument( + "--run-name", + type=str, + metavar="RUN_NAME", + help=( + "Force the run directory name (./strix_runs//) instead of " + "auto-generating one. Use for deterministic, resumable runs: a caller " + "that must know the run dir before the scan starts (e.g. to restore " + "prior state, or to --resume across a credential/wall-clock boundary " + "in CI) can key the run at a known name. When both --run-name and " + "--resume are given they must match. Must be a single path segment " + "(no '/', no '..')." + ), + ) + args = parser.parse_args() if args.instruction and args.instruction_file: @@ -582,6 +597,39 @@ def parse_arguments() -> argparse.Namespace: args.user_explicit_instruction = args.instruction if args.resume else None + if args.run_name is not None: + # run_dir_for() joins the name straight onto ./strix_runs/, so a name + # with a path separator or a parent-dir hop would escape the runs tree. + # Keep it a single, filesystem-safe segment. Path.parts collapses any + # os-specific separator to one element for a clean single-segment name. + run_name_parts = Path(args.run_name).parts + if not args.run_name.strip() or len(run_name_parts) != 1: + parser.error("--run-name must be a single path segment (no '/').") + if run_name_parts[0] in {".", ".."}: + parser.error("--run-name must not be '.' or '..'.") + # A caller that both keys the run (--run-name) and resumes it (--resume) + # must name the same dir — otherwise we'd resume one run and persist to + # another. Require them to agree rather than silently picking one. + if args.resume and args.resume != args.run_name: + parser.error( + f"--run-name {args.run_name!r} conflicts with --resume " + f"{args.resume!r}: they name different run dirs. Pass a single " + f"name (they must match), or drop one." + ) + # A FRESH (non-resume) --run-name must not land on an existing run dir: + # the fresh path rewrites run.json while leaving the prior run's + # findings/state files in place, mixing artifacts from two scans. + # (Auto-generated names are collision-free, so this only guards an + # explicit name.) Require --resume to continue an existing run. + if not args.resume and run_dir_for(args.run_name).exists(): + parser.error( + f"--run-name {args.run_name!r}: run dir already exists at " + f"{run_dir_for(args.run_name)}. A fresh scan would overwrite " + f"run.json but leave the prior run's findings/state in place. " + f"Pass --resume {args.run_name} to continue it, or choose a new " + f"--run-name." + ) + if args.resume: if args.target or args.target_list or args.mount: parser.error( @@ -859,7 +907,11 @@ def main() -> None: persist_current() - args.run_name = args.resume or generate_run_name(args.targets_info) + # Precedence: an explicit --run-name wins (deterministic, caller-keyed run + # dir); else --resume reuses the prior run's name; else auto-generate. The + # --run-name/--resume consistency check above guarantees the first two agree + # when both are set, so this collapses cleanly. + args.run_name = args.run_name or args.resume or generate_run_name(args.targets_info) if not args.resume: for target_info in args.targets_info: diff --git a/tests/test_cli_run_name.py b/tests/test_cli_run_name.py new file mode 100644 index 000000000..efd1e6514 --- /dev/null +++ b/tests/test_cli_run_name.py @@ -0,0 +1,100 @@ +"""Tests for the --run-name CLI argument (deterministic run dirs).""" + +from __future__ import annotations + +import importlib +import sys +from types import SimpleNamespace +from typing import Any + +import pytest + + +cli_main: Any = importlib.import_module("strix.interface.main") + + +def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + cli_main, + "load_settings", + lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)), + ) + + +def test_run_name_accepts_single_segment(monkeypatch: pytest.MonkeyPatch) -> None: + _stub_settings(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["strix", "-t", "https://example.com/", "-n", "--run-name", "my-scan-42"] + ) + args = cli_main.parse_arguments() + assert args.run_name == "my-scan-42" + + +def test_run_name_defaults_to_none(monkeypatch: pytest.MonkeyPatch) -> None: + # Absent flag → None; main() then falls back to --resume / generate_run_name. + _stub_settings(monkeypatch) + monkeypatch.setattr(sys, "argv", ["strix", "-t", "https://example.com/", "-n"]) + args = cli_main.parse_arguments() + assert args.run_name is None + + +@pytest.mark.parametrize("bad", ["a/b", "../escape", "sub/dir/name", "/abs"]) +def test_run_name_rejects_path_traversal(bad: str, monkeypatch: pytest.MonkeyPatch) -> None: + # run_dir_for() joins straight onto ./strix_runs/, so a separator or a + # parent hop would escape the runs tree — must be a single segment. + _stub_settings(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["strix", "-t", "https://example.com/", "-n", "--run-name", bad] + ) + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + +@pytest.mark.parametrize("dotted", [".", ".."]) +def test_run_name_rejects_dot_segments(dotted: str, monkeypatch: pytest.MonkeyPatch) -> None: + _stub_settings(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["strix", "-t", "https://example.com/", "-n", "--run-name", dotted] + ) + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + +def test_run_name_conflicting_with_resume_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + # --run-name and --resume that name different dirs would resume one run and + # persist to another — require them to agree. + _stub_settings(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["strix", "-n", "--resume", "run-a", "--run-name", "run-b"] + ) + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + +def test_fresh_run_name_on_existing_dir_is_rejected( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + # A fresh (non-resume) --run-name that names an existing run dir would + # overwrite run.json but leave the prior run's findings/state — reject it. + _stub_settings(monkeypatch) + (tmp_path / "strix_runs" / "already-here").mkdir(parents=True) + monkeypatch.setattr(cli_main, "run_dir_for", lambda name: tmp_path / "strix_runs" / name) + monkeypatch.setattr( + sys, "argv", ["strix", "-t", "https://example.com/", "-n", "--run-name", "already-here"] + ) + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + +def test_fresh_run_name_on_new_dir_is_accepted( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + # A name that does NOT collide is fine. + _stub_settings(monkeypatch) + (tmp_path / "strix_runs").mkdir(parents=True) + monkeypatch.setattr(cli_main, "run_dir_for", lambda name: tmp_path / "strix_runs" / name) + monkeypatch.setattr( + sys, "argv", ["strix", "-t", "https://example.com/", "-n", "--run-name", "brand-new"] + ) + args = cli_main.parse_arguments() + assert args.run_name == "brand-new"