diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c50194..92ed7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,16 @@ jobs: steps: - uses: actions/checkout@v4 + # The devkit-guard shell wrappers exec the Go engine binary. + # Provide Go so hooks_test.sh can build it on first run (the + # script auto-builds if $CLAUDE_PLUGIN_ROOT/bin/devkit-engine is + # missing) — without this, every "expected exit 2" fixture would + # silently fall through the no-binary path and fail. + - uses: actions/setup-go@v5 + with: + go-version-file: src/go.mod + cache-dependency-path: src/go.sum + - name: Run hook smoke tests run: bash hooks/hooks_test.sh diff --git a/hooks/devkit-guard.sh b/hooks/devkit-guard.sh index 248dc85..1b4901e 100755 --- a/hooks/devkit-guard.sh +++ b/hooks/devkit-guard.sh @@ -1,124 +1,67 @@ #!/usr/bin/env bash set -euo pipefail +# nullglob: an unmatched glob expands to nothing rather than the literal +# pattern, so the array-glob construct below is correct when no +# versioned binary exists. +shopt -s nullglob # devkit-guard: PreToolUse hook that enforces workflow step ordering. -# Reads $CLAUDE_PLUGIN_DATA/session.json. Blocks out-of-step actions. -# Exit 0 = allow, Exit 2 + stderr = hard block. +# Thin wrapper around `devkit-engine guard`. All policy lives in Go +# (src/cmd/guard.go) so the shell side is just binary resolution + exec. # -# Policy matrix: -# step_type=command, enforce=hard → allow only devkit MCP + TodoWrite -# (engine runs the command, not Claude) -# step_type=prompt, enforce=hard → allow Read/Grep/Glob/NotebookRead/ -# TodoWrite + devkit MCP. Forces the -# agent to advance before any -# write/bash/dispatch. Closes issue #63 -# drift hole. -# step_type=prompt, enforce=soft → allow everything, emit stderr nudge -# step_type=parallel → allow everything (engine dispatches) -# stale session (see lib/read-session.sh) → allow + warn; do not enforce -# against an orphaned state file. +# Exit 0 = allow, exit 2 = hard block (with diagnostic on stderr). +# Stdin (the PreToolUse JSON payload) is passed through unchanged so +# the engine can parse tool_name itself — no jq, no python3. # -# This hook uses an ALLOWLIST rather than a blocklist because the -# Claude Code tool surface evolves — Task, SlashCommand, ExitPlanMode, -# BashOutput, KillBash, TodoWrite, any mcp__* tool, and future names -# would silently bypass a blocklist of hardcoded names. - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/read-session.sh -source "${SCRIPT_DIR}/lib/read-session.sh" +# Binary search order: +# 1. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine — local dev symlink +# 2. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine-v* — shipped release asset +# +# The `bin/devkit` first-run-download wrapper is DELIBERATELY not +# reachable from this hook: downloading release assets from a +# time-limited PreToolUse hook is unsafe (timeout → silent fail-open). +# When no binary is found we fail OPEN with a LOUD diagnostic so the +# user notices on their first tool call — blocking every tool call on +# a broken install would wedge the session with no recovery path +# except manually editing hooks. -DATA_DIR="${CLAUDE_PLUGIN_DATA:-}" -if [[ -z "$DATA_DIR" ]]; then - printf 'devkit-guard: CLAUDE_PLUGIN_DATA unset — enforcement disabled\n' >&2 +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}" +if [[ -z "$PLUGIN_ROOT" ]]; then + printf 'devkit-guard: CLAUDE_PLUGIN_ROOT unset — enforcement disabled\n' >&2 exit 0 fi -SESSION_FILE="${DATA_DIR}/session.json" +BIN_DIR="$PLUGIN_ROOT/bin" -if ! parse_session_fields "$SESSION_FILE"; then - # python3 unavailable or JSON corrupt — fail closed if session file - # exists, otherwise fall through (no session = nothing to guard). - if [[ -f "$SESSION_FILE" ]]; then - printf 'BLOCKED: Cannot parse session state (python3 required or JSON corrupt). Remove %s to clear.\n' "$SESSION_FILE" >&2 - exit 2 - fi - exit 0 +# Preferred: local-dev symlink (created by `make install-plugin`). +if [[ -x "$BIN_DIR/devkit-engine" ]]; then + exec "$BIN_DIR/devkit-engine" guard fi -if [[ "$SESSION_STATUS" != "running" ]]; then - exit 0 -fi - -if [[ "$SESSION_STALE" == "1" ]]; then - printf 'devkit-guard: session %s idle past TTL — treating as orphaned (run devkit_start to reclaim)\n' "$SESSION_WORKFLOW" >&2 - exit 0 -fi - -# Read tool name from stdin. Matches PreToolUse payload format. -INPUT=$(cat) -TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null) || { - # Malformed payload — surface a diagnostic so the transcript shows - # why the next veto lists an empty tool name, instead of letting the - # BLOCKED message say "(attempted tool: )" with no hint. - printf 'devkit-guard: could not parse tool name from PreToolUse payload (python3 or JSON error)\n' >&2 - TOOL_NAME="" -} - -# Build a progress label for veto messages so the agent always sees -# workflow + position without another devkit_status round trip. -step_label() { - if [[ -n "$SESSION_CURRENT_INDEX" && -n "$SESSION_TOTAL_STEPS" ]]; then - local human_index=$((SESSION_CURRENT_INDEX + 1)) - printf '%s step %d/%d (%s)' "$SESSION_WORKFLOW" "$human_index" "$SESSION_TOTAL_STEPS" "$SESSION_CURRENT_STEP" - else - printf '%s (%s)' "$SESSION_WORKFLOW" "$SESSION_CURRENT_STEP" +# Shipped release assets. Filenames look like +# devkit-engine-v2.1.7-darwin-arm64. Pick the highest semver via +# `sort -V` (GNU coreutils; available on Ubuntu runners and recent +# macOS). A naive string comparison would pick v2.1.9 over v2.1.10 +# because `9 > 1` lexicographically — sort -V understands version +# fields and orders them correctly. +candidates=() +for candidate in "$BIN_DIR"/devkit-engine-v*; do + [[ -x "$candidate" ]] && candidates+=("$candidate") +done +if (( ${#candidates[@]} > 0 )); then + latest=$(printf '%s\n' "${candidates[@]}" | sort -V | tail -n1) + if [[ -n "$latest" && -x "$latest" ]]; then + exec "$latest" guard fi -} - -# Command steps: allow ONLY the MCP tools needed to progress the -# workflow. Everything else is blocked, including future tools. -if [[ "$SESSION_STEP_TYPE" == "command" && "$SESSION_ENFORCE" == "hard" ]]; then - case "$TOOL_NAME" in - mcp__*devkit-engine*|mcp__devkit__*|devkit_advance|devkit_status|devkit_list|devkit_start) - exit 0 - ;; - TodoWrite) - exit 0 - ;; - *) - printf 'BLOCKED: Command step "%s" in progress — the engine runs this step. Call devkit_advance to execute it. (attempted tool: %s)\n' "$(step_label)" "$TOOL_NAME" >&2 - exit 2 - ;; - esac -fi - -# Prompt steps under hard enforcement: allow read-only evidence tools -# plus devkit MCP. Blocks Write/Edit/Bash/Task/WebFetch/other MCP so -# the agent cannot drift into unrelated work between step 1 and -# devkit_advance. See issue #63. -if [[ "$SESSION_STEP_TYPE" == "prompt" && "$SESSION_ENFORCE" == "hard" ]]; then - case "$TOOL_NAME" in - mcp__*devkit-engine*|mcp__devkit__*|devkit_advance|devkit_status|devkit_list|devkit_start) - exit 0 - ;; - Read|Grep|Glob|TodoWrite|NotebookRead) - exit 0 - ;; - *) - printf 'BLOCKED: devkit workflow %s is at a prompt step — gather evidence with Read/Grep/Glob then call devkit_advance. (attempted tool: %s)\n' "$(step_label)" "$TOOL_NAME" >&2 - exit 2 - ;; - esac -fi - -# Prompt steps under soft enforcement: allow everything, but inject a -# stderr nudge so the transcript shows the agent that a step is open. -# Soft nudge is idempotent — if the agent ignores it, Stop gate still -# blocks via devkit-stop-guard.sh. -if [[ "$SESSION_STEP_TYPE" == "prompt" && "$SESSION_ENFORCE" != "hard" ]]; then - printf 'devkit-guard: %s is open — call devkit_advance when the step is complete.\n' "$(step_label)" >&2 - exit 0 fi -# Parallel steps: engine is dispatching, agent needs full tool access. +# No cached binary at all. Loud diagnostic + allow — see header +# comment for the fail-open rationale. Point the user at the real +# self-downloader at $BIN_DIR/devkit (that wrapper handles the +# download + verify + cache flow on first run). There is no +# `devkit install` subcommand — `devkit --version` triggers the same +# cache-if-missing path with zero side effects. +printf 'devkit-guard: ERROR no devkit-engine binary under %s — ' "$BIN_DIR" >&2 +printf 'run `%s/devkit --version` once to download and cache the engine. ' "$BIN_DIR" >&2 +printf 'Workflow enforcement is DISABLED until this is fixed.\n' >&2 exit 0 diff --git a/hooks/devkit-guard_test.sh b/hooks/devkit-guard_test.sh deleted file mode 100755 index ab50a3e..0000000 --- a/hooks/devkit-guard_test.sh +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Fixture matrix test for devkit-guard.sh. -# Seeds CLAUDE_PLUGIN_DATA with a crafted session.json and pipes a -# synthetic PreToolUse payload on stdin. Asserts exit code and the -# substring of whatever stderr diagnostic the guard emitted. -# -# Run: bash hooks/devkit-guard_test.sh - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -GUARD="${SCRIPT_DIR}/devkit-guard.sh" - -if [[ ! -x "$GUARD" ]]; then - chmod +x "$GUARD" || true -fi - -PASS=0 -FAIL=0 -FAILED_CASES=() - -# run_case