Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions cmd/ctxcop/hook_drain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 14 additions & 0 deletions cmd/ctxcop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <event>)")
return 0 // fail open
}
if args[0] != "aider" {
defer drainStdin()
}
// `ctxcop hook <harness> <event>` routes to the named adapter;
// everything else stays on the (default) Claude Code adapter so
// existing settings.json registrations don't break.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/harness/opencode/extension/ctxcop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async function callCtxcop(subcmd: string, payload: unknown): Promise<any> {
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();
Expand Down
34 changes: 34 additions & 0 deletions internal/harness/opencode/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package opencode
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions internal/harness/pi/extension/ctxcop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async function callCtxcop(subcmd: string, payload: unknown): Promise<unknown> {
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();
Expand Down
38 changes: 38 additions & 0 deletions internal/harness/pi/pi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package pi
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
Loading