Skip to content

Commit cd07206

Browse files
authored
Merge pull request #66 from 5uck1ess/fix/65-native-guard
fix(hooks): native devkit-engine guard subcommand (closes #65)
2 parents 51fad3b + 49dd599 commit cd07206

9 files changed

Lines changed: 1591 additions & 506 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ jobs:
4444
steps:
4545
- uses: actions/checkout@v4
4646

47+
# The devkit-guard shell wrappers exec the Go engine binary.
48+
# Provide Go so hooks_test.sh can build it on first run (the
49+
# script auto-builds if $CLAUDE_PLUGIN_ROOT/bin/devkit-engine is
50+
# missing) — without this, every "expected exit 2" fixture would
51+
# silently fall through the no-binary path and fail.
52+
- uses: actions/setup-go@v5
53+
with:
54+
go-version-file: src/go.mod
55+
cache-dependency-path: src/go.sum
56+
4757
- name: Run hook smoke tests
4858
run: bash hooks/hooks_test.sh
4959

hooks/devkit-guard.sh

Lines changed: 50 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,67 @@
11
#!/usr/bin/env bash
22
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
37

48
# 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.
711
#
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.
2015
#
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.
2927

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
3331
exit 0
3432
fi
3533

36-
SESSION_FILE="${DATA_DIR}/session.json"
34+
BIN_DIR="$PLUGIN_ROOT/bin"
3735

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
4639
fi
4740

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
7555
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
12156
fi
12257

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
12467
exit 0

0 commit comments

Comments
 (0)