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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions plugins/hackingtool/scripts/ht.sh
Original file line number Diff line number Diff line change
@@ -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 <tool_id> [--args "..."] [--command "..."] [...]
# sh ht.sh search --q nmap # any ht_<name>.py, called by <name>
#
# 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 <preflight|run|search|env|...> [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 <<EOF
{
"verdict": "blocked",
"env": {"host": "$HOST", "docker": false, "preferred_backend": "fallback"},
"recommendations": [
{"priority": "critical",
"action": "Install Python 3 OR start Docker Desktop",
"why": "No host Python interpreter and no reachable Docker daemon, so neither the orchestration scripts nor the tool containers can run."}
],
"summary_for_user": "Blocked — no host Python and Docker is not running. Start Docker Desktop (or install Python 3) and re-run."
}
EOF
exit 0
fi

# ── Docker bootstrap ──────────────────────────────────────────────────────────
# A tiny image with Python + the Docker CLI, built once and cached. The Docker
# socket is mounted so ht_run.py's inner `docker run` reaches the host daemon.
BOOTSTRAP_IMAGE="ht-bootstrap:latest"
if ! docker image inspect "$BOOTSTRAP_IMAGE" >/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" "$@"
29 changes: 26 additions & 3 deletions plugins/hackingtool/scripts/ht_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion plugins/hackingtool/scripts/ht_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:]

Expand Down
15 changes: 10 additions & 5 deletions plugins/hackingtool/skills/pentest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <tool_id> --args "..."`. It forwards all remaining flags to the matching `ht_<name>.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`.
Expand All @@ -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 <name> [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 <name> [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.) |
Expand All @@ -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 "<keyword>"`. Don't guess; ids are namespaced (e.g. `web_attack.Nuclei`).
4. **Execute** — `ht_run.py <tool_id> --args "..."`, or `--command "<full>"` for tools where `runnable=False`. Add `--network-host` for LAN, `--privileged` for raw sockets.
3. **Find tool ids** — `sh ht.sh search --q "<keyword>"`. Don't guess; ids are namespaced (e.g. `web_attack.Nuclei`).
4. **Execute** — `sh ht.sh run <tool_id> --args "..."`, or `--command "<full>"` 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.

Expand Down