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) <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-03-23 19:41:11 +01:00
parent e1b176cf55
commit 5bec9430b7
2 changed files with 100 additions and 1 deletions

26
hook.go
View File

@@ -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) {

View File

@@ -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)
}
}