|
1 | 1 | #!/usr/bin/env bash |
2 | 2 | set -euo pipefail |
| 3 | +# nullglob: an unmatched glob expands to nothing rather than the literal |
| 4 | +# pattern, so the array-glob construct below is correct when no |
| 5 | +# versioned binary exists. |
| 6 | +shopt -s nullglob |
3 | 7 |
|
4 | 8 | # devkit-guard: PreToolUse hook that enforces workflow step ordering. |
5 | | -# Reads $CLAUDE_PLUGIN_DATA/session.json. Blocks out-of-step actions. |
6 | | -# Exit 0 = allow, Exit 2 + stderr = hard block. |
| 9 | +# Thin wrapper around `devkit-engine guard`. All policy lives in Go |
| 10 | +# (src/cmd/guard.go) so the shell side is just binary resolution + exec. |
7 | 11 | # |
8 | | -# Policy matrix: |
9 | | -# step_type=command, enforce=hard → allow only devkit MCP + TodoWrite |
10 | | -# (engine runs the command, not Claude) |
11 | | -# step_type=prompt, enforce=hard → allow Read/Grep/Glob/NotebookRead/ |
12 | | -# TodoWrite + devkit MCP. Forces the |
13 | | -# agent to advance before any |
14 | | -# write/bash/dispatch. Closes issue #63 |
15 | | -# drift hole. |
16 | | -# step_type=prompt, enforce=soft → allow everything, emit stderr nudge |
17 | | -# step_type=parallel → allow everything (engine dispatches) |
18 | | -# stale session (see lib/read-session.sh) → allow + warn; do not enforce |
19 | | -# against an orphaned state file. |
| 12 | +# Exit 0 = allow, exit 2 = hard block (with diagnostic on stderr). |
| 13 | +# Stdin (the PreToolUse JSON payload) is passed through unchanged so |
| 14 | +# the engine can parse tool_name itself — no jq, no python3. |
20 | 15 | # |
21 | | -# This hook uses an ALLOWLIST rather than a blocklist because the |
22 | | -# Claude Code tool surface evolves — Task, SlashCommand, ExitPlanMode, |
23 | | -# BashOutput, KillBash, TodoWrite, any mcp__* tool, and future names |
24 | | -# would silently bypass a blocklist of hardcoded names. |
25 | | - |
26 | | -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
27 | | -# shellcheck source=lib/read-session.sh |
28 | | -source "${SCRIPT_DIR}/lib/read-session.sh" |
| 16 | +# Binary search order: |
| 17 | +# 1. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine — local dev symlink |
| 18 | +# 2. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine-v* — shipped release asset |
| 19 | +# |
| 20 | +# The `bin/devkit` first-run-download wrapper is DELIBERATELY not |
| 21 | +# reachable from this hook: downloading release assets from a |
| 22 | +# time-limited PreToolUse hook is unsafe (timeout → silent fail-open). |
| 23 | +# When no binary is found we fail OPEN with a LOUD diagnostic so the |
| 24 | +# user notices on their first tool call — blocking every tool call on |
| 25 | +# a broken install would wedge the session with no recovery path |
| 26 | +# except manually editing hooks. |
29 | 27 |
|
30 | | -DATA_DIR="${CLAUDE_PLUGIN_DATA:-}" |
31 | | -if [[ -z "$DATA_DIR" ]]; then |
32 | | - printf 'devkit-guard: CLAUDE_PLUGIN_DATA unset — enforcement disabled\n' >&2 |
| 28 | +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}" |
| 29 | +if [[ -z "$PLUGIN_ROOT" ]]; then |
| 30 | + printf 'devkit-guard: CLAUDE_PLUGIN_ROOT unset — enforcement disabled\n' >&2 |
33 | 31 | exit 0 |
34 | 32 | fi |
35 | 33 |
|
36 | | -SESSION_FILE="${DATA_DIR}/session.json" |
| 34 | +BIN_DIR="$PLUGIN_ROOT/bin" |
37 | 35 |
|
38 | | -if ! parse_session_fields "$SESSION_FILE"; then |
39 | | - # python3 unavailable or JSON corrupt — fail closed if session file |
40 | | - # exists, otherwise fall through (no session = nothing to guard). |
41 | | - if [[ -f "$SESSION_FILE" ]]; then |
42 | | - printf 'BLOCKED: Cannot parse session state (python3 required or JSON corrupt). Remove %s to clear.\n' "$SESSION_FILE" >&2 |
43 | | - exit 2 |
44 | | - fi |
45 | | - exit 0 |
| 36 | +# Preferred: local-dev symlink (created by `make install-plugin`). |
| 37 | +if [[ -x "$BIN_DIR/devkit-engine" ]]; then |
| 38 | + exec "$BIN_DIR/devkit-engine" guard |
46 | 39 | fi |
47 | 40 |
|
48 | | -if [[ "$SESSION_STATUS" != "running" ]]; then |
49 | | - exit 0 |
50 | | -fi |
51 | | - |
52 | | -if [[ "$SESSION_STALE" == "1" ]]; then |
53 | | - printf 'devkit-guard: session %s idle past TTL — treating as orphaned (run devkit_start to reclaim)\n' "$SESSION_WORKFLOW" >&2 |
54 | | - exit 0 |
55 | | -fi |
56 | | - |
57 | | -# Read tool name from stdin. Matches PreToolUse payload format. |
58 | | -INPUT=$(cat) |
59 | | -TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null) || { |
60 | | - # Malformed payload — surface a diagnostic so the transcript shows |
61 | | - # why the next veto lists an empty tool name, instead of letting the |
62 | | - # BLOCKED message say "(attempted tool: )" with no hint. |
63 | | - printf 'devkit-guard: could not parse tool name from PreToolUse payload (python3 or JSON error)\n' >&2 |
64 | | - TOOL_NAME="" |
65 | | -} |
66 | | - |
67 | | -# Build a progress label for veto messages so the agent always sees |
68 | | -# workflow + position without another devkit_status round trip. |
69 | | -step_label() { |
70 | | - if [[ -n "$SESSION_CURRENT_INDEX" && -n "$SESSION_TOTAL_STEPS" ]]; then |
71 | | - local human_index=$((SESSION_CURRENT_INDEX + 1)) |
72 | | - printf '%s step %d/%d (%s)' "$SESSION_WORKFLOW" "$human_index" "$SESSION_TOTAL_STEPS" "$SESSION_CURRENT_STEP" |
73 | | - else |
74 | | - printf '%s (%s)' "$SESSION_WORKFLOW" "$SESSION_CURRENT_STEP" |
| 41 | +# Shipped release assets. Filenames look like |
| 42 | +# devkit-engine-v2.1.7-darwin-arm64. Pick the highest semver via |
| 43 | +# `sort -V` (GNU coreutils; available on Ubuntu runners and recent |
| 44 | +# macOS). A naive string comparison would pick v2.1.9 over v2.1.10 |
| 45 | +# because `9 > 1` lexicographically — sort -V understands version |
| 46 | +# fields and orders them correctly. |
| 47 | +candidates=() |
| 48 | +for candidate in "$BIN_DIR"/devkit-engine-v*; do |
| 49 | + [[ -x "$candidate" ]] && candidates+=("$candidate") |
| 50 | +done |
| 51 | +if (( ${#candidates[@]} > 0 )); then |
| 52 | + latest=$(printf '%s\n' "${candidates[@]}" | sort -V | tail -n1) |
| 53 | + if [[ -n "$latest" && -x "$latest" ]]; then |
| 54 | + exec "$latest" guard |
75 | 55 | fi |
76 | | -} |
77 | | - |
78 | | -# Command steps: allow ONLY the MCP tools needed to progress the |
79 | | -# workflow. Everything else is blocked, including future tools. |
80 | | -if [[ "$SESSION_STEP_TYPE" == "command" && "$SESSION_ENFORCE" == "hard" ]]; then |
81 | | - case "$TOOL_NAME" in |
82 | | - mcp__*devkit-engine*|mcp__devkit__*|devkit_advance|devkit_status|devkit_list|devkit_start) |
83 | | - exit 0 |
84 | | - ;; |
85 | | - TodoWrite) |
86 | | - exit 0 |
87 | | - ;; |
88 | | - *) |
89 | | - 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 |
90 | | - exit 2 |
91 | | - ;; |
92 | | - esac |
93 | | -fi |
94 | | - |
95 | | -# Prompt steps under hard enforcement: allow read-only evidence tools |
96 | | -# plus devkit MCP. Blocks Write/Edit/Bash/Task/WebFetch/other MCP so |
97 | | -# the agent cannot drift into unrelated work between step 1 and |
98 | | -# devkit_advance. See issue #63. |
99 | | -if [[ "$SESSION_STEP_TYPE" == "prompt" && "$SESSION_ENFORCE" == "hard" ]]; then |
100 | | - case "$TOOL_NAME" in |
101 | | - mcp__*devkit-engine*|mcp__devkit__*|devkit_advance|devkit_status|devkit_list|devkit_start) |
102 | | - exit 0 |
103 | | - ;; |
104 | | - Read|Grep|Glob|TodoWrite|NotebookRead) |
105 | | - exit 0 |
106 | | - ;; |
107 | | - *) |
108 | | - 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 |
109 | | - exit 2 |
110 | | - ;; |
111 | | - esac |
112 | | -fi |
113 | | - |
114 | | -# Prompt steps under soft enforcement: allow everything, but inject a |
115 | | -# stderr nudge so the transcript shows the agent that a step is open. |
116 | | -# Soft nudge is idempotent — if the agent ignores it, Stop gate still |
117 | | -# blocks via devkit-stop-guard.sh. |
118 | | -if [[ "$SESSION_STEP_TYPE" == "prompt" && "$SESSION_ENFORCE" != "hard" ]]; then |
119 | | - printf 'devkit-guard: %s is open — call devkit_advance when the step is complete.\n' "$(step_label)" >&2 |
120 | | - exit 0 |
121 | 56 | fi |
122 | 57 |
|
123 | | -# Parallel steps: engine is dispatching, agent needs full tool access. |
| 58 | +# No cached binary at all. Loud diagnostic + allow — see header |
| 59 | +# comment for the fail-open rationale. Point the user at the real |
| 60 | +# self-downloader at $BIN_DIR/devkit (that wrapper handles the |
| 61 | +# download + verify + cache flow on first run). There is no |
| 62 | +# `devkit install` subcommand — `devkit --version` triggers the same |
| 63 | +# cache-if-missing path with zero side effects. |
| 64 | +printf 'devkit-guard: ERROR no devkit-engine binary under %s — ' "$BIN_DIR" >&2 |
| 65 | +printf 'run `%s/devkit --version` once to download and cache the engine. ' "$BIN_DIR" >&2 |
| 66 | +printf 'Workflow enforcement is DISABLED until this is fixed.\n' >&2 |
124 | 67 | exit 0 |
0 commit comments