diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 506463b..f8d82ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,34 @@ permissions: packages: write jobs: + verify-version: + name: Verify Release Version + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Verify tag matches package version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + package_version="$(python -c "from helm_path import __version__; print(__version__)")" + + if [[ "$tag_version" != "$package_version" ]]; then + echo "Release tag v${tag_version} does not match package version ${package_version}." >&2 + exit 1 + fi + build-python: name: Build Python Artifacts + needs: verify-version runs-on: ubuntu-latest steps: - name: Checkout @@ -25,10 +51,10 @@ jobs: cache: pip - name: Install build tooling - run: python -m pip install --upgrade pip build + run: python -Im pip install --upgrade pip build - name: Build distribution artifacts - run: python -m build + run: python -Im build - name: Upload distribution artifacts uses: actions/upload-artifact@v4 @@ -44,6 +70,7 @@ jobs: publish-images: name: Publish Container Images + needs: verify-version runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 7f23a40..32bc853 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[cod] .pytest_cache/ build/ +dist/ *.egg-info/ audit_log.db challenges/**/.ffr/ diff --git a/README.md b/README.md index 4d2d0b5..3340b7d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ The Docker images are for terminal capture only. AI generation now runs on the h - `CI` runs on every push and pull request. - It installs the package, compiles the code, runs the test suite, checks the CLI help output, validates challenge workspace scaffolding, and builds the lite Docker image. - `Release` runs on tags matching `v*` and on manual dispatch. +- The packaged version is sourced from `helm_path.__version__`, and tagged releases must use the matching `v` tag. - It builds Python distribution artifacts, uploads them to the GitHub release, and publishes `helm-path-lite` and `helm-path-kali` images to GitHub Container Registry. ## Roadmap @@ -102,7 +103,7 @@ The Docker images are for terminal capture only. AI generation now runs on the h | First remote CI validation | Next | Confirm the GitHub Actions workflows pass on the repository and fix any hosted-run drift. | | Real end-to-end smoke run | Next | Validate `init -> start -> report -> verify` against a real challenge with Docker and Ollama. | | Reporting integration test | Next | Add a mocked Ollama test that proves canned logs produce the expected report bundle. | -| Tagged release | Next | Cut `v0.2.0` after the live smoke run and CI validation succeed. | +| Tagged release | Next | Cut the next `v` tag after the live smoke run and CI validation succeed. | | Reproducible exploit verification | Later | Add optional CI replay for challenge artifacts without making it part of the local-first core. | | Cloud execution for heavy workloads | Later | Add remote execution only for cases where local verification is insufficient. | diff --git a/Test/test_logic.py b/Test/test_logic.py index ecc54e7..f0b37d2 100644 --- a/Test/test_logic.py +++ b/Test/test_logic.py @@ -1,5 +1,7 @@ import json +from importlib.metadata import version as distribution_version +from helm_path import __version__ from helm_path.ai import extract_json, render_report_prompt from helm_path.audit import init_audit_db, record_run, verify_chain from helm_path.processing import ( @@ -34,6 +36,10 @@ def test_init_challenge_workspace_creates_expected_layout(tmp_path): assert metadata["status"] == "initialized" +def test_distribution_version_matches_runtime_version(): + assert distribution_version("helm-path") == __version__ + + def test_clean_sensitive_data_and_log_processing(tmp_path): raw_log = tmp_path / "raw.log" clean_log = tmp_path / "clean.log" diff --git a/challenges/test-ctf/web/smoke-challenge/.gitignore b/challenges/test-ctf/web/smoke-challenge/.gitignore new file mode 100644 index 0000000..faec835 --- /dev/null +++ b/challenges/test-ctf/web/smoke-challenge/.gitignore @@ -0,0 +1,3 @@ +.ffr/ +*.pdf +__pycache__/ diff --git a/challenges/test-ctf/web/smoke-challenge/.metadata.json b/challenges/test-ctf/web/smoke-challenge/.metadata.json new file mode 100644 index 0000000..e487270 --- /dev/null +++ b/challenges/test-ctf/web/smoke-challenge/.metadata.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "challenge_id": "test-ctf__web__smoke-challenge", + "competition": "Test CTF", + "category": "Web", + "challenge_name": "Smoke Challenge", + "created_at": "2026-03-13T00:36:13+00:00", + "updated_at": "2026-03-13T00:36:13+00:00", + "status": "initialized" +} \ No newline at end of file diff --git a/challenges/test-ctf/web/smoke-challenge/artifacts/.gitkeep b/challenges/test-ctf/web/smoke-challenge/artifacts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/challenges/test-ctf/web/smoke-challenge/notes/FAILURES.md b/challenges/test-ctf/web/smoke-challenge/notes/FAILURES.md new file mode 100644 index 0000000..0c3ed80 --- /dev/null +++ b/challenges/test-ctf/web/smoke-challenge/notes/FAILURES.md @@ -0,0 +1,4 @@ +# Failure Analysis + +| Attempt | Reason It Failed | Evidence | +| --- | --- | --- | diff --git a/challenges/test-ctf/web/smoke-challenge/notes/WORKING_NOTES.md b/challenges/test-ctf/web/smoke-challenge/notes/WORKING_NOTES.md new file mode 100644 index 0000000..75196e0 --- /dev/null +++ b/challenges/test-ctf/web/smoke-challenge/notes/WORKING_NOTES.md @@ -0,0 +1,9 @@ +# Working Notes + +## Target Summary + +## Hypotheses + +## Evidence + +## Final Chain diff --git a/challenges/test-ctf/web/smoke-challenge/sessions/20260313-003626-94862b/commands.jsonl b/challenges/test-ctf/web/smoke-challenge/sessions/20260313-003626-94862b/commands.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/helm_path/graph/build.py b/helm_path/graph/build.py index aff5f6d..db635c7 100644 --- a/helm_path/graph/build.py +++ b/helm_path/graph/build.py @@ -43,10 +43,15 @@ def select_run_dirs(challenge_path: Path, run_id: str | None, all_runs: bool) -> target = challenge_path / "sessions" / run_id if not target.exists(): raise ValueError(f"Run '{run_id}' does not exist.") + if not (target / "manifest.json").exists(): + raise ValueError(f"Run '{run_id}' is incomplete because manifest.json is missing.") return [target] + complete_runs = [run for run in runs if (run / "manifest.json").exists()] + if not complete_runs: + raise ValueError("No complete recorded runs found. Remove incomplete session folders or rerun capture.") if all_runs or len(runs) == 1: - return runs - return [runs[-1]] + return complete_runs + return [complete_runs[-1]] def detect_executable(command_raw: str) -> str: diff --git a/helm_path/main.py b/helm_path/main.py index 4f526f5..2e49e14 100644 --- a/helm_path/main.py +++ b/helm_path/main.py @@ -1,6 +1,8 @@ from __future__ import annotations +import shutil import subprocess +import sys from pathlib import Path from typing import Any @@ -91,15 +93,24 @@ def select_run_dirs(challenge_path: Path, run_id: str | None, all_runs: bool) -> target = challenge_path / "sessions" / run_id if not target.exists(): raise typer.BadParameter(f"Run '{run_id}' does not exist.") + if not (target / "manifest.json").exists(): + raise typer.BadParameter(f"Run '{run_id}' is incomplete because manifest.json is missing.") return [target] + complete_runs = [run for run in runs if (run / "manifest.json").exists()] + if not complete_runs: + raise typer.BadParameter("No complete recorded runs found. Remove incomplete session folders or rerun capture.") if all_runs or len(runs) == 1: - return runs - return [runs[-1]] + return complete_runs + return [complete_runs[-1]] def verify_manifest_files(challenge_path: Path, run_dirs: list[Path]) -> list[str]: findings: list[str] = [] for run_dir in run_dirs: + manifest_path = run_dir / "manifest.json" + if not manifest_path.exists(): + findings.append(f"Incomplete run directory is missing manifest.json: {run_dir}") + continue manifest = load_manifest(run_dir) paths = run_file_paths(challenge_path, manifest["run_id"]) required = { @@ -204,6 +215,11 @@ def init( def start( challenge_path: Path = typer.Argument(..., help="Path to an initialized challenge workspace"), lite: bool = typer.Option(False, "--lite", help="Use the lightweight capture image"), + command: str | None = typer.Option( + None, + "--command", + help="Run a single shell command and exit. Useful for non-interactive smoke tests.", + ), ): """Record a new challenge run inside the Helm-Path container.""" challenge_path = resolve_challenge_path(challenge_path) @@ -217,25 +233,37 @@ def start( paths["commands_log"].touch() raw_log_relative = Path("sessions") / manifest["run_id"] / "raw.log" commands_log_relative = Path("sessions") / manifest["run_id"] / COMMAND_LOG_FILENAME + interactive = command is None + if interactive and not (sys.stdin.isatty() and sys.stdout.isatty()): + shutil.rmtree(paths["run_dir"], ignore_errors=True) + raise typer.BadParameter("Interactive capture requires a TTY. Re-run in a terminal or pass --command for a smoke test.") + docker_command = [ "docker", "run", - "-it", - "--rm", - "-v", - f"{challenge_path.resolve()}:/workspace", - "--workdir", - "/workspace", - "-e", - f"LOG_FILE={raw_log_relative.as_posix()}", - "-e", - f"COMMANDS_FILE={commands_log_relative.as_posix()}", - "-e", - f"RUN_ID={manifest['run_id']}", - "--name", - f"helm-path-{manifest['run_id']}", - image_tag, ] + if interactive: + docker_command.append("-it") + docker_command.extend( + [ + "--rm", + "-v", + f"{challenge_path.resolve()}:/workspace", + "--workdir", + "/workspace", + "-e", + f"LOG_FILE={raw_log_relative.as_posix()}", + "-e", + f"COMMANDS_FILE={commands_log_relative.as_posix()}", + "-e", + f"RUN_ID={manifest['run_id']}", + "--name", + f"helm-path-{manifest['run_id']}", + image_tag, + ] + ) + if command is not None: + docker_command.extend(["/usr/bin/zsh", "-ic", command]) console.print( Panel.fit( @@ -246,10 +274,13 @@ def start( ) ) - subprocess.run(docker_command, check=False) + result = subprocess.run(docker_command, check=False) if not paths["raw_log"].exists(): + shutil.rmtree(paths["run_dir"], ignore_errors=True) console.print("[bold red]No raw log was captured. The container likely exited before the recorder started.[/bold red]") + if result.returncode != 0: + console.print(f"[yellow]Docker exited with status {result.returncode}.[/yellow]") raise typer.Exit(1) stats = build_clean_log(paths["raw_log"], paths["clean_log"]) diff --git a/pyproject.toml b/pyproject.toml index 000319f..f834a23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "helm-path" -version = "0.2.0" +dynamic = ["version"] description = "Local-first CTF flight recorder and AI writeup generator" readme = "README.md" requires-python = ">=3.10" @@ -38,3 +38,6 @@ include = ["helm_path*"] [tool.setuptools.package-data] "helm_path.prompts" = ["*.txt"] "helm_path.graph.static" = ["*.html", "*.css", "*.js"] + +[tool.setuptools.dynamic] +version = {attr = "helm_path.__version__"}