mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
fix(security): deliver the hook secret to hooks + isolate its rate-limit bucket
Review fixes for COD-54: - Generated hook curl commands now present X-Codeman-Hook-Secret, read from the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported into every managed session's env by tmux buildEnvExports / the direct-PTY env builders). Without this, every local hook 401'd the moment a managed tunnel came up — the enforcement existed but nothing presented the secret. Path-not-value keeps the secret off command lines and out of config files, and running sessions pick up a newly generated secret with no respawn; server.start() ensures the file exists up front. - Hook-secret failures now count into a DEDICATED per-IP bucket (hookSecretFailures) instead of the shared authFailures map. Legacy (pre-secret) hook configs fire constantly from 127.0.0.1; counting their 401s against the shared bucket would 429 every cookie-less loopback request — locking out the Basic-Auth login path (and, through a tunnel, every client, since tunneled traffic also arrives as 127.0.0.1). - docs/security-architecture.md: secret-gated hook exemption, dedicated bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback proxies (user-run cloudflared / tailscale serve), which the managed-tunnel probe cannot see. - test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected after hook-bucket exhaustion; generated hooks reference the header + $CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders export the path only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -124,7 +124,11 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
|
|||||||
`onRequest` hook) runs in this order:
|
`onRequest` hook) runs in this order:
|
||||||
|
|
||||||
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
||||||
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
|
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
|
||||||
|
**managed tunnel is running**, the hook‑event exemption additionally requires
|
||||||
|
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
|
||||||
|
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
|
||||||
|
so misfiring hooks can never lock out the login path.
|
||||||
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
|
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
|
||||||
allow.
|
allow.
|
||||||
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
|
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
|
||||||
@@ -165,17 +169,29 @@ protection is unchanged.
|
|||||||
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
|
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
|
||||||
requests as local:
|
requests as local:
|
||||||
|
|
||||||
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
|
- `POST /api/hook-event` — auth‑exempt for loopback **only while no managed tunnel
|
||||||
|
is running**. When Codeman's own tunnel is up, the exemption requires the
|
||||||
|
per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in
|
||||||
|
`~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the
|
||||||
|
secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every
|
||||||
|
managed session), so they keep working — tunneled internet traffic can't know
|
||||||
|
it. Even without the secret the impact is bounded: the route is
|
||||||
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
|
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
|
||||||
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
||||||
watching — **not** arbitrary terminal input or file reads. It is a
|
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
|
||||||
session‑disruption / notification‑spoofing surface, not RCE.
|
the **managed** tunnel — an externally run loopback proxy (your own
|
||||||
|
`cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback
|
||||||
|
exemption still applies there (prefer `tailscale serve`, which authenticates at
|
||||||
|
the tailnet layer). Hook configs regenerated since COD‑54 always present the
|
||||||
|
header, so a future release can require the secret unconditionally.
|
||||||
- QR `/q/` — still protected by its own short‑code brute‑force limiter
|
- QR `/q/` — still protected by its own short‑code brute‑force limiter
|
||||||
(10 failures / 60s against a 62⁶ space).
|
(10 failures / 60s against a 62⁶ space).
|
||||||
|
|
||||||
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
||||||
up (it does not gate the hook‑event exemption, but it gates everything else and
|
up — it gates everything except the (secret‑gated) hook exemption and is the
|
||||||
is the documented practice). Prefer `tailscale serve` (below), which authenticates
|
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
|
||||||
|
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
|
||||||
|
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
|
||||||
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||||
|
|
||||||
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
||||||
|
|||||||
+10
-2
@@ -3,8 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Generates `.claude/settings.local.json` with hook definitions that POST
|
* Generates `.claude/settings.local.json` with hook definitions that POST
|
||||||
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
|
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
|
||||||
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every
|
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
|
||||||
* managed session) so the config is static per case directory.
|
* env vars (set on every managed session) so the config is static per case
|
||||||
|
* directory and free of secret values.
|
||||||
*
|
*
|
||||||
* Key exports:
|
* Key exports:
|
||||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||||
@@ -41,11 +42,18 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
|||||||
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||||
// Read Claude Code's stdin JSON and forward it as the data field.
|
// Read Claude Code's stdin JSON and forward it as the data field.
|
||||||
// Falls back to empty object if stdin is unavailable or malformed.
|
// Falls back to empty object if stdin is unavailable or malformed.
|
||||||
|
// COD-54: present the per-instance hook secret so the bypass keeps working while
|
||||||
|
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
|
||||||
|
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
|
||||||
|
// never lands in this config and rotation needs no respawn. If the var/file is
|
||||||
|
// missing the header is empty — the middleware then allows the request only on
|
||||||
|
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
|
||||||
const curlCmd = (event: HookEventType) =>
|
const curlCmd = (event: HookEventType) =>
|
||||||
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
||||||
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
||||||
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||||
`-H 'Content-Type: application/json' ` +
|
`-H 'Content-Type: application/json' ` +
|
||||||
|
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||||
`--data @- ` +
|
`--data @- ` +
|
||||||
`2>/dev/null || true`;
|
`2>/dev/null || true`;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||||
import { isEffortLevel } from './types.js';
|
import { isEffortLevel } from './types.js';
|
||||||
import { getAugmentedPath } from './utils/index.js';
|
import { getAugmentedPath } from './utils/index.js';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Claude CLI permission flags based on the configured mode.
|
* Build Claude CLI permission flags based on the configured mode.
|
||||||
@@ -113,6 +114,8 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
|||||||
CODEMAN_MUX: '1',
|
CODEMAN_MUX: '1',
|
||||||
CODEMAN_SESSION_ID: sessionId,
|
CODEMAN_SESSION_ID: sessionId,
|
||||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||||
|
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||||
|
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,5 +152,7 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
|
|||||||
CODEMAN_MUX: '1',
|
CODEMAN_MUX: '1',
|
||||||
CODEMAN_SESSION_ID: sessionId,
|
CODEMAN_SESSION_ID: sessionId,
|
||||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||||
|
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||||
|
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -857,6 +857,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||||
|
// Path only (not the secret value): hook curl commands cat the file at
|
||||||
|
// execution time, so the COD-54 hook secret stays off the command line.
|
||||||
|
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
|
||||||
];
|
];
|
||||||
// Only unset CLAUDECODE for Claude sessions
|
// Only unset CLAUDECODE for Claude sessions
|
||||||
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface AuthState {
|
|||||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||||
authFailures: StaleExpirationMap<string, number> | null;
|
authFailures: StaleExpirationMap<string, number> | null;
|
||||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||||
|
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +54,7 @@ export function registerAuthMiddleware(
|
|||||||
authSessions: null,
|
authSessions: null,
|
||||||
authFailures: null,
|
authFailures: null,
|
||||||
qrAuthFailures: null,
|
qrAuthFailures: null,
|
||||||
|
hookSecretFailures: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||||
@@ -79,11 +81,26 @@ export function registerAuthMiddleware(
|
|||||||
refreshOnGet: false,
|
refreshOnGet: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
|
||||||
|
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
|
||||||
|
// their 401s against the shared bucket would 429 every cookie-less request from
|
||||||
|
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
|
||||||
|
// client, since tunneled traffic also arrives as 127.0.0.1).
|
||||||
|
state.hookSecretFailures = new StaleExpirationMap<string, number>({
|
||||||
|
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||||
|
refreshOnGet: false,
|
||||||
|
});
|
||||||
|
|
||||||
const authSessions = state.authSessions;
|
const authSessions = state.authSessions;
|
||||||
const authFailures = state.authFailures;
|
const authFailures = state.authFailures;
|
||||||
|
const hookSecretFailures = state.hookSecretFailures;
|
||||||
|
|
||||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
function sendAuthRateLimit(
|
||||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
reply: FastifyReply,
|
||||||
|
clientIp: string,
|
||||||
|
failures: StaleExpirationMap<string, number> = authFailures
|
||||||
|
): void {
|
||||||
|
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||||
reply.header('Retry-After', String(retryAfterSeconds));
|
reply.header('Retry-After', String(retryAfterSeconds));
|
||||||
reply.code(429).send('Too Many Requests — try again later');
|
reply.code(429).send('Too Many Requests — try again later');
|
||||||
@@ -118,15 +135,15 @@ export function registerAuthMiddleware(
|
|||||||
done();
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Wrong/absent secret while tunneled — treat as a failed auth attempt so the
|
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
|
||||||
// per-IP rate limiter (below) throttles brute-force/abuse of this route.
|
// hook bucket (never authFailures, which would lock out the login path).
|
||||||
const hookIp = req.ip;
|
const hookIp = req.ip;
|
||||||
const hookFailures = authFailures.get(hookIp) ?? 0;
|
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||||
sendAuthRateLimit(reply, hookIp);
|
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authFailures.set(hookIp, hookFailures + 1);
|
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||||
reply.code(401).send('Unauthorized: hook secret required');
|
reply.code(401).send('Unauthorized: hook secret required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { hostname as getHostname } from 'node:os';
|
import { hostname as getHostname } from 'node:os';
|
||||||
import { dataPath } from '../config/instance.js';
|
import { dataPath } from '../config/instance.js';
|
||||||
|
import { getHookSecret } from '../config/hook-secret.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||||
import type { ClaudeMode, SessionState } from '../types.js';
|
import type { ClaudeMode, SessionState } from '../types.js';
|
||||||
@@ -253,6 +254,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
|
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||||
@@ -608,6 +610,7 @@ export class WebServer extends EventEmitter {
|
|||||||
this.authSessions = authState.authSessions;
|
this.authSessions = authState.authSessions;
|
||||||
this.authFailures = authState.authFailures;
|
this.authFailures = authState.authFailures;
|
||||||
this.qrAuthFailures = authState.qrAuthFailures;
|
this.qrAuthFailures = authState.qrAuthFailures;
|
||||||
|
this.hookSecretFailures = authState.hookSecretFailures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||||
@@ -1816,6 +1819,10 @@ export class WebServer extends EventEmitter {
|
|||||||
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
|
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
|
||||||
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
|
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
|
||||||
|
|
||||||
|
// Ensure the COD-54 hook secret exists on disk before any session exports
|
||||||
|
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
|
||||||
|
getHookSecret();
|
||||||
|
|
||||||
// Start scheduled runs cleanup timer
|
// Start scheduled runs cleanup timer
|
||||||
this.cleanup.setInterval(
|
this.cleanup.setInterval(
|
||||||
() => {
|
() => {
|
||||||
@@ -2292,6 +2299,10 @@ export class WebServer extends EventEmitter {
|
|||||||
this.qrAuthFailures.dispose();
|
this.qrAuthFailures.dispose();
|
||||||
this.qrAuthFailures = null;
|
this.qrAuthFailures = null;
|
||||||
}
|
}
|
||||||
|
if (this.hookSecretFailures) {
|
||||||
|
this.hookSecretFailures.dispose();
|
||||||
|
this.hookSecretFailures = null;
|
||||||
|
}
|
||||||
this.activePlanOrchestrators.clear();
|
this.activePlanOrchestrators.clear();
|
||||||
this.cleaningUp.clear();
|
this.cleaningUp.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -147,4 +147,40 @@ describe('COD-54 hook-event auth — rate limiting', () => {
|
|||||||
}
|
}
|
||||||
expect(saw429).toBe(true);
|
expect(saw429).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hook-secret failures do NOT lock out the Basic-Auth login path (separate bucket)', async () => {
|
||||||
|
// The previous test exhausted the hook bucket for 127.0.0.1. Legacy (pre-secret)
|
||||||
|
// hooks fire constantly, so if they shared authFailures, every cookie-less
|
||||||
|
// request from loopback would now 429 — locking out login (and, via a tunnel,
|
||||||
|
// every client). Assert the login path is unaffected:
|
||||||
|
// 1. A credential-less request still gets a 401 challenge, NOT 429.
|
||||||
|
const unauthed = await fetch(`${baseUrl}/api/status`);
|
||||||
|
expect(unauthed.status).toBe(401);
|
||||||
|
// 2. Correct Basic credentials still authenticate.
|
||||||
|
const authed = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64') },
|
||||||
|
});
|
||||||
|
expect(authed.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('COD-54 secret delivery — generated hooks + session env present the secret', () => {
|
||||||
|
it('generated hook curl commands send the secret header, read from the file at exec time', async () => {
|
||||||
|
const { generateHooksConfig } = await import('../src/hooks-config.js');
|
||||||
|
const config = generateHooksConfig();
|
||||||
|
const commands = JSON.stringify(config);
|
||||||
|
// Header present, value sourced from $CODEMAN_HOOK_SECRET_FILE (not embedded).
|
||||||
|
expect(commands).toContain(HOOK_SECRET_HEADER);
|
||||||
|
expect(commands).toContain('$CODEMAN_HOOK_SECRET_FILE');
|
||||||
|
expect(commands).not.toContain(getHookSecret());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session env builders export CODEMAN_HOOK_SECRET_FILE (path only, never the value)', async () => {
|
||||||
|
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
|
||||||
|
const claudeEnv = buildClaudeEnv('test-session');
|
||||||
|
const shellEnv = buildShellEnv('test-session');
|
||||||
|
expect(claudeEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
|
||||||
|
expect(shellEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
|
||||||
|
expect(JSON.stringify(claudeEnv)).not.toContain(getHookSecret());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user