diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 47aa841a..583f26b2 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -147,7 +147,7 @@ export async function runClaude(options: StartOptions = {}): Promise { }); logger.debug(`[START] Hook server started on port ${hookServer.port}`); - const hookSettingsPath = generateHookSettingsFile(hookServer.port); + const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token); logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); // Print log file path diff --git a/cli/src/claude/utils/generateHookSettings.ts b/cli/src/claude/utils/generateHookSettings.ts index 61892ce3..f5be3fe6 100644 --- a/cli/src/claude/utils/generateHookSettings.ts +++ b/cli/src/claude/utils/generateHookSettings.ts @@ -30,14 +30,14 @@ function shellJoin(parts: string[]): string { /** * Generate a temporary settings file with SessionStart hook configuration. */ -export function generateHookSettingsFile(port: number): string { +export function generateHookSettingsFile(port: number, token: string): string { const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); mkdirSync(hooksDir, { recursive: true }); const filename = `session-hook-${process.pid}.json`; const filepath = join(hooksDir, filename); - const { command, args } = getHappyCliCommand(['hook-forwarder', String(port)]); + const { command, args } = getHappyCliCommand(['hook-forwarder', '--port', String(port), '--token', token]); const hookCommand = shellJoin([command, ...args]); const settings = { diff --git a/cli/src/claude/utils/sessionHookForwarder.ts b/cli/src/claude/utils/sessionHookForwarder.ts index a95779ac..8cc206d3 100644 --- a/cli/src/claude/utils/sessionHookForwarder.ts +++ b/cli/src/claude/utils/sessionHookForwarder.ts @@ -11,22 +11,73 @@ function parsePort(value: string | undefined): number | null { return null; } - const port = Number.parseInt(value, 10); - if (!port || Number.isNaN(port)) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { return null; } return port; } +function parseArgs(args: string[]): { port: number | null; token: string | null } { + let port: number | null = null; + let token: string | null = null; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) { + continue; + } + + if (arg === '--port' || arg === '-p') { + port = parsePort(args[i + 1]); + i += 1; + continue; + } + + if (arg.startsWith('--port=')) { + port = parsePort(arg.slice('--port='.length)); + continue; + } + + if (arg === '--token' || arg === '-t') { + token = args[i + 1] ?? null; + i += 1; + continue; + } + + if (arg.startsWith('--token=')) { + token = arg.slice('--token='.length); + continue; + } + + if (!port) { + port = parsePort(arg); + continue; + } + + if (!token) { + token = arg; + } + } + + return { port, token }; +} + export async function runSessionHookForwarder(args: string[]): Promise { - const port = parsePort(args[0]); + const { port, token } = parseArgs(args); if (!port) { logError('Invalid or missing port argument'); process.exitCode = 1; return; } + if (!token) { + logError('Missing hook token'); + process.exitCode = 1; + return; + } + try { const chunks: Buffer[] = []; process.stdin.resume(); @@ -49,7 +100,8 @@ export async function runSessionHookForwarder(args: string[]): Promise { path: '/hook/session-start', headers: { 'Content-Type': 'application/json', - 'Content-Length': body.length + 'Content-Length': body.length, + 'x-hapi-hook-token': token } }, (res) => { if (res.statusCode && res.statusCode >= 400) { diff --git a/cli/src/claude/utils/startHookServer.test.ts b/cli/src/claude/utils/startHookServer.test.ts index 57fe4a0e..2e5b2d57 100644 --- a/cli/src/claude/utils/startHookServer.test.ts +++ b/cli/src/claude/utils/startHookServer.test.ts @@ -2,17 +2,22 @@ import { describe, it, expect } from 'vitest' import { request } from 'node:http' import { startHookServer, type SessionHookData } from './startHookServer' -const sendHookRequest = async (port: number, body: string): Promise<{ statusCode?: number; body: string }> => { +const sendHookRequest = async (port: number, body: string, token?: string): Promise<{ statusCode?: number; body: string }> => { return await new Promise((resolve, reject) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + if (token) { + headers['x-hapi-hook-token'] = token + } + const req = request({ host: '127.0.0.1', port, path: '/hook/session-start', method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } + headers }, (res) => { const chunks: Buffer[] = [] res.on('data', (chunk) => chunks.push(chunk as Buffer)) @@ -41,7 +46,7 @@ describe('startHookServer', () => { try { const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' }) - const response = await sendHookRequest(server.port, body) + const response = await sendHookRequest(server.port, body, server.token) expect(response.statusCode).toBe(200) } finally { server.stop() @@ -60,7 +65,7 @@ describe('startHookServer', () => { }) try { - const response = await sendHookRequest(server.port, '{"session_id":') + const response = await sendHookRequest(server.port, '{"session_id":', server.token) expect(response.statusCode).toBe(400) expect(response.body).toBe('invalid json') } finally { @@ -80,7 +85,7 @@ describe('startHookServer', () => { try { const body = JSON.stringify({ extra: 'ok' }) - const response = await sendHookRequest(server.port, body) + const response = await sendHookRequest(server.port, body, server.token) expect(response.statusCode).toBe(422) expect(response.body).toBe('missing session_id') } finally { @@ -89,4 +94,24 @@ describe('startHookServer', () => { expect(hookCalled).toBe(false) }) + + it('returns 401 when hook token is missing', async () => { + let hookCalled = false + const server = await startHookServer({ + onSessionHook: () => { + hookCalled = true + } + }) + + try { + const body = JSON.stringify({ session_id: 'session-123' }) + const response = await sendHookRequest(server.port, body) + expect(response.statusCode).toBe(401) + expect(response.body).toBe('unauthorized') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) }) diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts index e24842e4..02073edf 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/claude/utils/startHookServer.ts @@ -6,6 +6,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http'; +import { randomBytes } from 'node:crypto'; import { logger } from '@/ui/logger'; /** @@ -24,24 +25,46 @@ export interface SessionHookData { export interface HookServerOptions { /** Called when a session hook is received with a valid session ID. */ onSessionHook: (sessionId: string, data: SessionHookData) => void; + /** Optional token to require for hook requests. */ + token?: string; } export interface HookServer { /** The port the server is listening on. */ port: number; + /** Token required for hook requests. */ + token: string; /** Stop the server. */ stop: () => void; } +function readHookToken(req: IncomingMessage): string | null { + const header = req.headers['x-hapi-hook-token']; + if (Array.isArray(header)) { + return header[0] ?? null; + } + return header ?? null; +} + /** * Start a dedicated HTTP server for receiving Claude session hooks. */ export async function startHookServer(options: HookServerOptions): Promise { const { onSessionHook } = options; + const hookToken = options.token || randomBytes(16).toString('hex'); return new Promise((resolve, reject) => { const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - if (req.method === 'POST' && req.url === '/hook/session-start') { + const requestPath = req.url?.split('?')[0]; + if (req.method === 'POST' && requestPath === '/hook/session-start') { + const providedToken = readHookToken(req); + if (providedToken !== hookToken) { + logger.debug('[hookServer] Unauthorized hook request'); + res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); + req.resume(); + return; + } + let timedOut = false; const timeout = setTimeout(() => { timedOut = true; @@ -122,6 +145,7 @@ export async function startHookServer(options: HookServerOptions): Promise { server.close(); logger.debug('[hookServer] Stopped');