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/cmd/ctxcop/hook_drain_test.go b/cmd/ctxcop/hook_drain_test.go new file mode 100644 index 0000000..1b589c0 --- /dev/null +++ b/cmd/ctxcop/hook_drain_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "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 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) + } + 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, "USERPROFILE="+home) // USERPROFILE on Windows + 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/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 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) + } +}