diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8c2acaf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must keep LF endings — they run under /bin/sh (incl. inside +# containers), where a CRLF would break the shebang/interpreter. +*.sh text eol=lf diff --git a/plugins/hackingtool/scripts/ht.sh b/plugins/hackingtool/scripts/ht.sh new file mode 100644 index 0000000..fe1f6dc --- /dev/null +++ b/plugins/hackingtool/scripts/ht.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env sh +# ht.sh — Python-free entrypoint for the pentest skill scripts. +# +# Runs the ht_*.py orchestration scripts using the host's Python when a real +# interpreter is present. When it isn't (common on Windows: no Python, or only +# the Microsoft Store stub), it bootstraps them inside a lightweight container +# that has both Python and the Docker CLI, with the host Docker socket mounted — +# so tool containers are still launched by the host daemon. +# +# Usage: +# sh ht.sh preflight +# sh ht.sh run [--args "..."] [--command "..."] [...] +# sh ht.sh search --q nmap # any ht_.py, called by +# +# Output is the underlying script's stdout verbatim (JSON). Diagnostics go to +# stderr so callers can still parse stdout as JSON. + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLUGIN_ROOT=$(dirname -- "$SCRIPT_DIR") + +if [ "$#" -lt 1 ]; then + echo "usage: sh ht.sh [args...]" >&2 + exit 2 +fi + +CMD=$1 +shift +TARGET="$SCRIPT_DIR/ht_${CMD}.py" +if [ ! -f "$TARGET" ]; then + echo "no such script: ht_${CMD}.py (in $SCRIPT_DIR)" >&2 + exit 2 +fi + +log() { echo "ht.sh: $*" >&2; } + +# ── Host OS (used to tell the containerized detector the real host) ──────────── +detect_host() { + case "$(uname -s 2>/dev/null || echo unknown)" in + Linux) # could be real Linux or WSL; both are "linux" + echo linux ;; + Darwin) + echo macos ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + echo windows ;; + *) + echo unknown ;; + esac +} +HOST=$(detect_host) + +# ── Find a *real* host Python (reject the Windows Store execution-alias stub) ── +find_python() { + for cand in python3 python; do + if command -v "$cand" >/dev/null 2>&1; then + # The stub exits non-zero and prints nothing on stdout for this. + if ver=$("$cand" -c 'import sys;print(sys.version_info[0])' 2>/dev/null) \ + && [ "$ver" = "3" ]; then + echo "$cand" + return 0 + fi + fi + done + return 1 +} + +# ── Is the host Docker daemon reachable? ────────────────────────────────────── +docker_ready() { + command -v docker >/dev/null 2>&1 || return 1 + v=$(docker version --format '{{.Server.Version}}' 2>/dev/null) || return 1 + [ -n "$v" ] +} + +# Fast path: a genuine host Python runs the script directly. +if PY=$(find_python); then + log "using host Python ($PY), backend auto-detected natively" + exec "$PY" "$TARGET" "$@" +fi + +log "no host Python found; attempting Docker bootstrap" + +if ! docker_ready; then + # Emit a structured, honest blocked verdict rather than a shell error so the + # skill can surface it. Mirrors ht_preflight's shape for the common case. + cat </dev/null 2>&1; then + log "building $BOOTSTRAP_IMAGE (one-time, ~10s)" + printf 'FROM docker:cli\nRUN apk add --no-cache python3\n' \ + | docker build -q -t "$BOOTSTRAP_IMAGE" - >&2 +fi + +# Host-native paths for volume mounts. On Git Bash, cygpath yields C:\... form +# that Docker Desktop understands; elsewhere the POSIX path is already correct. +if command -v cygpath >/dev/null 2>&1; then + ROOT_MOUNT=$(cygpath -w "$PLUGIN_ROOT") + HOST_CWD=$(cygpath -w "$PWD") + SOCK="//var/run/docker.sock" +else + ROOT_MOUNT="$PLUGIN_ROOT" + HOST_CWD="$PWD" + SOCK="/var/run/docker.sock" +fi + +# Don't let MSYS rewrite the container-side paths (/opt, the socket target). +export MSYS_NO_PATHCONV=1 + +log "backend=docker (bootstrapped); host=$HOST" +exec docker run --rm -i \ + -v "$ROOT_MOUNT":/opt/ht \ + -v "$SOCK":/var/run/docker.sock \ + -e "HT_FORCE_HOST=$HOST" \ + -e HT_FORCE_DOCKER=1 \ + -e "HT_HOST_CWD=$HOST_CWD" \ + "$BOOTSTRAP_IMAGE" \ + python3 "/opt/ht/scripts/ht_${CMD}.py" "$@" diff --git a/plugins/hackingtool/scripts/ht_env.py b/plugins/hackingtool/scripts/ht_env.py index 84e3c1d..86edf6e 100644 --- a/plugins/hackingtool/scripts/ht_env.py +++ b/plugins/hackingtool/scripts/ht_env.py @@ -28,6 +28,12 @@ def _has(cmd: str) -> bool: def _detect_host() -> str: + # When the scripts are bootstrapped inside a container (e.g. a Windows + # host with no native Python), platform.system() reports the container's + # OS, not the host's. The launcher passes the real host via HT_FORCE_HOST. + forced = os.environ.get("HT_FORCE_HOST", "").strip().lower() + if forced in ("linux", "macos", "windows", "unknown"): + return forced s = platform.system().lower() if s == "darwin": return "macos" @@ -75,14 +81,31 @@ def _wsl_distros() -> list[str]: def _docker_ready() -> bool: + # The launcher already confirmed the host daemon before bootstrapping into + # a container, and the docker CLI may be absent inside that container even + # though the socket is mounted. HT_FORCE_DOCKER carries that verdict in. + forced = os.environ.get("HT_FORCE_DOCKER", "").strip().lower() + if forced in ("1", "true", "yes"): + return True + if forced in ("0", "false", "no"): + return False if not _has("docker"): return False + # Probe the daemon with `docker version` rather than `docker info`: + # it is much lighter (no image/network/plugin enumeration) yet still + # round-trips to the daemon, so it returns non-zero when the daemon is + # down. On Windows Docker Desktop `docker info` frequently exceeds a + # short timeout on a cold or busy daemon, yielding a false negative that + # collapses the backend to `fallback`. A generous timeout absorbs the + # cold-start delay without hanging. try: r = subprocess.run( - ["docker", "info"], - capture_output=True, timeout=5, + ["docker", "version", "--format", "{{.Server.Version}}"], + capture_output=True, timeout=15, ) - return r.returncode == 0 + # Server section only renders when the daemon answered; a client-only + # response (daemon unreachable) exits non-zero. + return r.returncode == 0 and bool(r.stdout.strip()) except (subprocess.TimeoutExpired, OSError): return False diff --git a/plugins/hackingtool/scripts/ht_run.py b/plugins/hackingtool/scripts/ht_run.py index 10b74d3..2269e9c 100644 --- a/plugins/hackingtool/scripts/ht_run.py +++ b/plugins/hackingtool/scripts/ht_run.py @@ -227,7 +227,11 @@ def run_docker(command: str, timeout: int, image: str, command as args to the image's ENTRYPOINT. Otherwise we run via bash -lc (required for the generic kali-rolling image). """ - cwd = os.getcwd().replace("\\", "/") + # When bootstrapped inside a container, os.getcwd() is the bootstrap + # container's path, not the host's — but the tool container is launched by + # the *host* daemon via the mounted socket, so the volume source must be a + # host path. The launcher passes the real host cwd via HT_HOST_CWD. + cwd = (os.environ.get("HT_HOST_CWD") or os.getcwd()).replace("\\", "/") if len(cwd) > 1 and cwd[1] == ":": cwd = "/" + cwd[0].lower() + cwd[2:] diff --git a/plugins/hackingtool/skills/pentest/SKILL.md b/plugins/hackingtool/skills/pentest/SKILL.md index 9bfcc9d..523c8a3 100644 --- a/plugins/hackingtool/skills/pentest/SKILL.md +++ b/plugins/hackingtool/skills/pentest/SKILL.md @@ -10,9 +10,11 @@ You have real Bash, real filesystem, real process execution, and a fleet of pent ## Step 0 — Preflight (run first, every session) ```bash -python ${CLAUDE_PLUGIN_ROOT}/scripts/ht_preflight.py +sh ${CLAUDE_PLUGIN_ROOT}/scripts/ht.sh preflight ``` +`ht.sh` is a Python-free launcher: it runs the scripts with the host's Python when a real interpreter is present, and otherwise **bootstraps them inside a container** (Python + Docker CLI, host socket mounted) — so the skill works on a Windows box that has Docker but no Python. Tool containers are always launched by the host daemon either way. If the host *does* have Python, calling `python .../ht_preflight.py` directly still works — the launcher just makes it universal. + Read the `verdict` and act: - **`ready`** → state the backend in one sentence (e.g. "Running native on macOS" / "Running via Docker on Windows" / "Running native in WSL Ubuntu") and start work. @@ -38,9 +40,11 @@ When the ask is "audit / scan / find vulns", reach for: Anti-patterns: `for`-looping curl across many paths instead of `ffuf` / `nuclei`; hand-parsing TLS output instead of `nuclei -tags ssl`; saying "I don't have nuclei" when preflight returned `ready` (you have it via Docker). +Invoke any bundled script through the launcher by its short name — `sh ht.sh preflight`, `sh ht.sh search --q nmap`, `sh ht.sh run --args "..."`. It forwards all remaining flags to the matching `ht_.py` unchanged. + ## The execution model -Every tool runs through `ht_run.py`, which: +Every tool runs through `ht_run.py` (via `sh ht.sh run ...`), which: 1. Reads `ht_env.py` to pick a backend — **native** on Linux/macOS, **WSL** on Windows with a real distro, **Docker** anywhere with Docker Desktop. 2. Looks up a purpose-built Docker image for the tool if one exists (`instrumentisto/nmap`, `projectdiscovery/nuclei`, `caffix/amass`, 20+ more). Falls back to `kalilinux/kali-rolling`. @@ -51,10 +55,11 @@ Only one pre-block: tools flagged `interactive`. Bypass with `--force` + `--comm ## Bundled scripts -All at `${CLAUDE_PLUGIN_ROOT}/scripts/`. Emit JSON. +All at `${CLAUDE_PLUGIN_ROOT}/scripts/`. Emit JSON. Call them via `sh ht.sh [args]`. | Script | Purpose | |---|---| +| `ht.sh` | **Launcher.** Runs the scripts below via host Python, or a Docker bootstrap when no Python is present. `sh ht.sh [args]`. | | `ht_preflight.py` | **Run first.** Capability check + setup recommendations. | | `ht_search.py` | Query the tool index (`--q`, `--category`, `--tag`, `--capability`, `--os`). | | `ht_env.py` | Low-level env detect. (Preflight wraps this.) | @@ -64,8 +69,8 @@ All at `${CLAUDE_PLUGIN_ROOT}/scripts/`. Emit JSON. 1. **Preflight** — handle verdict per Step 0. 2. **Read the ask** — map to a workflow (`reference/workflows.md`) and apply the defaults table. -3. **Find tool ids** — `ht_search.py --q ""`. Don't guess; ids are namespaced (e.g. `web_attack.Nuclei`). -4. **Execute** — `ht_run.py --args "..."`, or `--command ""` for tools where `runnable=False`. Add `--network-host` for LAN, `--privileged` for raw sockets. +3. **Find tool ids** — `sh ht.sh search --q ""`. Don't guess; ids are namespaced (e.g. `web_attack.Nuclei`). +4. **Execute** — `sh ht.sh run --args "..."`, or `--command ""` for tools where `runnable=False`. Add `--network-host` for LAN, `--privileged` for raw sockets. 5. **Parse status:** `ok` → summarize highlights; `error` → report stderr, decide whether to retry; `fallback` → see `reference/runtime-fallbacks.md`; `timeout` → raise `--timeout` or chunk the scan. 6. **Compose** — `subfinder → httpx → nuclei`, `holehe → sherlock → maigret`. Feed outputs forward.