From 3d9a63a3f23c1e068a2e96498123003f19985026 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 00:17:26 +0800 Subject: [PATCH 1/2] fix(O165): hooks fail closed on throw; Stop fatal veto; blocked prompt exits 9 --- CHANGELOG.md | 21 +++ README.en.md | 6 + README.md | 1 + src/cli/exit-codes.ts | 2 + src/cli/hooks.ts | 190 +++++++++++++++++---------- src/cli/oneshot.ts | 35 ++++- src/cli/repl.ts | 10 +- src/cli/run-outcome.ts | 14 ++ src/core/agent/moss-agent-helpers.ts | 4 +- src/errors.ts | 3 + 10 files changed, 205 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df559765..54538cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +### 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` 结论线,不再抛带堆栈的错误。 + ### O156 — Windows PowerShell 5.1: a missing hook command is "failed to start" A hook run through Windows PowerShell 5.1 (`powershell -Command `) diff --git a/README.en.md b/README.en.md index 21a212df..4b25d884 100644 --- a/README.en.md +++ b/README.en.md @@ -453,6 +453,12 @@ one line: press `e` to switch to English. paths to system programs (such as the background job's `powershell.exe`) from the environment before `.env` was loaded. A child PATH left with no usable entry (only `.` or empty entries) becomes a directory that does not exist, never an empty string. +- **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 55a00302..a9271ba3 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开 - **Moss 启动的程序不从工作区取(O158)**:启动探测、shell 解析、git 探测、hook 的启动命令、MCP 服务器等 Moss 自己启动的程序,一律解析成绝对路径,只查 PATH 里的绝对目录,跳过空项、`.`、相对路径和工作区内的目录;Windows 子进程设 `NoDefaultCurrentDirectoryInExePath=1`。**已知限制**:在 PATH 含工作区目录的环境里启动 moss(例如 `npx` / `npm run` 带来的 `node_modules/.bin`)时,模型 exec 的命令和 hook 脚本内部调用的程序沿用这个 PATH,会使用项目里的同名工具。 - **hook 启动命令的安全解析范围(O164)**:Moss 只对 hook 命令开头的那个程序名做上面的安全解析。用了环境变量前缀(`FOO=1 git`)、`exec` / `env` / `command`、引号、子 shell、分号或重定向时,按普通 shell 查找;若以 `npx` / `npm run` 启动,PATH 里的 `node_modules/.bin` 会被用到。用户级 hook 建议写成 `程序名 参数` 或绝对路径。项目 hook(`.moss/config.json`)只在你信任该文件夹后才运行;信任文件夹等于信任项目代码,包括项目 hook 和 `node_modules/.bin` 里的程序。 - **`.env` 不能改系统位置(O164)**:`SystemRoot`、`windir`、`SystemDrive`、`ProgramFiles`(含 `(x86)`、`ProgramW6432`)、`PSModulePath`、`NoDefaultCurrentDirectoryInExePath` 与 `PATH`、`PATHEXT`、`ComSpec`、`TEMP`/`TMP` 一样不能由 `.env` 设置;Moss 拼系统程序路径(如后台任务用的 `powershell.exe`)时只用加载 `.env` 之前的环境。子进程的 PATH 被清空(只剩 `.`、空项)时换成一个不存在的绝对目录,不会留空串。 +- **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 6bf0311d..547243be 100644 --- a/src/cli/hooks.ts +++ b/src/cli/hooks.ts @@ -548,26 +548,35 @@ export function createConfiguredHookCallbacks( pre.length === 0 ? undefined : async (request: ToolApprovalRequest): Promise => { - 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 { + 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); } - if (hookFailed(r)) notice('PreToolUse', hook, r, false); + return { approved: true }; + } catch (err) { + const line = hookFailedLine('PreToolUse', errorMessage(err)); + process.stderr.write(`[hooks] ${line}\n`); + return { approved: false, reason: line }; } - return { approved: true }; }; const onToolResult = @@ -614,23 +623,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 => { @@ -729,53 +746,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 { @@ -822,6 +855,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 { @@ -867,8 +906,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', From 82374157c8b6d6f6aaa3e1d00d0f1ac1fc92a2f8 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 01:30:55 +0800 Subject: [PATCH 2/2] test(O165): hooks-fail-closed pins the new fail-closed Stop contract --- test/hooks-fail-closed.spec.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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); }