From 5bec9430b7d751dde0ee32cd1c79630b4ea46961 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 19:41:11 +0100 Subject: [PATCH] feat(03-01): HTTP handler POST /hook with validation and body size limit - handleHook validates POST method (405 on others) - MaxBytesReader limits body to 64KB (400 on overflow) - JSON decode errors return 400 - Valid payloads update registry via processHookEvent - 4 tests cover OK, method not allowed, bad JSON, body too large Co-Authored-By: Claude Opus 4.6 (1M context) --- hook.go | 26 +++++++++++++++++- hook_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/hook.go b/hook.go index 411909d..93de376 100644 --- a/hook.go +++ b/hook.go @@ -1,6 +1,10 @@ package main -import "time" +import ( + "encoding/json" + "net/http" + "time" +) // HookEvent represents the JSON payload sent by Claude Code hooks. type HookEvent struct { @@ -16,6 +20,26 @@ type HookEvent struct { ToolName string `json:"tool_name,omitempty"` } +// handleHook is the HTTP handler for POST /hook. +// Validates method, limits body size, decodes JSON, and dispatches to processHookEvent. +func (d *Daemon) handleHook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + + var event HookEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + d.processHookEvent(event) + w.WriteHeader(http.StatusOK) +} + // processHookEvent maps a Claude Code hook event to a registry update. // Ignores empty session IDs and unknown event types. func (d *Daemon) processHookEvent(event HookEvent) { diff --git a/hook_test.go b/hook_test.go index 3d896d1..a55978b 100644 --- a/hook_test.go +++ b/hook_test.go @@ -1,6 +1,9 @@ package main import ( + "net/http" + "net/http/httptest" + "strings" "testing" ) @@ -225,3 +228,75 @@ func TestUpdateFromHookClearsWaitingSince(t *testing.T) { t.Errorf("WaitingSince should be nil after returning to Working, got %v", list[0].WaitingSince) } } + +func TestHandleHookPostOK(t *testing.T) { + d := newTestDaemon(t) + + body := `{ + "session_id": "sess-1", + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "cwd": "/home/user/project" + }` + + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) + w := httptest.NewRecorder() + + d.handleHook(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + list := d.registry.List() + if len(list) != 1 { + t.Fatalf("registry len = %d, want 1", len(list)) + } + if list[0].WaitType != "permission" { + t.Errorf("WaitType = %q, want %q", list[0].WaitType, "permission") + } + if list[0].State != "Needs Input" { + t.Errorf("State = %q, want %q", list[0].State, "Needs Input") + } +} + +func TestHandleHookMethodNotAllowed(t *testing.T) { + d := newTestDaemon(t) + + req := httptest.NewRequest(http.MethodGet, "/hook", nil) + w := httptest.NewRecorder() + + d.handleHook(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", w.Code) + } +} + +func TestHandleHookBadJSON(t *testing.T) { + d := newTestDaemon(t) + + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader("not json")) + w := httptest.NewRecorder() + + d.handleHook(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandleHookBodyTooLarge(t *testing.T) { + d := newTestDaemon(t) + + // 65KB body exceeds the 64KB limit + bigBody := strings.Repeat("x", 65*1024) + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(bigBody)) + w := httptest.NewRecorder() + + d.handleHook(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +}