mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge PR #127: require hook-event secret unconditionally + stale-config self-heal
Require the hook-event secret unconditionally (drop managed-tunnel gating)
This commit is contained in:
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||
|
||||
const AUTH_PORT = 3160;
|
||||
const NOAUTH_PORT = 3161;
|
||||
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
|
||||
});
|
||||
|
||||
describe('Hook Event Endpoint', () => {
|
||||
it('should allow hook events from localhost without auth', async () => {
|
||||
it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||
body: JSON.stringify({
|
||||
event: 'stop',
|
||||
sessionId: 'nonexistent-session',
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
// Should pass auth (localhost bypass) but may 404 on session — that's fine
|
||||
// The key assertion is it does NOT return 401
|
||||
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
|
||||
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('should reject hook events with invalid schema', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||
body: JSON.stringify({ invalid: 'data' }),
|
||||
});
|
||||
// Schema validation should catch this
|
||||
// Past the auth gate (valid secret) → schema validation should catch this (not a 401).
|
||||
expect(res.status).not.toBe(401); // Not an auth error
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
|
||||
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
|
||||
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
|
||||
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
|
||||
* keeping the plain localhost bypass for the normal loopback-only case so
|
||||
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
|
||||
* the bypass behind a shared hook secret. COD-91 makes that requirement
|
||||
* UNCONDITIONAL — the loopback bypass requires the secret whether or not a
|
||||
* managed tunnel is running, because Codeman can't detect a user's own loopback
|
||||
* reverse proxy (own cloudflared / `tailscale serve` / nginx → 127.0.0.1).
|
||||
* Managed-session hooks always present the secret, so the legitimate channel
|
||||
* keeps working.
|
||||
*
|
||||
* Tests:
|
||||
* - tunnel running + no secret → 401 (closes the hole)
|
||||
* - tunnel running + bad secret → 401
|
||||
* - tunnel running + good secret → not 401 (allowed)
|
||||
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
|
||||
* - tunnel NOT running + no secret → 401 (COD-91: secret required unconditionally)
|
||||
* - tunnel NOT running + good secret → not 401 (allowed)
|
||||
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
||||
*
|
||||
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
|
||||
@@ -83,7 +87,7 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
|
||||
describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let isRunningSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -105,8 +109,13 @@ describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-co
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
});
|
||||
|
||||
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
|
||||
it('rejects a localhost hook POST WITHOUT a secret even with no tunnel (COD-91)', async () => {
|
||||
const res = await postHook(baseUrl);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows a localhost hook POST WITH the correct secret when no tunnel is running', async () => {
|
||||
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* COD-91 — `refreshStaleHookSecret` self-heal.
|
||||
*
|
||||
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
|
||||
* hook curls baked into cases created before the secret header existed (COD-54). Those
|
||||
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
|
||||
* CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the
|
||||
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
|
||||
* pre-secret hook curls, never clobbering a user's customizations.
|
||||
*
|
||||
* Pure filesystem logic against a temp dir — no port / server / tmux.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { refreshStaleHookSecret } from '../src/hooks-config.js';
|
||||
|
||||
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
|
||||
|
||||
// A faithful pre-secret Codeman hook curl (what cases created before COD-54 contain):
|
||||
// targets /api/hook-event, but with NO X-Codeman-Hook-Secret header.
|
||||
function staleCodemanHooks() {
|
||||
return {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
|
||||
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
|
||||
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
|
||||
timeout: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('refreshStaleHookSecret', () => {
|
||||
let dir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-selfheal-'));
|
||||
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||
settingsPath = join(dir, '.claude', 'settings.local.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds the secret header to a stale Codeman hooks block and preserves other keys', async () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
|
||||
);
|
||||
await refreshStaleHookSecret(dir);
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_HOOK_SECRET_FILE');
|
||||
// sibling keys untouched
|
||||
expect(after.env).toEqual({ CLAUDE_CODE_FOO: '1' });
|
||||
expect(after.model).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves a hooks block that already carries the secret unchanged', async () => {
|
||||
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||||
await refreshStaleHookSecret(dir);
|
||||
const healed = readFileSync(settingsPath, 'utf-8');
|
||||
expect(healed).toContain(SECRET_HEADER);
|
||||
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
||||
});
|
||||
|
||||
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
|
||||
const foreign = JSON.stringify(
|
||||
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
|
||||
null,
|
||||
2
|
||||
);
|
||||
writeFileSync(settingsPath, foreign);
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
|
||||
});
|
||||
|
||||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a malformed settings file untouched', async () => {
|
||||
const garbage = '{ not valid json';
|
||||
writeFileSync(settingsPath, garbage);
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user