diff --git a/CHANGELOG.md b/CHANGELOG.md index ae36447a..0f79d336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,27 @@ python -V` 被报成删除 home / 工作区根目录,非 git 目录下的 `$PW /tmp`、`rm -rf "$PWD"`)行为不变;实际写入仍由脚本扫描(第 2 层)与运行后 快照(第 3 层)兜底。 +### O165 — a hook that throws blocks; Stop ends Incomplete; a rejected prompt exits 9 + +Anything thrown while handling a hook on a blocking event now fails closed +with a visible `[hooks]` notice: PreToolUse blocks the call, PermissionRequest +denies, UserPromptSubmit rejects the prompt, and a throwing Stop hook is a +**terminal veto** — `runStopHooks` used to catch the exception and return +"not blocked", silently approving the stop. A fatal Stop veto carries +`fatal: true`: the REPL skips its forced continuation turn (no further model +call) and the headless run ends Incomplete (exit 9). `-p` with a blocked +UserPromptSubmit hook used to exit 8 (TOOL_EXECUTION); it is +INPUT_GUARDRAIL_DENIED → Incomplete (9) with the stable 「被拦」/`Blocked` +last line instead of a rethrown error with a stack. + +处理 hook 时抛出的任何异常现在都按拦截失败关闭,并给出可见的 `[hooks]` 提示: +PreToolUse 拦下调用、PermissionRequest 拒绝、UserPromptSubmit 拒绝提示词; +抛异常的 Stop hook 是**终局否决**——以前 `runStopHooks` 捕获异常后返回「未拦 +截」,异常悄悄放行了结束。终局否决带 `fatal: true`:REPL 不再强制续一轮(不再 +调用模型),headless 以未完成(exit 9)结束。`-p` 下 UserPromptSubmit 被拦以前 +exit 8(TOOL_EXECUTION);现在是 INPUT_GUARDRAIL_DENIED → Incomplete(9), +末行是稳定的「被拦」/`Blocked` 结论线,不再抛带堆栈的错误。 + ### O162 — hook output over the capture limit blocks Each hook's stdout and stderr are captured up to 10 MiB per stream; the verdict diff --git a/README.en.md b/README.en.md index db7ba61f..b1855d45 100644 --- a/README.en.md +++ b/README.en.md @@ -481,6 +481,12 @@ one line: press `e` to switch to English. `git fetch`/`git push` with `--upload-pack` / `--receive-pack=` (glued, split, or clone's short `-u`) make git run that command; they are pre-blocked like `git -c` and `--ext-diff`. `git push -u origin main` (--set-upstream) is unaffected. +- **A hook that throws blocks (O165)**: any exception while handling a hook on a blocking + event fails closed with a visible notice (PreToolUse blocks the call, PermissionRequest + denies, UserPromptSubmit rejects the prompt); a throwing Stop hook is a terminal veto — + no forced continuation turn, no further model call, and headless ends Incomplete + (exit 9). In `-p`, a blocked UserPromptSubmit hook ends 「被拦」/`Blocked` with exit 9 + (was exit 8). - Credentials come only from `.env` or the environment: never hardcoded, never logged, never passed to child processes, never written to the device registry. No account, no cloud, no telemetry. diff --git a/README.md b/README.md index 54f86f0c..ac5da020 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开 - **hook 输出超过捕获上限即拦截(O162)**:每个 hook 的 stdout / stderr 各有 10 MiB 捕获上限;一旦有输出被截断,判定(最后一行 JSON)就不可信,所有阻塞类事件(`PreToolUse`、`UserPromptSubmit`、`PermissionRequest`、`Stop`)一律按拦截失败关闭——退出码与可读部分说了什么都不算数。`blocking: false` 的 hook 不能收紧任何调用,其输出超限同样不拦截。 - **脚本的参数不是删除(O168)**:解释器脚本形式的参数(`python3 x.py $HOME`、`node x.js "$PWD"`)、`sh x.sh` 的参数、`.`/`source` 的操作数与裸 `.` 不再触发「删除受保护祖先」规则——正常命令不再被误拦,非 git 目录也不再被说成 git 仓库内部文件。豁免与 cd 一样仅限该路径未在命令其他位置出现;直接点名受保护路径(`$HOME/.ssh`、`.git/config`)、内联形式与真实删除行为不变,实际写入仍由脚本扫描与运行后快照兜底。 - **git 的 pack 旗标按可执行拦截(O169)**:`git clone --upload-pack=`、`git fetch/push --upload-pack/--receive-pack=`(含空格分开与 clone 的 `-u` 短旗标)会让 git 执行该命令,与 `git -c`、`--ext-diff` 等一样在执行前拦截;`git push -u origin main`(设上游)不受影响。 +- **hook 抛异常一律拦截(O165)**:处理 hook 时抛出的任何异常都按拦截失败关闭并给出可见提示(PreToolUse 拦下调用、PermissionRequest 拒绝、UserPromptSubmit 拒绝提示词);抛异常的 Stop hook 是终局否决——不强制续跑、不再调用模型,headless 以未完成(exit 9)结束。`-p` 下 UserPromptSubmit 被拦时末行「被拦」、exit 9(原为 exit 8)。 - 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。 - 无账号、无云服务、无遥测;provider 是普通 HTTP 端点。 diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index f97b3341..c5ced624 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -27,6 +27,8 @@ const ERROR_CODE_TO_EXIT: Record = { [ErrorCode.TOOL_EXECUTION_TIMEOUT]: ExitCode.TOOL_EXECUTION, [ErrorCode.TOOL_NOT_FOUND]: ExitCode.TOOL_EXECUTION, [ErrorCode.TOOL_NOT_ALLOWED]: ExitCode.TOOL_EXECUTION, + /** O165: a blocked prompt ends 「被拦」/Incomplete, like every other block. */ + [ErrorCode.INPUT_GUARDRAIL_DENIED]: ExitCode.INCOMPLETE, [ErrorCode.SESSION_NOT_FOUND]: ExitCode.SESSION, [ErrorCode.SESSION_PERSIST_FAILED]: ExitCode.SESSION, [ErrorCode.SKILL_LOAD_FAILED]: ExitCode.GENERIC, diff --git a/src/cli/hooks.ts b/src/cli/hooks.ts index 8aab92c6..f7f25256 100644 --- a/src/cli/hooks.ts +++ b/src/cli/hooks.ts @@ -610,48 +610,57 @@ export function createConfiguredHookCallbacks( : async (request: ToolApprovalRequest): Promise => { // O157: deny > ask > allow. Every matching hook runs, so a deny after // an ask still blocks; asks are collected for one confirmation. - const asks: PreToolUseHookAsk[] = []; - for (const hook of pre) { - if (!toolNameMatches(hook, request.tool.name)) continue; - const r = await runHookCommand( - hook.command, - { - event: 'PreToolUse', - toolName: request.tool.name, - input: request.input, - ...claudePayload(hook, request.tool.name, request.input), - }, - cwd, - timeoutFor(hook) - ); - const parsed = parseHookJson(r.stdout); - if (preToolUseBlocks(hook, r, parsed)) { - return { approved: false, reason: blockReason('PreToolUse', hook, r, parsed) }; + // O165: anything thrown while running a PreToolUse hook (a bad + // matcher, a formatting bug) used to escape as a tool error; now it + // blocks the call with a visible notice — a safety gate fails closed. + try { + const asks: PreToolUseHookAsk[] = []; + for (const hook of pre) { + if (!toolNameMatches(hook, request.tool.name)) continue; + const r = await runHookCommand( + hook.command, + { + event: 'PreToolUse', + toolName: request.tool.name, + input: request.input, + ...claudePayload(hook, request.tool.name, request.input), + }, + cwd, + timeoutFor(hook) + ); + const parsed = parseHookJson(r.stdout); + if (preToolUseBlocks(hook, r, parsed)) { + return { approved: false, reason: blockReason('PreToolUse', hook, r, parsed) }; + } + if (hookFailed(r)) notice('PreToolUse', hook, r, false); + // A blocking:false hook cannot restrict the call, so its ask is + // ignored like its deny. preToolUseBlocks already refused non-zero. + if ( + hook.blocking !== false && + r.outcome === 'exit' && + isAskDecision(parsed?.decision) + ) { + const reason = parsed?.reason?.trim().slice(0, 500); + asks.push({ + command: hook.command, + ...(hook.source ? { source: hook.source } : {}), + ...(reason ? { reason } : {}), + }); + } } - if (hookFailed(r)) notice('PreToolUse', hook, r, false); - // A blocking:false hook cannot restrict the call, so its ask is - // ignored like its deny. preToolUseBlocks already refused non-zero. - if ( - hook.blocking !== false && - r.outcome === 'exit' && - isAskDecision(parsed?.decision) - ) { - const reason = parsed?.reason?.trim().slice(0, 500); - asks.push({ - command: hook.command, - ...(hook.source ? { source: hook.source } : {}), - ...(reason ? { reason } : {}), - }); + if (asks.length > 0) { + return { + approved: false, + reason: hookAskHeadlessReason(asks, 'print'), + hookAsks: asks, + }; } + return { approved: true }; + } catch (err) { + const line = hookFailedLine('PreToolUse', errorMessage(err)); + process.stderr.write(`[hooks] ${line}\n`); + return { approved: false, reason: line }; } - if (asks.length > 0) { - return { - approved: false, - reason: hookAskHeadlessReason(asks, 'print'), - hookAsks: asks, - }; - } - return { approved: true }; }; const onToolResult = @@ -698,23 +707,31 @@ export function createConfiguredHookCallbacks( }; const runStop = async (info: StopHookInfo): Promise => { - for (const hook of stop) { - const r = await runHookCommand( - hook.command, - { - event: 'Stop', - stopReason: info.stopReason, - ...(info.response ? { response: info.response.slice(-4000) } : {}), - }, - cwd, - timeoutFor(hook) - ); - if (exitBlocks(hook, r)) { - return { blocked: true, reason: blockReason('Stop', hook, r) }; + // O165: a throw inside one Stop hook is a terminal veto, like the runner + // level (runStopHooks) — visible notice, no continuation, Incomplete. + try { + for (const hook of stop) { + const r = await runHookCommand( + hook.command, + { + event: 'Stop', + stopReason: info.stopReason, + ...(info.response ? { response: info.response.slice(-4000) } : {}), + }, + cwd, + timeoutFor(hook) + ); + if (exitBlocks(hook, r)) { + return { blocked: true, reason: blockReason('Stop', hook, r) }; + } + if (hookFailed(r)) notice('Stop', hook, r, false); } - if (hookFailed(r)) notice('Stop', hook, r, false); + return { blocked: false }; + } catch (err) { + const line = hookFailedLine('Stop', errorMessage(err)); + process.stderr.write(`[hooks] ${line}\n`); + return { blocked: true, fatal: true, reason: line }; } - return { blocked: false }; }; const runSubagentStop = async (info: SubagentStopHookInfo): Promise => { @@ -813,53 +830,69 @@ export function createConfiguredHookCallbacks( const runUserPromptSubmit = async (message: string): Promise => { noticed.clear(); const extra: string[] = []; - for (const hook of userPrompt) { - const r = await runHookCommand( - hook.command, - { event: 'UserPromptSubmit', prompt: message }, - cwd, - timeoutFor(hook) - ); - const parsed = parseHookJson(r.stdout); - if (promptBlocks(hook, r, parsed)) { - return { blocked: true, reason: blockReason('UserPromptSubmit', hook, r, parsed) }; - } - if (hookFailed(r)) { - notice('UserPromptSubmit', hook, r, false); - continue; + // O165: a throw while handling one UserPromptSubmit hook blocks the prompt + // (fail closed) instead of escaping as a run error. + try { + for (const hook of userPrompt) { + const r = await runHookCommand( + hook.command, + { event: 'UserPromptSubmit', prompt: message }, + cwd, + timeoutFor(hook) + ); + const parsed = parseHookJson(r.stdout); + if (promptBlocks(hook, r, parsed)) { + return { blocked: true, reason: blockReason('UserPromptSubmit', hook, r, parsed) }; + } + if (hookFailed(r)) { + notice('UserPromptSubmit', hook, r, false); + continue; + } + const context = parsed?.additionalContext?.trim() || (parsed ? '' : r.stdout.trim()); + if (context) extra.push(context); } - const context = parsed?.additionalContext?.trim() || (parsed ? '' : r.stdout.trim()); - if (context) extra.push(context); + return extra.length > 0 + ? { blocked: false, extraContext: extra.join('\n') } + : { blocked: false }; + } catch (err) { + const line = hookFailedLine('UserPromptSubmit', errorMessage(err)); + process.stderr.write(`[hooks] ${line}\n`); + return { blocked: true, reason: line }; } - return extra.length > 0 - ? { blocked: false, extraContext: extra.join('\n') } - : { blocked: false }; }; const runPermissionRequest = async (info: { toolName: string; input: Record; }): Promise => { - for (const hook of permission) { - if (!toolNameMatches(hook, info.toolName)) continue; - const r = await runHookCommand( - hook.command, - { - event: 'PermissionRequest', - toolName: info.toolName, - input: info.input, - ...claudePayload(hook, info.toolName, info.input), - }, - cwd, - timeoutFor(hook) - ); - const parsed = parseHookJson(r.stdout); - if (promptBlocks(hook, r, parsed)) { - return { denied: true, reason: blockReason('PermissionRequest', hook, r, parsed) }; + // O165: a throw while handling one PermissionRequest hook denies (fail + // closed) instead of escaping as a run error. + try { + for (const hook of permission) { + if (!toolNameMatches(hook, info.toolName)) continue; + const r = await runHookCommand( + hook.command, + { + event: 'PermissionRequest', + toolName: info.toolName, + input: info.input, + ...claudePayload(hook, info.toolName, info.input), + }, + cwd, + timeoutFor(hook) + ); + const parsed = parseHookJson(r.stdout); + if (promptBlocks(hook, r, parsed)) { + return { denied: true, reason: blockReason('PermissionRequest', hook, r, parsed) }; + } + if (hookFailed(r)) notice('PermissionRequest', hook, r, false); } - if (hookFailed(r)) notice('PermissionRequest', hook, r, false); + return { denied: false }; + } catch (err) { + const line = hookFailedLine('PermissionRequest', errorMessage(err)); + process.stderr.write(`[hooks] ${line}\n`); + return { denied: true, reason: line }; } - return { denied: false }; }; return { @@ -906,6 +939,12 @@ export interface StopHookInfo { export interface StopHookResult { blocked: boolean; reason?: string; + /** + * O165: the hook infrastructure itself threw (not a hook verdict). The veto + * is terminal: the REPL does not force a continuation turn — the run ends + * Incomplete (exit 9) with a visible notice. + */ + fatal?: boolean; } export interface UserPromptSubmitResult { @@ -951,8 +990,15 @@ export async function runStopHooks(info: StopHookInfo): Promise try { return (await lifecycleRunner?.runStop(info)) ?? { blocked: false }; } catch (err) { - process.stderr.write(`${hookFailedLine('Stop', errorMessage(err))}\n`); - return { blocked: false }; + // O165: a throwing Stop hook used to become { blocked: false } — the + // exception silently approved the stop. Fail closed instead: a visible + // notice and a terminal veto (no model continuation; Incomplete, exit 9). + const zh = isZhLocale(); + const line = zh + ? `${hookEventZhName('Stop')}钩子执行抛出异常,已按拦截处理(本次运行以未完成结束,不再调用模型):${errorMessage(err)}` + : `Stop hook threw; treated as deny (the run ends Incomplete with no further model call): ${errorMessage(err)}`; + process.stderr.write(`[hooks] ${line}\n`); + return { blocked: true, fatal: true, reason: line }; } } diff --git a/src/cli/oneshot.ts b/src/cli/oneshot.ts index fb033b23..405da5ee 100644 --- a/src/cli/oneshot.ts +++ b/src/cli/oneshot.ts @@ -8,7 +8,7 @@ import { } from '../core/tools/background-process-registry.js'; import { isZhLocale } from './cli-locale.js'; import { exitCodeForError, ExitCode } from './exit-codes.js'; -import { mossErrorFromOutcome } from '../errors.js'; +import { mossErrorFromOutcome, errorMessage, MossError, ErrorCode } from '../errors.js'; import { createHeadlessPrintState, formatHeadlessBackgroundStillRunningEvent, @@ -439,6 +439,8 @@ export async function runOneShot( }); let finalResult: HeadlessResultEvent | undefined; let runError: unknown = undefined; + /** O165: the UserPromptSubmit hook rejected the prompt (blocked outcome). */ + let inputBlockedReason: string | undefined; const completionTracker = createRunCompletionTracker(); // O121: JSON / stream-json derive failure straight from the same // resolveRunOutcome the text last line uses, so every non-completed outcome @@ -571,8 +573,24 @@ export async function runOneShot( } } catch (err) { runError = err; - if (outputFormat === 'text') throw err; - writeStructured(formatHeadlessThrownError(state, err)); + // O165: a prompt rejected by an input guardrail (UserPromptSubmit hook) + // is a 「被拦」 outcome line (exit 9), not a rethrown error with a stack. + if (err instanceof MossError && err.code === ErrorCode.INPUT_GUARDRAIL_DENIED) { + const cause = err.cause; + const prefix = 'input guardrail rejected the user message: '; + const reason = + typeof cause === 'string' && cause.trim() + ? cause.trim() + : err.message.startsWith(prefix) + ? err.message.slice(prefix.length) + : err.message; + inputBlockedReason = reason; + process.stderr.write(`[hooks] ${reason}\n`); + } else if (outputFormat === 'text') { + throw err; + } else { + writeStructured(formatHeadlessThrownError(state, err)); + } } if (finalResult ? isHeadlessResultError(finalResult) : Boolean(state.lastError)) { @@ -780,8 +798,12 @@ export async function runOneShot( : ' (interactive mode reports; host may force-continue)'; process.stderr.write(`[hooks] ${stopResult.reason ?? fallback}${suffix}\n`); } - } catch { - stopResult = { blocked: false }; + } catch (err) { + // O165: even if something above runStopHooks throws, the stop is vetoed + // (fail closed), not silently approved. + const line = `Stop hook threw; treated as deny: ${errorMessage(err)}`; + process.stderr.write(`[hooks] ${line}\n`); + stopResult = { blocked: true, fatal: true, reason: line }; } // O98: never end a text `--print` run on a false success. Resolve a stable @@ -799,6 +821,9 @@ export async function runOneShot( ...(stopResult.blocked ? { blocked: true, ...(stopResult.reason ? { blockedReason: stopResult.reason } : {}) } : {}), + ...(inputBlockedReason + ? { inputBlocked: inputBlockedReason, inputBlockedZh: inputBlockedReason } + : {}), }); const line = formatOutcomeLine(outcome, lang); if (line) stdout.write(`\n${line}\n`); diff --git a/src/cli/repl.ts b/src/cli/repl.ts index 84636c5c..3a2b5563 100644 --- a/src/cli/repl.ts +++ b/src/cli/repl.ts @@ -298,7 +298,8 @@ export async function runInteractive( onAgentEvent: (event) => usage.record(event), taskFlow: messageRequestsTaskContract(String(submitText)), }); - if (stop?.blocked) { + // O165: a fatal (throwing) Stop hook is terminal — no continuation. + if (stop?.blocked && !stop.fatal) { const feedback = `[stop-hook feedback] ${stop.reason ?? 'The Stop hook requires more work; continue the task.'}`; await runOneShot(agent, feedback, { sessionKey, @@ -701,7 +702,8 @@ export async function runInteractive( onAgentEvent: (event) => usage.record(event), taskFlow: messageRequestsTaskContract(resolved.prompt), }); - if (stop?.blocked) { + // O165: a fatal (throwing) Stop hook is terminal — no continuation. + if (stop?.blocked && !stop.fatal) { const feedback = `[stop-hook feedback] ${stop.reason ?? 'The Stop hook requires more work; continue the task.'}`; await runOneShot(agent, feedback, { sessionKey, @@ -745,7 +747,9 @@ export async function runInteractive( onAgentEvent: (event) => usage.record(event), taskFlow: messageRequestsTaskContract(msg), }); - if (stop?.blocked) { + // O165: a fatal (throwing) Stop hook is terminal — no continuation turn; + // the run already ended Incomplete with a visible notice. + if (stop?.blocked && !stop.fatal) { // Stop hook vetoed the run ending: one forced continuation turn, then // the veto is consumed (a hook that keeps blocking would loop forever). const feedback = `[stop-hook feedback] ${stop.reason ?? 'The Stop hook requires more work; continue the task.'}`; diff --git a/src/cli/run-outcome.ts b/src/cli/run-outcome.ts index a67aec5e..0b774146 100644 --- a/src/cli/run-outcome.ts +++ b/src/cli/run-outcome.ts @@ -77,6 +77,12 @@ export interface ResolveRunOutcomeInput { /** Stop hook blocked the run ending. */ blocked?: boolean; blockedReason?: string; + /** + * O165: an input guardrail (UserPromptSubmit hook) rejected the prompt — + * a 「被拦」 outcome with this reason, not a rethrown run error. + */ + inputBlocked?: string; + inputBlockedZh?: string; } const CHECK_LABEL: Record = { @@ -171,6 +177,14 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome { stuckAtZh = `${hookEventZhName('Stop')}钩子`; } + // O165: the prompt itself was rejected before the model ran — a stable + // 「被拦」 outcome line (exit 9), like every other blocked safety outcome. + if (input.inputBlocked) { + status = 'blocked'; + reasonsEn.push(input.inputBlocked); + reasonsZh.push(input.inputBlockedZh ?? input.inputBlocked); + } + // O101 layer 3: protected-path post-run detection → incomplete (model // success claims must not override). if (tracker.protectedPathChanges && tracker.protectedPathChanges.length > 0) { diff --git a/src/core/agent/moss-agent-helpers.ts b/src/core/agent/moss-agent-helpers.ts index f3e87d0e..59fe5bf6 100644 --- a/src/core/agent/moss-agent-helpers.ts +++ b/src/core/agent/moss-agent-helpers.ts @@ -93,7 +93,9 @@ export function createInputGuardrailDeniedError( reason: string ): MossError { return new MossError({ - code: ErrorCode.TOOL_NOT_ALLOWED, + // O165: a rejected prompt is a blocked safety outcome → Incomplete (9), + // not a tool-execution failure (8). + code: ErrorCode.INPUT_GUARDRAIL_DENIED, message: `input guardrail rejected the user message: ${reason || 'no reason provided'}`, hint: 'Review the request or host input policy before retrying.', recoverable: false, diff --git a/src/errors.ts b/src/errors.ts index 167f0d76..0e3780b4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,6 +20,9 @@ export enum ErrorCode { TOOL_NOT_ALLOWED = 'TOOL_NOT_ALLOWED', + /** O165: an input guardrail (UserPromptSubmit hook) rejected the prompt. */ + INPUT_GUARDRAIL_DENIED = 'INPUT_GUARDRAIL_DENIED', + SESSION_NOT_FOUND = 'SESSION_NOT_FOUND', SESSION_PERSIST_FAILED = 'SESSION_PERSIST_FAILED', diff --git a/test/hooks-fail-closed.spec.mjs b/test/hooks-fail-closed.spec.mjs index e76472a3..73d3fa7b 100644 --- a/test/hooks-fail-closed.spec.mjs +++ b/test/hooks-fail-closed.spec.mjs @@ -395,7 +395,7 @@ test('runStopHooks wrapper: missing Stop command blocks', async () => { } }); -test('runStopHooks wrapper: internal error stays fail-open but is visible', async () => { +test('runStopHooks wrapper: internal error fails closed (O165) and is visible', async () => { setLifecycleHookRunner({ runStop: async () => { throw new Error('o88-internal-boom'); @@ -403,8 +403,13 @@ test('runStopHooks wrapper: internal error stays fail-open but is visible', asyn }); try { const { value, stderr } = await captureStderr(() => runStopHooks({ stopReason: 'end_turn' })); - assert.deepEqual(value, { blocked: false }); - assert.match(stderr, /\[hooks\] Stop hook failed: .*o88-internal-boom/); + // O165: a throwing Stop hook is a terminal veto — blocked + fatal, never + // a silent allow. The REPL skips its continuation turn; headless ends + // Incomplete (exit 9). + assert.equal(value.blocked, true); + assert.equal(value.fatal, true); + assert.match(value.reason ?? '', /Stop hook threw.*o88-internal-boom/); + assert.match(stderr, /\[hooks\] Stop hook threw.*o88-internal-boom/); } finally { setLifecycleHookRunner(undefined); }