Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<RUN_NAME>/) 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:
Expand All @@ -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(
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing Run Directory Is Reused

When a fresh invocation passes a name already present under strix_runs, this selection enters the not args.resume path. _persist_run_record() then replaces the prior run.json, while old vulnerability and state files can remain, so repeated use of a deterministic name can destroy metadata and mix artifacts from separate scans. The fresh path should reject an existing run directory and require --resume, or explicitly replace the directory as one unit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/interface/main.py
Line: 901

Comment:
**Existing Run Directory Is Reused**

When a fresh invocation passes a name already present under `strix_runs`, this selection enters the `not args.resume` path. `_persist_run_record()` then replaces the prior `run.json`, while old vulnerability and state files can remain, so repeated use of a deterministic name can destroy metadata and mix artifacts from separate scans. The fresh path should reject an existing run directory and require `--resume`, or explicitly replace the directory as one unit.

How can I resolve this? If you propose a fix, please make it concise.


if not args.resume:
for target_info in args.targets_info:
Expand Down
100 changes: 100 additions & 0 deletions tests/test_cli_run_name.py
Original file line number Diff line number Diff line change
@@ -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"