From 26780618386fc28be1882b0feaea276a0a1cd0bf Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Tue, 13 Oct 2026 12:51:18 -0700 Subject: [PATCH 1/3] fix(hook): drain stdin when a hook returns early, handle EPIPE in bridges When ctxcop is paused (or an off switch is set), 13 handlers across the claudecode, codex, cursor and opencode adapters, and all three pi handlers, return before reading stdin. A bridge that writes a payload over the pipe buffer (~64KB) gets EPIPE, and with no stdin error handler Node throws an unhandled 'error' event that crashes the host (seen in pi). Drain stdin once in cmdHook after the handler replies, so every harness is covered and new handlers cannot regress it. Skipped when stdin is a terminal and for aider, which has no stdin hook. Add a stdin error handler to the pi and opencode bridges so an early-exiting hook fails open. Tests: build the binary and write 400KB to every harness hook event while paused; run each shipped bridge in node against a fake hook that exits unread. Co-Authored-By: Claude Sonnet 5.5 --- cmd/ctxcop/hook_drain_test.go | 60 +++++++++++++++++++ cmd/ctxcop/main.go | 14 +++++ internal/harness/opencode/extension/ctxcop.ts | 1 + internal/harness/opencode/opencode_test.go | 34 +++++++++++ internal/harness/pi/extension/ctxcop.ts | 1 + internal/harness/pi/pi_test.go | 38 ++++++++++++ 6 files changed, 148 insertions(+) create mode 100644 cmd/ctxcop/hook_drain_test.go diff --git a/cmd/ctxcop/hook_drain_test.go b/cmd/ctxcop/hook_drain_test.go new file mode 100644 index 0000000..d0282d0 --- /dev/null +++ b/cmd/ctxcop/hook_drain_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// A paused hook returns before reading stdin. Every harness bridge must +// still be able to write a payload larger than the pipe buffer without +// EPIPE, so the binary has to drain stdin before exiting. +func TestPausedHooksDrainStdin(t *testing.T) { + bin := filepath.Join(t.TempDir(), "ctxcop") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".ctxcop"), 0o700); err != nil { + t.Fatal(err) + } + expiry := time.Now().Add(10 * time.Minute).Format(time.RFC3339) + if err := os.WriteFile(filepath.Join(home, ".ctxcop", "paused"), []byte(expiry), 0o600); err != nil { + t.Fatal(err) + } + body := `{"payload":"` + strings.Repeat("x", 400*1024) + `"}` + + events := [][]string{ + {"pre-tool-use"}, {"post-tool-use"}, {"pre-compact"}, {"post-compact"}, + {"elicitation-result"}, {"session-start"}, + {"codex", "pre-tool-use"}, {"codex", "post-tool-use"}, + {"cursor", "pre-tool-use"}, {"cursor", "post-tool-use"}, + {"pi", "before-provider-request"}, {"pi", "before-agent-start"}, {"pi", "tool-call"}, + {"opencode", "tool-execute-before"}, {"opencode", "tool-execute-after"}, + } + for _, ev := range events { + t.Run(strings.Join(ev, "/"), func(t *testing.T) { + cmd := exec.Command(bin, append([]string{"hook"}, ev...)...) + cmd.Env = append(os.Environ(), "HOME="+home) + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + // exec.Cmd.Stdin hides EPIPE, so write directly and check it. + if _, err := io.WriteString(in, body); err != nil { + t.Errorf("write to hook stdin: %v", err) + } + in.Close() + if err := cmd.Wait(); err != nil { + t.Errorf("exit: %v", err) + } + }) + } +} diff --git a/cmd/ctxcop/main.go b/cmd/ctxcop/main.go index 6917ccf..38addd9 100644 --- a/cmd/ctxcop/main.go +++ b/cmd/ctxcop/main.go @@ -407,11 +407,25 @@ func cmdScan() int { return 0 } +// drainStdin consumes unread hook input. Handlers that return early (paused, +// off switches) would otherwise exit with the harness still writing, which +// surfaces as EPIPE in the bridge once the payload exceeds the pipe buffer. +// Skipped on a terminal so manual runs don't block. +func drainStdin() { + if fi, err := os.Stdin.Stat(); err != nil || fi.Mode()&os.ModeCharDevice != 0 { + return + } + _, _ = io.Copy(io.Discard, os.Stdin) +} + func cmdHook(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "ctxcop: hook requires an event name (pre-tool-use | session-start | ... | codex )") return 0 // fail open } + if args[0] != "aider" { + defer drainStdin() + } // `ctxcop hook ` routes to the named adapter; // everything else stays on the (default) Claude Code adapter so // existing settings.json registrations don't break. diff --git a/internal/harness/opencode/extension/ctxcop.ts b/internal/harness/opencode/extension/ctxcop.ts index bb2c20f..29bb3ab 100644 --- a/internal/harness/opencode/extension/ctxcop.ts +++ b/internal/harness/opencode/extension/ctxcop.ts @@ -22,6 +22,7 @@ async function callCtxcop(subcmd: string, payload: unknown): Promise { try { resolve(out ? JSON.parse(out) : {}); } catch { resolve({}); } }); + proc.stdin?.on("error", () => {}); // EPIPE if the hook exits without reading; fail open try { proc.stdin?.write(JSON.stringify(payload)); proc.stdin?.end(); diff --git a/internal/harness/opencode/opencode_test.go b/internal/harness/opencode/opencode_test.go index 28b2216..02a8927 100644 --- a/internal/harness/opencode/opencode_test.go +++ b/internal/harness/opencode/opencode_test.go @@ -3,6 +3,9 @@ package opencode import ( "bytes" "encoding/json" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -286,3 +289,34 @@ func TestIsExfilClass(t *testing.T) { } } } + +// A hook that exits without reading a >64KB payload (paused) must not crash +// the host with an unhandled EPIPE on the bridge's stdin. +func TestBridgeSurvivesEarlyExitHook(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed") + } + dir := t.TempDir() + fake := filepath.Join(dir, "fake-ctxcop") + if err := os.WriteFile(fake, []byte("#!/bin/sh\necho '{}'\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bridge.ts"), []byte(BridgeSource()), 0o644); err != nil { + t.Fatal(err) + } + script := `import { ctxcop } from "./bridge.ts"; +const h = await ctxcop.server({}); +await h["tool.execute.before"]({ tool: "bash" }, { args: { command: "x".repeat(400 * 1024) } }); +console.log("ok");` + js := filepath.Join(dir, "t.mjs") + if err := os.WriteFile(js, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(node, js) + cmd.Env = append(os.Environ(), "CTXCOP_BIN="+fake) + outb, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(outb), "ok") { + t.Fatalf("bridge crashed on early-exit hook: %v\n%s", err, outb) + } +} diff --git a/internal/harness/pi/extension/ctxcop.ts b/internal/harness/pi/extension/ctxcop.ts index 7619e09..f88c742 100644 --- a/internal/harness/pi/extension/ctxcop.ts +++ b/internal/harness/pi/extension/ctxcop.ts @@ -22,6 +22,7 @@ async function callCtxcop(subcmd: string, payload: unknown): Promise { try { resolve(out ? JSON.parse(out) : {}); } catch { resolve({}); } }); + proc.stdin?.on("error", () => {}); // EPIPE if the hook exits without reading; fail open try { proc.stdin?.write(JSON.stringify(payload)); proc.stdin?.end(); diff --git a/internal/harness/pi/pi_test.go b/internal/harness/pi/pi_test.go index 037c486..7bb947f 100644 --- a/internal/harness/pi/pi_test.go +++ b/internal/harness/pi/pi_test.go @@ -3,6 +3,9 @@ package pi import ( "bytes" "encoding/json" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -268,3 +271,38 @@ func TestBridgeSourceLooksRight(t *testing.T) { } } } + +// ---- Bridge ---------------------------------------------------------------- + +// Real-process check of the bridge: a paused hook that exits without +// reading a >64KB payload must not crash the host with an unhandled EPIPE. +func TestBridgeSurvivesEarlyExitHook(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed") + } + dir := t.TempDir() + fake := filepath.Join(dir, "fake-ctxcop") + if err := os.WriteFile(fake, []byte("#!/bin/sh\necho '{}'\n"), 0o755); err != nil { + t.Fatal(err) + } + // Run the shipped bridge source with a fake pi; node strips the TS types. + if err := os.WriteFile(filepath.Join(dir, "bridge.ts"), []byte(BridgeSource()), 0o644); err != nil { + t.Fatal(err) + } + js := filepath.Join(dir, "t.mjs") + script := `import bridge from "./bridge.ts"; +const h = {}; +bridge({ on: (n, f) => (h[n] = f) }); +await h.before_provider_request({ payload: "x".repeat(400 * 1024) }); +console.log("ok");` + if err := os.WriteFile(js, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(node, js) + cmd.Env = append(os.Environ(), "CTXCOP_BIN="+fake) + outb, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(outb), "ok") { + t.Fatalf("bridge crashed on early-exit hook: %v\n%s", err, outb) + } +} From 1e1da1196fda10bb86e7a363e59a486b7b2b7b30 Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Tue, 13 Oct 2026 14:45:30 -0700 Subject: [PATCH 2/3] test(hook): run drain test on Windows Build the binary with .exe and set USERPROFILE, which os.UserHomeDir reads on Windows. Refs SECOPS-1421. Co-Authored-By: Claude Sonnet 5.5 --- cmd/ctxcop/hook_drain_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/ctxcop/hook_drain_test.go b/cmd/ctxcop/hook_drain_test.go index d0282d0..1b589c0 100644 --- a/cmd/ctxcop/hook_drain_test.go +++ b/cmd/ctxcop/hook_drain_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -15,6 +16,9 @@ import ( // EPIPE, so the binary has to drain stdin before exiting. func TestPausedHooksDrainStdin(t *testing.T) { bin := filepath.Join(t.TempDir(), "ctxcop") + if runtime.GOOS == "windows" { + bin += ".exe" + } if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { t.Fatalf("build: %v\n%s", err, out) } @@ -39,7 +43,7 @@ func TestPausedHooksDrainStdin(t *testing.T) { for _, ev := range events { t.Run(strings.Join(ev, "/"), func(t *testing.T) { cmd := exec.Command(bin, append([]string{"hook"}, ev...)...) - cmd.Env = append(os.Environ(), "HOME="+home) + cmd.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home) // USERPROFILE on Windows in, err := cmd.StdinPipe() if err != nil { t.Fatal(err) From 090484b982ac1b8eaafe5d86687ed669eb3aeabe Mon Sep 17 00:00:00 2001 From: Andrew Rose Date: Tue, 13 Oct 2026 14:49:15 -0700 Subject: [PATCH 3/3] build: bump Go to 1.26.9 for GO-2026-6617 govulncheck flags net/http in 1.26.6 (HTTP/2 server crash, HPACK encoder race), fixed in 1.26.9. CI takes its Go version from go.mod. Also add Unreleased changelog entries for this PR. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++++++++++ go.mod | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdfe65..6e2a357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ hardening pass done ahead of open-sourcing — is preserved in ## [Unreleased] +### Security +- **Go toolchain bumped to 1.26.9** for GO-2026-6617 (HTTP/2 server crash + from an HPACK encoder race in `net/http`, fixed in 1.26.9). + +### Fixed +- **Hooks drain stdin before exiting.** A paused hook (or one with an off + switch set) returned without reading its input, so a bridge writing a + payload over the pipe buffer (~64KB) got EPIPE and crashed the host. The + pi and opencode bridges also handle stdin errors now. + ## [0.1.1] — 2026-08-27 ### Security diff --git a/go.mod b/go.mod index 9361872..ca98e6f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/webflow/ctxcop -go 1.26.6 +go 1.26.9 require ( github.com/BurntSushi/toml v1.6.0