Skip to content

fix(hooks): native devkit-engine guard subcommand (closes #65) - #66

Merged
5uck1ess merged 2 commits into
mainfrom
fix/65-native-guard
Apr 11, 2026
Merged

fix(hooks): native devkit-engine guard subcommand (closes #65)#66
5uck1ess merged 2 commits into
mainfrom
fix/65-native-guard

Conversation

@5uck1ess

@5uck1ess 5uck1ess commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Closes #65

Replaces python3-based parsing in devkit-guard.sh / devkit-stop-guard.sh with a new devkit-engine guard [--tool-name] [--stop] Cobra subcommand. Policy is unchanged; the substrate moves to Go for portability, speed, and testability.

Why

  • Windows / minimal containers often lack python3. The old hooks either hard-blocked every tool call on those hosts (fail-closed on python3 unavailable) or silently skipped enforcement on timeout.
  • Python3 cold-start was 50-150 ms per hook invocation, compounding in high-churn workflow steps.
  • Bash + python3 + jq split policy across three languages. A Go subcommand lets the engine and guard share the exact same SessionState parser (lib.ReadSessionJSON), eliminating drift.

What

Native subcommand (src/cmd/guard.go)

  • Overrides rootCmd.PersistentPreRunE to a no-op so the guard never requires a git repo or opens the SQLite DB — critical because it runs on every PreToolUse.
  • Hot path fix: sessionFileExists (pure os.Stat) runs BEFORE lib.ReadSessionJSON so the no-workflow path skips the mkdir + session.json.lock create side effects. Verified: 20 no-workflow invocations leave an empty $CLAUDE_PLUGIN_DATA.
  • Unconditional fail-closed on any read error after a positive existence check (permission, quota, lock failure, parse error). Silently failing open on permission errors would let a broken plugin data dir disarm the guard.
  • isDevkitMCPTool is anchored on the full plugin+server prefix (mcp__plugin_devkit_devkit-engine__ + mcp__devkit__ short-form). This is tighter than the pre-fix(hooks): enforce workflow progression on prompt steps + orphan recovery #64 mcp__*devkit* substring AND tighter than fix(hooks): enforce workflow progression on prompt steps + orphan recovery #64's mcp__*devkit-engine* glob — a hypothetical second MCP server under the devkit plugin cannot silently inherit command-step permissions.
  • --stop mode emits the Stop-hook JSON verdict byte-for-byte matching the old shell output. writeStopVerdict panics on the unreachable json.Marshal failure path (dead code removed).
  • readToolNameFromStdin returns (string, error) so three failure modes (read / empty / parse) log distinctly. Empty tool still default-denies under hard enforcement — security posture unchanged, debuggability improved.
  • staleTTL honours DEVKIT_SESSION_STALE_TTL_SECONDS with TrimSpace + warning on garbage values. sessionIsStale logs a one-line WARNING when both timestamps are zero so a wedged session leaves a debuggable trail.
  • Args: cobra.NoArgs so extra positional args fail loudly during dev.

Thin shell wrappers (hooks/devkit-guard.sh, hooks/devkit-stop-guard.sh)

  • shopt -s nullglob + highest-sorted versioned binary selection (the naive "first glob match" would pick v2.1.0 over v2.1.10).
  • bin/devkit first-run-download fallback is deliberately removed from the hook path. Downloading release assets from a time-limited hook is unsafe — a fresh install should fail closed with a diagnostic pointing at devkit install rather than silently blocking on a network call.
  • When no binary is found: loud stderr naming the search path, then allow (guard) / approve (stop-guard). Broken installs trip the user's attention on first tool call.

hooks/hooks.json

  • Timeout raised from 2s → 10s to give margin for macOS Gatekeeper, Windows Defender, and network-mount cold starts.

Deleted

  • hooks/lib/read-session.sh (python3 session parser, superseded)
  • hooks/devkit-guard_test.sh (shell fixture matrix, ported to Go as 30+ table-driven rows)

Test coverage

Go (src/cmd/guard_test.go, ~800 lines):

  • 30+ table-driven policy-matrix cases with wantStderrSubstr pinning veto-message wording
  • Allowlist bypass negatives: mcp__plugin_evil_server__devkit_masquerade → block, mcp__plugin_devkit_other_server__probe → block
  • Fixture-parity gaps from the deleted shell test: prompt+hard+TodoWrite, prompt+soft+Write, parallel+soft+Bash
  • Schema-drift pins: uppercase RUNNING, TotalSteps=0 label fallback, --tool-name flag vs stdin precedence, empty / malformed stdin
  • DEVKIT_SESSION_STALE_TTL_SECONDS garbage matrix (6 subtests): non-numeric, negative, zero, trailing-space, empty, overflow
  • Longer TTL override, zero-timestamp warning, unreadable session file (mode 0o000, Unix-only), stale+prompt+soft
  • t.Parallel() prohibition comment documenting the package-level IO hook globals

Shell (hooks/hooks_test.sh, +105 lines):

  • CLAUDE_PLUGIN_ROOT unset → disabled + exit 0 / approve
  • Empty bin/ directory → loud warning + allow / approve (the "fresh clone before first build" scenario)
  • Versioned binary only (no local-dev symlink) → exec with guard arg
  • Multiple versioned binaries coexisting → pick highest-sorted executable

Verification

  • go test ./... — all packages green
  • hooks/hooks_test.sh52/52 pass (up from 46), including all 6 new shell wrapper binary-resolution cases
  • Latency: ~8.5ms per guard invocation over a 20-run wall clock (vs. 50-150ms python3 cold start), with empty $CLAUDE_PLUGIN_DATA verified after 20 runs — no session.json.lock leaked as a side effect on the no-workflow hot path
  • Cross-compile clean: linux/amd64, darwin/arm64, windows/amd64

History

This PR went through a full mega-review (tri-review + pr-review-toolkit, 6 agents). The review found 2 critical issues my first pass missed:

  • N1: lib.ReadSessionJSON error path was silently failing OPEN on permission errors (sessionFileExists post-hoc check could return false on broken data dirs)
  • N2: isDevkitMCPTool substring match would have allowed any third-party MCP server with "devkit" anywhere in its tool name

Both plus 3 other HIGH-priority findings and 8 MEDIUM recommendations are fixed in this single-commit rebase onto main. See the commit message for the full catalogue.

Test plan

  • CI green across lint / go test / hooks smoke / cross-platform matrix
  • Manual: run a full feature workflow end-to-end on a machine without python3; confirm enforcement works
  • Manual: verify the version-glob selection on a machine with multiple cached devkit-engine-v* binaries
  • Manual: confirm the 10s timeout is sufficient under Windows Defender cold scan

5uck1ess added a commit that referenced this pull request Apr 11, 2026
Applies the must-fix + strong-recommend findings from tri-review +
pr-review-toolkit on PR #66. Policy is unchanged; correctness, safety,
and diagnostics improve across the board.

Must-fix (N1, N2, B1, B2, B3):

- N1: lib.ReadSessionJSON no longer runs on the no-workflow hot path.
  runPreToolGuard + runStopGuard now short-circuit on sessionFileExists
  BEFORE acquiring the session lock, eliminating the mkdir +
  session.json.lock side effects that the old code created on every
  tool call. Any error from ReadSessionJSON after the file is
  confirmed present now unconditionally fails closed — previously the
  post-hoc sessionFileExists fallback could fail-OPEN on permission
  errors or TOCTOU races, silently disarming the guard.

- N2: isDevkitMCPTool is tightened from the unanchored mcp__*devkit*
  substring match (ported from the shell glob) to exact prefix
  matching on mcp__plugin_devkit_ and mcp__devkit__. The old loose
  match would have allowed a third-party MCP server with "devkit"
  anywhere in its tool name to bypass the command-step allowlist
  (e.g. mcp__plugin_evil_server__devkit_masquerade). Negative test
  row added to pin the tightened behaviour.

- B1: shell wrappers now shopt -s nullglob and pick the highest-sorted
  versioned binary instead of executing the first glob-expansion
  match. The old for-loop picked v2.1.0 over v2.1.10 lexicographically.
  Still imperfect under 9→10 semver transitions; header comment
  documents the limitation and points at the long-term fix (a
  devkit-engine-latest symlink maintained by the installer).

- B2: BLOCKED stderr diagnostic no longer embeds the raw Go error.
  Wording is pinned to a stable string so minor encoding/json version
  changes don't break downstream log parsers.

- B3: hook timeout in hooks.json raised from 2s to 10s to give room
  for cold-start on macOS Gatekeeper, Windows Defender, and network
  mounts. The bin/devkit first-run-download fallback is REMOVED from
  the hook path entirely — downloading release assets from a
  time-limited hook is unsafe, and a fresh install should fail closed
  with a diagnostic pointing at `devkit install` rather than silently
  blocking on a network call. The loud "no binary" stderr makes a
  broken install visible on the user's first tool call.

Strong-recommends (M1, M2, M3, M4, M5, M6, plus test coverage):

- M1: sessionIsStale now emits a one-line stderr warning when both
  UpdatedAt and StartedAt are zero, so a schema-drift wedge leaves a
  debuggable trail instead of silently disabling orphan recovery.

- M2: staleTTL now strings.TrimSpace the env var (handles copy-paste
  trailing whitespace), and logs a stderr warning when the value is
  non-numeric or non-positive. Silent degradation to default was
  untestable by operators tuning the knob.

- M3: readToolNameFromStdin returns (string, error) instead of
  collapsing three failure modes (read error, empty, parse error)
  into "". The call site logs the error distinctly, and the empty
  tool name still falls through to default-deny under hard
  enforcement — security posture unchanged, debuggability improved.
  Block diagnostics now print "<unknown>" instead of a dangling paren
  when the tool name is empty.

- M4: guardCmd now declares Args: cobra.NoArgs so
  `devkit-engine guard extra_positional` fails loudly in development
  instead of silently ignoring the extra arg.

- M5: top-of-file comment in guard_test.go documents the t.Parallel()
  prohibition and why (six package-level globals + t.Setenv). Follow-
  up PR should refactor to a guardContext struct.

- M6: the TestGuardPreToolUse table now carries a wantStderrSubstr
  field so block-message wording is pinned. Previously any regression
  in veto text would silently pass the suite.

- writeStopVerdict fallback branch deleted (dead code — json.Marshal
  on stopVerdict cannot fail). Panics on unreachable instead, so any
  future field addition that breaks marshalling trips CI.

- Broken stdout write in writeStopVerdict now logs to stderr so a
  post-mortem trail exists if Claude Code's stdout pipe breaks.

- sessionFileExists now inlines filepath.Join(dataDir, "session.json")
  for readability, no package hop.

New test coverage (Go):

- Fixture parity gaps from the deleted hooks/devkit-guard_test.sh:
  prompt+hard+TodoWrite, prompt+soft+Write, parallel+soft+Bash.
- Schema-drift pins: Status="RUNNING" uppercase treated as
  not-running; TotalSteps=0 exercises stepLabel() no-index branch;
  --tool-name flag vs stdin precedence; empty stdin under
  command+hard blocks; malformed stdin JSON blocks with error log.
- Allowlist bypass negative: mcp__plugin_evil_server__devkit_masquerade
  must block; mcp__devkit__advance short-form must allow.
- DEVKIT_SESSION_STALE_TTL_SECONDS garbage matrix (6 subtests):
  non-numeric, negative, zero, trailing-space (trimmed), empty,
  overflow.
- Longer TTL override: 2h env + 45min-old session stays fresh.
- Zero-timestamp warning: session with no UpdatedAt/StartedAt logs
  the anomaly and still enforces.
- Unreadable session file (mode 0o000) fails closed. Unix-only.
- Stale session under prompt+soft (previously only command+hard was
  covered). Pins the stale check's precedence over the step-type
  switch.

New test coverage (shell):

- CLAUDE_PLUGIN_ROOT unset → disabled + exit 0 (guard) / approve
  (stop-guard). Previously completely untested.
- Empty bin/ directory → loud warning + allow (guard) / approve
  (stop-guard). The "fresh clone before first build" scenario.
- Versioned binary only (no local-dev symlink) → exec with guard arg.
- Multiple versioned binaries coexisting → pick the highest-sorted
  executable. Pins B1's behaviour so any future refactor of the
  selection strategy must update the test.

Verification:

- go test ./... — all packages green.
- hooks/hooks_test.sh — 52/52 pass (up from 46), including the 6 new
  shell wrapper binary-resolution cases.
- Latency on the no-workflow hot path: still ~8.5ms per call, and
  verified empty $CLAUDE_PLUGIN_DATA after 20 runs — no lock file
  leaked as a side effect (N1 regression fixed).
- Cross-compile clean for linux/amd64 and windows/amd64.
Base automatically changed from fix/63-workflow-guard-prompt-steps to main April 11, 2026 04:28
Replaces python3-based parsing in devkit-guard.sh / devkit-stop-guard.sh
with a new `devkit-engine guard [--tool-name] [--stop]` Cobra subcommand.
Policy is unchanged; the substrate moves to Go for portability, speed,
and testability.

Why
---
- Windows / minimal containers often lack python3. The old hooks hard-
  blocked every tool call on those hosts (fail-closed on python3
  unavailable) or silently skipped enforcement.
- Python3 cold-start was 50-150 ms per hook invocation on every tool
  call, compounding in high-churn workflow steps.
- Bash + python3 + jq split policy across three languages. A Go
  subcommand lets the engine and the guard share the exact same
  SessionState parser (lib.ReadSessionJSON), eliminating drift.

Native subcommand (src/cmd/guard.go, +398 lines)
-------------------------------------------------
- New `devkit-engine guard` command. Overrides rootCmd.PersistentPreRunE
  to a no-op so the guard never requires a git repo, never opens the
  SQLite DB, and never fails on hosts without .git. Cobra lets a child
  command shadow the parent's persistent pre-run entirely.
- Hot path (no active workflow): single read-only os.Stat, zero writes.
  sessionFileExists runs BEFORE lib.ReadSessionJSON so we skip the
  withSessionLock mkdir + session.json.lock create side effects on
  every PreToolUse call where the user has no running session.
- Any error after a positive sessionFileExists result fails CLOSED
  unconditionally — permission errors, quota, lock-acquire failures,
  parse errors all return a BLOCKED diagnostic pointing at the file.
  Silently fail-open on permission errors would let a broken plugin
  data dir disarm the guard with zero user-visible signal.
- Policy matrix mirrors PR #64 exactly:
    command + hard → only devkit MCP + TodoWrite
    prompt  + hard → read-only evidence tools + devkit MCP
    prompt  + soft → allow with stderr nudge
    parallel       → allow (engine is dispatching)
    stale session  → allow with stderr warning (orphan recovery)
- isDevkitMCPTool is anchored on the full plugin+server prefix
  (mcp__plugin_devkit_devkit-engine__) plus the short-form
  mcp__devkit__ namespace. This is tighter than PR #64's shell glob
  (mcp__*devkit-engine*) and much tighter than the original
  mcp__*devkit* substring — even a hypothetical second MCP server
  under the devkit plugin cannot silently inherit command-step
  permissions.
- effectiveEnforce defaults empty Enforce to "hard", mirroring the
  shell hook's python .get('enforce','hard') so a schema-drift gap
  can't silently disarm enforcement.
- sessionIsStale falls back UpdatedAt → StartedAt → "fresh", but also
  logs a one-line WARNING when both timestamps are zero so a wedged
  session leaves a debuggable trail.
- staleTTL honours DEVKIT_SESSION_STALE_TTL_SECONDS. Whitespace is
  trimmed (TrimSpace) so copy-paste trailing-space doesn't bite;
  non-numeric / non-positive values log a warning and fall back to
  default rather than silently degrading.
- readToolNameFromStdin returns (string, error) so the three failure
  modes (read error / empty / parse error) can be logged distinctly.
  Empty tool name still falls through to default-deny under hard
  enforcement, so the security posture is unchanged; only the
  diagnostic improves.
- Block diagnostics substitute "<unknown>" when the tool name is
  empty, so log readers don't see a dangling "(attempted tool: )".
- --stop mode emits Stop-hook JSON verdict on stdout (no trailing
  newline, matching the shell printf '%s' output byte-for-byte).
  writeStopVerdict panics on the unreachable json.Marshal failure
  path instead of silently writing a hardcoded fallback — any future
  field addition that breaks marshalling trips CI.
- Broken stdout in writeStopVerdict now logs to stderr so a pipe
  failure leaves some post-mortem trail.
- guardCmd declares Args: cobra.NoArgs so extra positional args fail
  loudly in development rather than being silently dropped.

Thin shell wrappers (hooks/devkit-guard.sh, hooks/devkit-stop-guard.sh)
----------------------------------------------------------------------
Both scripts reduce to binary-resolution + exec:
  1. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine (local-dev symlink)
  2. $CLAUDE_PLUGIN_ROOT/bin/devkit-engine-v* (shipped release asset)
- shopt -s nullglob so an unmatched glob expands to nothing instead
  of iterating once with the literal pattern string.
- When multiple versioned binaries coexist, pick the highest-sorted
  executable match. The naive "first glob match" would pick v2.1.0
  over v2.1.10 lexicographically.
- The bin/devkit first-run-download fallback has been DELIBERATELY
  removed from the hook path. Downloading release assets from a
  time-limited hook is unsafe (fail-open on timeout), and a fresh
  install should fail closed with a diagnostic pointing at
  `devkit install` rather than silently blocking on a network call.
- When no binary is found, emit a LOUD stderr diagnostic naming the
  search path and instructing `devkit install` — then allow (guard)
  or approve (stop-guard). A broken install should trip the user's
  attention on first tool call rather than silently disarming
  enforcement.
- No python3, no jq, no subshell parsing. Stdin (the PreToolUse JSON
  payload) passes straight through exec to the Go binary.

hooks/hooks.json
----------------
Timeout for devkit-guard.sh and devkit-stop-guard.sh raised from 2s
to 10s. The Go binary clears the old 8.5ms budget by three orders of
magnitude under warm conditions, but 10s gives room for:
- macOS Gatekeeper quarantine scan on first exec
- Windows Defender cold scan
- Network filesystem exec stall
- Cold Go runtime init on large binaries

Deleted
-------
- hooks/lib/read-session.sh — python3 session parser, superseded.
- hooks/devkit-guard_test.sh — shell fixture matrix, ported to
  src/cmd/guard_test.go as 40+ table-driven cases.

Test coverage (src/cmd/guard_test.go, +800 lines)
-------------------------------------------------
- 30+ table-driven rows covering the full policy matrix including
  fixture-parity gaps from the deleted shell test: prompt+hard+TodoWrite,
  prompt+soft+Write, parallel+soft+Bash.
- Schema-drift pins: Status="RUNNING" uppercase case-sensitivity,
  TotalSteps=0 label fallback, --tool-name flag vs stdin precedence,
  empty stdin / malformed stdin under command+hard.
- wantStderrSubstr field on the table pins veto-message wording so
  any regression in the block diagnostic fails the suite.
- Allowlist bypass negatives:
    mcp__plugin_evil_server__devkit_masquerade → block
    mcp__plugin_devkit_other_server__probe    → block (tightening
      beyond PR #64's shell glob)
  Positive cases:
    mcp__plugin_devkit_devkit-engine__devkit_advance → allow
    mcp__devkit__advance → allow (short-form)
- DEVKIT_SESSION_STALE_TTL_SECONDS garbage matrix (6 subtests):
  non-numeric, negative, zero, trailing-space (trimmed), empty,
  integer overflow.
- Longer TTL override (7200s + 45min-old session stays fresh).
- Zero-timestamp warning pinned (session with no UpdatedAt/StartedAt
  logs anomaly and still enforces).
- Unreadable session file (mode 0o000, Unix-only) fails closed with
  BLOCKED diagnostic.
- Stale session under prompt+soft (previously only command+hard was
  covered).
- Top-of-file comment documents the t.Parallel() prohibition: the
  test helper mutates package-level IO globals, and parallelism
  would race. Follow-up refactor to a guardContext struct would
  enable parallel tests.

Test coverage (hooks/hooks_test.sh, +105 lines)
-----------------------------------------------
- CLAUDE_PLUGIN_ROOT unset → disabled + exit 0 / approve.
- Empty bin/ directory → loud warning + allow / approve.
  (The "fresh clone before first build" scenario — previously
  completely untested.)
- Versioned binary only (no local-dev symlink) → exec with guard arg.
- Multiple versioned binaries coexisting → pick the highest-sorted
  executable. Pins B1's contract against future refactors.

Verification
------------
- go test ./... — all packages green (40+ guard cases, 6 stale-TTL
  subtests, 5 dedicated scenario tests).
- hooks/hooks_test.sh — 52/52 pass, up from 46.
- Latency: ~8.5 ms per guard invocation over a 20-run wall clock
  (vs. 50-150 ms python3 cold start), with empty $CLAUDE_PLUGIN_DATA
  verified after 20 runs — no session.json.lock leaked as a side
  effect on the no-workflow hot path.
- Cross-compile clean: linux/amd64, darwin/arm64, windows/amd64.

Rebased onto main after PR #64 + 2.1.8 version bump. PR #64's engine-
side changes (stale-session reclaim notice in tools.go, UpdatedAt
side-effect doc in state_json.go) are inherited from main unchanged.
@5uck1ess
5uck1ess force-pushed the fix/65-native-guard branch from c175ee8 to a152211 Compare April 11, 2026 04:35
…ference

Addresses two CI failures on a152211 plus two blockers surfaced by the
second-pass pr-review-toolkit code review.

CI failures
-----------
- build-and-test "Check formatting": gofmt -d flagged two docstring
  comment-list indentations and one test struct alignment. `gofmt -w`
  applied; no semantic change.
- hook-smoke-tests "Run hook smoke tests": the CI job runs
  `bash hooks/hooks_test.sh` from the repo root with no Go toolchain
  set up and no CLAUDE_PLUGIN_ROOT exported. Pre-refactor, that was
  fine because the shell hooks had all their logic inline. After the
  python3 → Go rewrite the wrappers exec a binary that doesn't exist
  on a fresh CI checkout, so every "expected exit 2" fixture silently
  fell through the no-binary fail-open path and looked like a regression.

  Two-layer fix:
  1. hooks/hooks_test.sh now auto-detects the repo root, defaults
     CLAUDE_PLUGIN_ROOT to it when unset, and auto-builds the engine
     binary from src/ if it's missing. This lets `bash hooks/hooks_test.sh`
     just work from a fresh clone, for local dev and CI alike.
  2. .github/workflows/ci.yml hook-smoke-tests job now includes
     actions/setup-go@v5 so the auto-build path is reachable.

Second-pass review blockers
---------------------------
- C1 (review): the version-picker string-comparison loop in
  hooks/devkit-guard.sh and devkit-stop-guard.sh was the exact bug
  the comment above it warned about. `v2.1.9 > v2.1.10` is the
  lexicographic ordering ("9" > "1"), so the loop would have silently
  run a stale engine after the 10th patch release. Replaced with
  `sort -V` (GNU coreutils, available on Ubuntu and recent macOS).
  The hooks_test.sh multi-version fixture is tightened from
  v2.1.6/v2.1.7 (both lex-ordered correctly) to v2.1.9/v2.1.10 (the
  digit-count boundary that actually exercises the bug).

- C2 (review): three stderr diagnostics directed the user to
  `devkit install`, which is not a real subcommand. `src/cmd/` has
  guard, mcp, status, workflow, and root — no install. The actual
  self-downloader is `$CLAUDE_PLUGIN_ROOT/bin/devkit` (the
  committed wrapper that handles download + verify + cache on first
  run). Updated all three diagnostics to point at
  `$BIN_DIR/devkit --version` (a side-effect-free invocation that
  triggers the cache-if-missing path).

Verification
------------
- gofmt -l . is clean
- go test ./... -race -count=1 — all packages green
- hooks/hooks_test.sh — 52/52 pass on a fresh-clone simulation
  (deleted bin/devkit-engine, unset CLAUDE_PLUGIN_ROOT). The script
  auto-built the engine and all guard fixtures exercised the real
  binary.
- sort -V version picker verified end-to-end: v2.1.9 + v2.1.10 side
  by side → wrapper execs v2.1.10 (the new test pins this).
@5uck1ess
5uck1ess merged commit cd07206 into main Apr 11, 2026
6 checks passed
@5uck1ess
5uck1ess deleted the fix/65-native-guard branch April 11, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace python3-based hooks with a native devkit-engine guard subcommand

1 participant