diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 166b256..57d02aa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/bin/hooks b/bin/hooks index 815156e..0594196 100755 Binary files a/bin/hooks and b/bin/hooks differ diff --git a/failopen.go b/failopen.go new file mode 100644 index 0000000..54f2697 --- /dev/null +++ b/failopen.go @@ -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 +} diff --git a/failopen_test.go b/failopen_test.go new file mode 100644 index 0000000..c3202ad --- /dev/null +++ b/failopen_test.go @@ -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`) + } +} diff --git a/main.go b/main.go index d68a9f0..8797cdc 100644 --- a/main.go +++ b/main.go @@ -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 ") - 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 {