(hook: HookConfig, ctx: HookContext)
| 18 | * a config file. We do redact sensitive arg values from the JSON we expose via env. |
| 19 | */ |
| 20 | export async function runHook(hook: HookConfig, ctx: HookContext): Promise<HookRunResult> { |
| 21 | const start = Date.now(); |
| 22 | const timeoutMs = Math.max(1, hook.timeout ?? 30) * 1000; |
| 23 | const hookName = hook.name ?? hook.command.slice(0, 60); |
| 24 | |
| 25 | // Build env. Redact sensitive arg values so a logger hook can't accidentally |
| 26 | // exfiltrate API keys etc. that the model passed to a tool. |
| 27 | const env: Record<string, string> = { |
| 28 | ...(process.env as Record<string, string>), |
| 29 | QODEX_HOOK_EVENT: ctx.event, |
| 30 | QODEX_SESSION_ID: ctx.sessionId, |
| 31 | QODEX_CWD: ctx.cwd, |
| 32 | }; |
| 33 | if (ctx.toolName) env.QODEX_TOOL_NAME = ctx.toolName; |
| 34 | if (ctx.toolArgsJson) { |
| 35 | // Re-parse, redact, re-serialise so secret values don't leak via env |
| 36 | try { |
| 37 | const parsed = JSON.parse(ctx.toolArgsJson); |
| 38 | env.QODEX_TOOL_ARGS_JSON = JSON.stringify( |
| 39 | parsed && typeof parsed === 'object' && !Array.isArray(parsed) |
| 40 | ? redactObject(parsed) |
| 41 | : parsed, |
| 42 | ); |
| 43 | } catch (e: any) { |
| 44 | // Redaction failed — NEVER fall back to the raw args (would leak secrets |
| 45 | // into the hook subprocess env). Use a safe placeholder instead. |
| 46 | logger.warn('Tool arg redaction failed; passing placeholder to hook', { hookName, err: e?.message }); |
| 47 | env.QODEX_TOOL_ARGS_JSON = '[redaction failed]'; |
| 48 | } |
| 49 | } |
| 50 | if (ctx.toolResult !== undefined) { |
| 51 | // Truncate to avoid huge env entries (some systems have ARG_MAX limits) |
| 52 | env.QODEX_TOOL_RESULT = ctx.toolResult.length > 64 * 1024 |
| 53 | ? ctx.toolResult.slice(0, 64 * 1024) + '\n...[truncated]' |
| 54 | : ctx.toolResult; |
| 55 | } |
| 56 | if (ctx.filePaths && ctx.filePaths.length > 0) { |
| 57 | env.QODEX_FILE_PATHS = ctx.filePaths.join(' '); |
| 58 | } |
| 59 | |
| 60 | return new Promise<HookRunResult>((resolve) => { |
| 61 | let proc: ReturnType<typeof spawn>; |
| 62 | try { |
| 63 | proc = spawn(hook.command, [], { |
| 64 | shell: true, |
| 65 | cwd: hook.cwd ?? ctx.cwd, |
| 66 | env, |
| 67 | stdio: ['ignore', 'pipe', 'pipe'], |
| 68 | // Run in its OWN process group so a timeout can kill the whole tree, not |
| 69 | // just the shell. With `shell: true` the command runs as `sh -c "<cmd>"`; |
| 70 | // SIGTERM to the shell alone can orphan its children (e.g. `sleep`), which |
| 71 | // keep the stdout pipe open so `close` never fires until they exit on their |
| 72 | // own — the hook timeout then never takes effect. Killing the group fixes it. |
| 73 | // (Windows has no POSIX process groups; cross-spawn handles kill there.) |
| 74 | detached: process.platform !== 'win32', |
| 75 | }); |
| 76 | } catch (e: any) { |
| 77 | logger.warn(`Hook spawn failed`, { hookName, err: e.message }); |
no test coverage detected