Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,12 @@ one line: press `e` to switch to English.
`git fetch`/`git push` with `--upload-pack` / `--receive-pack=<cmd>` (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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<cmd>`、`git fetch/push --upload-pack/--receive-pack=<cmd>`(含空格分开与 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 端点。

Expand Down
2 changes: 2 additions & 0 deletions src/cli/exit-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const ERROR_CODE_TO_EXIT: Record<ErrorCode, ExitCode> = {
[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,
Expand Down
232 changes: 139 additions & 93 deletions src/cli/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,48 +610,57 @@ export function createConfiguredHookCallbacks(
: async (request: ToolApprovalRequest): Promise<PreToolUseDecision> => {
// 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 =
Expand Down Expand Up @@ -698,23 +707,31 @@ export function createConfiguredHookCallbacks(
};

const runStop = async (info: StopHookInfo): Promise<StopHookResult> => {
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<void> => {
Expand Down Expand Up @@ -813,53 +830,69 @@ export function createConfiguredHookCallbacks(
const runUserPromptSubmit = async (message: string): Promise<UserPromptSubmitResult> => {
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<string, unknown>;
}): Promise<PermissionRequestResult> => {
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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -951,8 +990,15 @@ export async function runStopHooks(info: StopHookInfo): Promise<StopHookResult>
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 };
}
}

Expand Down
Loading
Loading