Unknown hook name fails open instead of blocking the tool (v1.4.0) #6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fleet-integration",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "oleks",
|
"name": "oleks",
|
||||||
|
|||||||
+62
@@ -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
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,14 +32,18 @@ var dispatch = map[string]handler{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if listEntryPoints() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: hooks <hook-name>")
|
failOpenUnknown("")
|
||||||
os.Exit(2)
|
return
|
||||||
}
|
}
|
||||||
h, ok := dispatch[os.Args[1]]
|
h, ok := dispatch[os.Args[1]]
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Fprintf(os.Stderr, "hooks: unknown hook %q\n", os.Args[1])
|
failOpenUnknown(os.Args[1])
|
||||||
os.Exit(2)
|
return
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(os.Stdin)
|
raw, err := io.ReadAll(os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user