Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Coding principles (`clean-code`, `dry`, `yagni`, `dont-reinvent`, `executing`, `
| PreToolUse | **audit-trail** | Logs every command to `.devkit/audit.log` |
| PreToolUse | **pr-gate** | Prompts to run the pr-ready skill before `gh pr create` |
| PreToolUse | **rtk-rewrite** | Compresses Bash output via RTK (no-op if not installed) |
| PreToolUse | **devkit-guard** | Blocks out-of-step tools during workflow command steps |
| PreToolUse | **devkit-guard** | Blocks out-of-step tools during workflow command AND prompt steps (hard enforce); soft enforce emits a reminder. Skills are intentionally unguarded. |
| PostToolUse | **post-validate** | Suppressed errors, leaked secrets, writes outside repo |
| PostToolUse | **slop-detect** | AI code patterns — doc/code imbalance, restating comments |
| PostToolUse | **lang-review** | Language-aware checks: Go, TypeScript, Rust, Python, Shell |
Expand Down
127 changes: 83 additions & 44 deletions hooks/devkit-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,81 +5,120 @@ set -euo pipefail
# Reads $CLAUDE_PLUGIN_DATA/session.json. Blocks out-of-step actions.
# Exit 0 = allow, Exit 2 + stderr = hard block.
#
# Policy: during a command step (workflow.yml `command:`), the engine —
# not Claude — executes the shell. The only tool Claude is allowed to
# call is devkit_advance (which triggers execution and returns the next
# step). Everything else is blocked so Claude cannot observe or
# interfere with the state of the step.
# 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.
#
# 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"

DATA_DIR="${CLAUDE_PLUGIN_DATA:-}"
if [[ -z "$DATA_DIR" ]]; then
printf 'devkit-guard: CLAUDE_PLUGIN_DATA unset — enforcement disabled\n' >&2
exit 0 # not in plugin context
exit 0
fi

SESSION_FILE="${DATA_DIR}/session.json"
if [[ ! -f "$SESSION_FILE" ]]; then
exit 0 # no active workflow
fi

# Parse all session fields in a single python3 call (no jq dependency).
# Outputs tab-separated: status, step_type, enforce, current_step.
# Passes file path via sys.argv to prevent shell injection. Handles
# FileNotFoundError so a TOCTOU race (file cleared between -f and open)
# is treated as "no session," matching the pre-check intent.
SESSION_DATA=$(python3 -c "
import json, sys
try:
d = json.load(open(sys.argv[1]))
except FileNotFoundError:
print('\t'.join(['', '', '', '']))
sys.exit(0)
print('\t'.join([
d.get('status', ''),
d.get('step_type', ''),
d.get('enforce', 'hard'),
d.get('current_step', '')
]))
" "$SESSION_FILE" 2>/dev/null) || {
# python3 unavailable or JSON corrupt — fail closed if session file exists
printf 'BLOCKED: Cannot parse session state (python3 required or JSON corrupt). Remove %s to clear.\n' "$SESSION_FILE" >&2
exit 2
}
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
fi

IFS=$'\t' read -r STATUS STEP_TYPE ENFORCE CURRENT_STEP <<< "$SESSION_DATA"
if [[ "$SESSION_STATUS" != "running" ]]; then
exit 0
fi

if [[ "$STATUS" != "running" ]]; then
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
# 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 || echo "")
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"
fi
}

# Command steps: allow ONLY the MCP tools needed to progress the
# workflow. Everything else is blocked, including future tools the
# hook author hasn't heard of.
if [[ "$STEP_TYPE" == "command" && "$ENFORCE" == "hard" ]]; then
# workflow. Everything else is blocked, including future tools.
if [[ "$SESSION_STEP_TYPE" == "command" && "$SESSION_ENFORCE" == "hard" ]]; then
case "$TOOL_NAME" in
# MCP devkit tools — Claude uses these to drive the engine.
mcp__*devkit*|devkit_advance|devkit_status|devkit_list|devkit_start)
mcp__*devkit-engine*|mcp__devkit__*|devkit_advance|devkit_status|devkit_list|devkit_start)
exit 0
;;
# TodoWrite is a pure in-memory tracker with no side effects, allowed.
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' "$CURRENT_STEP" "$TOOL_NAME" >&2
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/parallel steps: allow everything (Claude needs full tool access).
# 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.
exit 0
Loading
Loading