Skip to content
Merged
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
31 changes: 29 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -44,6 +70,7 @@ jobs:

publish-images:
name: Publish Container Images
needs: verify-version
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
.pytest_cache/
build/
dist/
*.egg-info/
audit_log.db
challenges/**/.ffr/
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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
Expand All @@ -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<version>` 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. |

Expand Down
6 changes: 6 additions & 0 deletions Test/test_logic.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions challenges/test-ctf/web/smoke-challenge/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.ffr/
*.pdf
__pycache__/
10 changes: 10 additions & 0 deletions challenges/test-ctf/web/smoke-challenge/.metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
Empty file.
4 changes: 4 additions & 0 deletions challenges/test-ctf/web/smoke-challenge/notes/FAILURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Failure Analysis

| Attempt | Reason It Failed | Evidence |
| --- | --- | --- |
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Working Notes

## Target Summary

## Hypotheses

## Evidence

## Final Chain
9 changes: 7 additions & 2 deletions helm_path/graph/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 49 additions & 18 deletions helm_path/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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"])
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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__"}
Loading