From 27ac2d6576d60bac60bee4b489b8827dadaddb19 Mon Sep 17 00:00:00 2001 From: seanturner83 Date: Thu, 16 Jul 2026 22:15:50 +0100 Subject: [PATCH 1/2] feat(cli): --run-name for deterministic, resumable run dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_run_name() always auto-derives the run dir name, so a fresh scan's dir is unpredictable and a caller that needs to know it *before* the scan starts has no way to key it. That blocks CI/automation patterns: - resuming a scan across a credential / wall-clock boundary (assume a fresh token every ~50min, kill Strix, `strix --resume `), which needs the name pinned up front, and - restoring prior run state into a known dir before invoking Strix. Today the only workaround is to scan strix_runs/ for the newest dir after the first iteration, which is fragile when a stale dir is present. Add --run-name: force ./strix_runs// instead of generating. Precedence is --run-name > --resume > auto; when both --run-name and --resume are passed they must name the same dir (else we'd resume one run and persist to another) — enforced, not silently resolved. The name is validated as a single filesystem-safe path segment (no '/', no '..') since run_dir_for() joins it straight onto strix_runs/. Absent flag = today's behaviour, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- strix/interface/main.py | 41 +++++++++++++++++++++- tests/test_cli_run_name.py | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_run_name.py diff --git a/strix/interface/main.py b/strix/interface/main.py index 2403200df..f140a8e84 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,26 @@ 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." + ) + if args.resume: if args.target or args.target_list or args.mount: parser.error( @@ -859,7 +894,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..f5ffd7f34 --- /dev/null +++ b/tests/test_cli_run_name.py @@ -0,0 +1,71 @@ +"""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() From 2da9e3b8fad1d44f7082a0045fa3b8f1f602a7ea Mon Sep 17 00:00:00 2001 From: seanturner83 Date: Thu, 16 Jul 2026 22:31:49 +0100 Subject: [PATCH 2/2] review: reject a fresh --run-name that lands on an existing run dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: a fresh (non-resume) --run-name naming an existing run dir enters the not-args.resume path, where _persist_run_record() rewrites run.json but leaves the prior run's vulnerability/state files in place — mixing artifacts from two scans. Guard it in parse_arguments(): a non-resume --run-name whose run_dir_for() already exists is rejected with a message pointing at --resume (auto-generated names are collision-free, so this only bites an explicit name). +2 tests (existing dir rejected, new dir accepted). Co-Authored-By: Claude Opus 4.8 (1M context) --- strix/interface/main.py | 13 +++++++++++++ tests/test_cli_run_name.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/strix/interface/main.py b/strix/interface/main.py index f140a8e84..b82310eff 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -616,6 +616,19 @@ def parse_arguments() -> argparse.Namespace: 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: diff --git a/tests/test_cli_run_name.py b/tests/test_cli_run_name.py index f5ffd7f34..efd1e6514 100644 --- a/tests/test_cli_run_name.py +++ b/tests/test_cli_run_name.py @@ -69,3 +69,32 @@ def test_run_name_conflicting_with_resume_is_rejected(monkeypatch: pytest.Monkey ) 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"