Unknown hook name fails open instead of blocking the tool (v1.4.0) #6

Merged
oleks merged 1 commits from fix/5-unknown-hook-fail-open into main 2026-08-19 00:02:41 +03:00
5 changed files with 164 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fleet-integration",
"version": "1.3.0",
"version": "1.4.0",
"description": "Integration playbooks for the oleks fleet, distilled from the 2026-07-21 k3s-over-ZeroTier connectivity research (27 incidents, 9 recurring patterns — catalogued on the oleks/cluster wiki page K3s-over-ZeroTier-Connectivity). Core finding: the failures were integration-shaped, not platform-shaped — undeclared state, half-finished migrations, hardcoded environment assumptions, half-implemented lifecycles, watchdog amplification. Six skills cover the levels of the system: integration-preflight (declared-vs-live drift audit before building on top), onboard-host (join a host to the fleet network + k3s), integrate-service (service end-to-end: chart, Flux, ingress, DNS, secrets, DB, TLS, deploy tracking), ephemeral-lifecycle (scale-to-zero credential/member lifecycle rules), add-watchdog (health-check design that doesn't amplify failures), finish-migration (cutover discipline: done means old state REMOVED and declared==live). Playbooks encode failure classes, not current facts — they direct verification against live sources of truth (fleet-modules/cluster-topology.nix, the wiki) rather than restating values that rot.",
"author": {
"name": "oleks",
BIN
View File
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
)
// unknownEntryPoint is the stable stderr marker this binary prints when it is
// asked for a hook name it does not have. Tooling greps for it; humans read
// the rest of the line.
//
// It exists because this path can NOT signal through the exit status. Claude
// Code reads exit 2 from a PreToolUse hook as "block this tool call", so the
// previous os.Exit(2) on an unknown name meant a hooks.json naming a hook the
// compiled binary lacks took out the matched tool — for a Bash matcher, every
// shell command, in EVERY concurrently running session on the host, not just
// the one doing the rebuild.
//
// That skew is routine rather than exotic: this plugin ships a committed
// prebuilt bin/hooks that CI does not rebuild, and oleks-local is a directory
// source, so a live session sees an edited hooks.json the instant it is
// written, against whatever binary is on disk.
//
// A missing notice is a far smaller loss than a missing tool, so this fails
// open. Third independent occurrence of the same defect fleet-wide; see
// oleks/claude-plugin-anxious#182 and kotkan/claude-plugin-decision-flow#85.
const unknownEntryPoint = "fleet-integration/hooks: unknown-entry-point:"
// registeredNames returns every entry point this binary actually has, sorted.
func registeredNames() []string {
names := make([]string, 0, len(dispatch))
for k := range dispatch {
names = append(names, k)
}
sort.Strings(names)
return names
}
// failOpenUnknown reports an unusable invocation on stderr and returns, so the
// caller can exit 0. stderr is the right channel: the harness surfaces it as a
// diagnostic without blocking, which is exactly the "loud but harmless"
// channel this case wants.
func failOpenUnknown(name string) {
fmt.Fprintf(os.Stderr, "%s %q\n", unknownEntryPoint, name)
fmt.Fprintf(os.Stderr, " registered: %s\n", strings.Join(registeredNames(), " "))
fmt.Fprintln(os.Stderr, " allowing the call — a hooks.json/binary skew must not block a tool.")
}
// listEntryPoints handles the `list` subcommand, which prints the registered
// entry points one per line. It replaces reading a skew off a non-zero exit
// status, which is no longer available now that an unknown name fails open.
func listEntryPoints() bool {
if len(os.Args) == 2 && os.Args[1] == "list" {
for _, n := range registeredNames() {
fmt.Println(n)
}
return true
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"errors"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// buildHooks compiles this package into a temp binary. These tests assert on
// the PROCESS EXIT STATUS, which is the whole property at issue and which a
// function-level test cannot observe.
func buildHooks(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "hooks")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("building test binary: %v\n%s", err, out)
}
return bin
}
func runHooks(t *testing.T, bin string, args ...string) (stdout, stderr string, code int) {
t.Helper()
cmd := exec.Command(bin, args...)
cmd.Stdin = strings.NewReader("{}")
var so, se strings.Builder
cmd.Stdout, cmd.Stderr = &so, &se
if err := cmd.Run(); err != nil {
var ee *exec.ExitError
if !errors.As(err, &ee) {
t.Fatalf("running %s: %v", bin, err)
}
code = ee.ExitCode()
}
return so.String(), se.String(), code
}
// An unknown entry point must NOT exit non-zero. Claude Code reads exit 2 from
// a PreToolUse hook as "block this tool call", so a hooks.json naming a hook
// this binary lacks would take out the matched tool in every running session
// on the host — see the note on unknownEntryPoint.
func TestUnknownEntryPointFailsOpen(t *testing.T) {
_, stderr, code := runHooks(t, buildHooks(t), "definitely-not-a-registered-hook")
if code != 0 {
t.Errorf("exit = %d, want 0 (a hooks.json/binary skew must not block a tool)", code)
}
if !strings.Contains(stderr, unknownEntryPoint) {
t.Errorf("stderr missing %q marker, got: %s", unknownEntryPoint, stderr)
}
if !strings.Contains(stderr, "registered:") {
t.Errorf("stderr should name the registered entry points, got: %s", stderr)
}
}
// A missing entry point is the same fault as an unknown one.
func TestMissingEntryPointFailsOpen(t *testing.T) {
_, stderr, code := runHooks(t, buildHooks(t))
if code != 0 {
t.Errorf("exit = %d, want 0", code)
}
if !strings.Contains(stderr, unknownEntryPoint) {
t.Errorf("stderr missing %q marker, got: %s", unknownEntryPoint, stderr)
}
}
// `list` is how tooling detects a skew now that it cannot read one off a
// non-zero exit status.
func TestListPrintsEveryRegisteredEntryPoint(t *testing.T) {
stdout, _, code := runHooks(t, buildHooks(t), "list")
if code != 0 {
t.Fatalf("exit = %d, want 0", code)
}
got := map[string]bool{}
for _, line := range strings.Fields(stdout) {
got[line] = true
}
for name := range dispatch {
if !got[name] {
t.Errorf("list omitted registered entry point %q (got %q)", name, stdout)
}
}
if len(got) != len(dispatch) {
t.Errorf("list printed %d names, want %d", len(got), len(dispatch))
}
}
// `list` must not shadow a real hook.
func TestListIsNotARegisteredHookName(t *testing.T) {
if _, clash := dispatch["list"]; clash {
t.Fatal(`a hook is registered as "list", which the list subcommand shadows`)
}
}
+8 -4
View File
@@ -32,14 +32,18 @@ var dispatch = map[string]handler{
}
func main() {
if listEntryPoints() {
return
}
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: hooks <hook-name>")
os.Exit(2)
failOpenUnknown("")
return
}
h, ok := dispatch[os.Args[1]]
if !ok {
fmt.Fprintf(os.Stderr, "hooks: unknown hook %q\n", os.Args[1])
os.Exit(2)
failOpenUnknown(os.Args[1])
return
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {