Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/hook-fail-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

`[[hooks]]` entries accept a new optional `fail_mode` field. The default `"open"` keeps today's behavior: a hook that crashes, times out, fails to spawn, or exits with an unexpected code resolves to allow. Setting `fail_mode = "closed"` inverts that for hooks acting as security gates: any failure to deliver a verdict blocks the operation with an explicit reason. Exit codes 0/2 and structured stdout decisions behave exactly as before, and a user interrupt still resolves to allow in both modes. When identical hook commands are deduplicated, a fail-closed entry now wins over a fail-open duplicate.
15 changes: 9 additions & 6 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ The script's response is determined by two things:
- **Exit code**: `0` means allow, `2` means block, other non-zero values default to allow
- **Standard output** (stdout): can include explanatory text

Even if the script errors or times out, the CLI **will not interrupt your work** as a result — this "allow on failure" design is called fail-open, preventing hook errors from becoming blockers.
By default, even if the script errors or times out, the CLI **will not interrupt your work** as a result — this "allow on failure" design is called fail-open, preventing hook errors from becoming blockers.

For hooks that act as security gates, this default can be inverted per hook with `fail_mode = "closed"`: if the hook cannot deliver a verdict (spawn failure, crash, timeout, or any exit code other than `0` or `2`), the operation is **blocked** instead of allowed. A user interrupt (Esc) still resolves to allow in both modes, since the turn is being cancelled anyway.

::: warning Note
Precisely because of fail-open, Hooks are suitable for alerts and lightweight interception, but **should not be used as the sole security barrier**. For truly high-risk operations, rely on permission approvals and manual confirmation.
With the default fail-open behavior, Hooks are suitable for alerts and lightweight interception, but **should not be used as the sole security barrier**. For truly high-risk operations, use `fail_mode = "closed"` on the hook, or rely on permission approvals and manual confirmation. Note that fail-closed means a broken or slow hook script will block every matching operation until fixed — that is the point, but it makes the hook script's reliability your responsibility.
:::

## Quick Start: A Minimal Hook
Expand All @@ -47,10 +49,11 @@ All hook rules are written in the `[[hooks]]` array in `~/.kimi-code/config.toml
| `matcher` | `string` | No | A regular expression to filter event targets; if omitted, matches all |
| `command` | `string` | Yes | The shell command to run when triggered |
| `timeout` | `integer` | No | Timeout in seconds, range 1–600; defaults to 30 seconds |
| `fail_mode` | `string` | No | `"open"` (default) or `"closed"`. Controls what happens when the hook itself fails; see [How Hooks Work](#how-hooks-work) |

`[[hooks]]` only allows these four fields; extra fields will cause the config file to fail to load.
`[[hooks]]` only allows these five fields; extra fields will cause the config file to fail to load.

**When multiple rules match the same event**, all matching hooks run in parallel; multiple rules with identical `command` values run only once.
**When multiple rules match the same event**, all matching hooks run in parallel; multiple rules with identical `command` values run only once. If any of the collapsed duplicates sets `fail_mode = "closed"`, the single run is fail-closed.

The working directory for hook commands is the current session's project directory. On non-Windows platforms, hook processes are placed in a separate process group; on timeout, a signal is sent first to give the process a chance to clean up, then it is forcibly terminated.

Expand All @@ -76,8 +79,8 @@ After the script exits, the CLI determines the hook's intent based on the exit c
| --- | --- | --- |
| `0` | Normal exit, allow | Continue execution; stdout content (if any) may be appended to context |
| `2` | Intentional block | Stop the current operation; stderr content (printed via `console.error`) is used as the reason for blocking |
| Other non-zero | Script error | Default allow (fail-open) |
| Timeout or crash | Script exception | Default allow (fail-open) |
| Other non-zero | Script error | Default allow (fail-open); blocks when the hook sets `fail_mode = "closed"` |
| Timeout or crash | Script exception | Default allow (fail-open); blocks when the hook sets `fail_mode = "closed"` |

You can also return a JSON object via stdout to block:

Expand Down
2 changes: 1 addition & 1 deletion docs/en/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ Plugin MCP servers start after `/reload` or in new sessions. To enable or disabl

## Hooks in Plugins

A plugin can declare hook rules in its manifest that run on lifecycle events while the plugin is enabled. Each entry uses the same fields as a [`[[hooks]]` rule in `config.toml`](./hooks.md#configuration) (`event`, `matcher`, `command`, `timeout`):
A plugin can declare hook rules in its manifest that run on lifecycle events while the plugin is enabled. Each entry uses the same fields as a [`[[hooks]]` rule in `config.toml`](./hooks.md#configuration) (`event`, `matcher`, `command`, `timeout`, `fail_mode`):

```json
{
Expand Down
15 changes: 9 additions & 6 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ Hooks(钩子)是一种自动触发机制:你预先告诉 Kimi Code CLI"每
- **退出码**(exit code,程序结束时向操作系统报告的状态数字):`0` 表示放行,`2` 表示阻断,其他数字默认放行
- **标准输出**(stdout,就是你用 `console.log` 或 `print` 打印出来的内容):可以附带说明文字

即使脚本报错、超时,CLI 也**不会因此中断你的工作**——这种"出错就放行"的设计叫 fail-open(失败开放),避免 hook 异常变成绊脚石。
默认情况下,即使脚本报错、超时,CLI 也**不会因此中断你的工作**——这种"出错就放行"的设计叫 fail-open(失败开放),避免 hook 异常变成绊脚石。

对于承担安全拦截职责的 hook,可以按条规则用 `fail_mode = "closed"` 反转这一默认行为:当 hook 无法给出裁决时(进程无法启动、崩溃、超时,或退出码既不是 `0` 也不是 `2`),操作会被**拦截**而不是放行。用户主动中断(Esc)在两种模式下都按放行处理,因为整轮操作本来就要取消。

::: warning 注意
正因为 fail-open,Hooks 适合做提醒和轻量拦截,但**不应作为唯一的安全防线**。对真正高风险的操作,仍需依赖权限审批和人工确认
在默认的 fail-open 行为下,Hooks 适合做提醒和轻量拦截,但**不应作为唯一的安全防线**。对真正高风险的操作,可以给 hook 配置 `fail_mode = "closed"`,或依赖权限审批和人工确认。注意 fail-closed 意味着 hook 脚本一旦损坏或变慢,所有匹配的操作都会被拦截直到修复——这正是它的意义所在,但也意味着 hook 脚本的可靠性由你负责
:::

## 快速上手:一个最简单的 hook
Expand All @@ -47,10 +49,11 @@ command = "terminal-notifier -title Kimi -message 'Task done'"
| `matcher` | `string` | 否 | 用正则表达式(一种字符串匹配语法)过滤事件目标;不填则匹配全部 |
| `command` | `string` | 是 | 触发时要运行的 Shell 命令 |
| `timeout` | `integer` | 否 | 超时秒数,范围 1–600;默认 30 秒 |
| `fail_mode` | `string` | 否 | `"open"`(默认)或 `"closed"`。控制 hook 自身失败时的行为,见[Hooks 是怎么工作的](#hooks-是怎么工作的) |

`[[hooks]]` 只允许这四个字段,多写会导致配置文件加载失败。
`[[hooks]]` 只允许这五个字段,多写会导致配置文件加载失败。

**同一事件匹配多条规则时**,所有命中的 hook 并行运行;`command` 完全相同的多条规则只运行一次。
**同一事件匹配多条规则时**,所有命中的 hook 并行运行;`command` 完全相同的多条规则只运行一次。被合并的重复项中只要有一条配置了 `fail_mode = "closed"`,这次运行就按 fail-closed 处理。

Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上,hook 进程放在独立进程组里,超时时先发信号让它有机会善后,之后才强制终止。

Expand All @@ -76,8 +79,8 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
| --- | --- | --- |
| `0` | 正常结束,放行 | 继续执行,若标准输出(stdout)有内容可附加到上下文 |
| `2` | 主动阻断 | 停止当前操作;错误输出(stderr,`console.error` 打印的内容)作为阻断原因 |
| 其他非零值 | 脚本出错 | 默认放行(fail-open) |
| 超时或崩溃 | 脚本异常 | 默认放行(fail-open) |
| 其他非零值 | 脚本出错 | 默认放行(fail-open);hook 配置了 `fail_mode = "closed"` 时拦截 |
| 超时或崩溃 | 脚本异常 | 默认放行(fail-open);hook 配置了 `fail_mode = "closed"` 时拦截 |

也可以通过标准输出返回一段 JSON 来阻断:

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ Plugin MCP servers 会在 `/reload` 后或新会话中启动。启用或禁用

## 插件中的 Hooks

plugin 可以在其 manifest 中声明 hook 规则,在 plugin 启用期间于生命周期事件上运行。每一项使用与 [`config.toml` 中的 `[[hooks]]` 规则](./hooks.md#配置)相同的字段(`event`、`matcher`、`command`、`timeout`):
plugin 可以在其 manifest 中声明 hook 规则,在 plugin 启用期间于生命周期事件上运行。每一项使用与 [`config.toml` 中的 `[[hooks]]` 规则](./hooks.md#配置)相同的字段(`event`、`matcher`、`command`、`timeout`、`fail_mode`):

```json
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const HookDefSchema = z
matcher: z.string().optional(),
command: z.string().min(1),
timeout: z.number().int().min(1).max(600).optional(),
failMode: z.enum(['open', 'closed']).optional(),
})
.strict();

Expand Down
57 changes: 51 additions & 6 deletions packages/agent-core-v2/src/agent/externalHooks/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { z } from 'zod';

import { type IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';

import type { HookResult } from './types';
import type { HookFailMode, HookResult } from './types';

export interface RunHookOptions {
readonly timeout: number;
readonly cwd?: string;
readonly env?: Record<string, string>;
readonly signal?: AbortSignal;
readonly failMode?: HookFailMode;
}

export function buildHookSpawnOptions(options: {
Expand Down Expand Up @@ -69,7 +70,9 @@ export async function runHook(
env: options.env,
});
} catch (error) {
return allowResult({ stderr: errorMessage(error) });
return failureResult(options.failMode, `spawn failed: ${errorMessage(error)}`, {
stderr: errorMessage(error),
});
}

return new Promise<HookResult>((resolve) => {
Expand Down Expand Up @@ -104,17 +107,28 @@ export async function runHook(
void Promise.all([proc.wait(), stdoutDone, stderrDone]).then(
([code]) => {
proc.dispose();
settle(resultFromExitCode(code, stdout, stderr));
settle(resultFromExitCode(code, stdout, stderr, options.failMode));
},
(error) => {
proc.dispose();
settle(allowResult({ stdout, stderr: stderr + errorMessage(error) }));
settle(
failureResult(options.failMode, `hook errored: ${errorMessage(error)}`, {
stdout,
stderr: stderr + errorMessage(error),
}),
);
},
);

const timeout = setTimeout(() => {
killProcess(proc);
settle(allowResult({ stdout, stderr, timedOut: true }));
settle(
failureResult(
options.failMode,
`timed out after ${timeoutSeconds(options.timeout)}s`,
{ stdout, stderr, timedOut: true },
),
);
}, timeoutMs);

const onAbort = (): void => {
Expand All @@ -137,7 +151,12 @@ function timeoutSeconds(timeout: number): number {
return Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TIMEOUT_SECONDS;
}

function resultFromExitCode(exitCode: number, stdout: string, stderr: string): HookResult {
function resultFromExitCode(
exitCode: number,
stdout: string,
stderr: string,
failMode?: HookFailMode,
): HookResult {
if (exitCode === 2) {
const message = stderr.trim();
return {
Expand All @@ -150,6 +169,10 @@ function resultFromExitCode(exitCode: number, stdout: string, stderr: string): H
};
}

if (exitCode !== 0 && failMode === 'closed') {
return failureResult(failMode, `exit code ${exitCode}`, { stdout, stderr, exitCode });
}

const structured = exitCode === 0 ? structuredOutput(stdout) : undefined;
if (structured?.action === 'block') {
return {
Expand Down Expand Up @@ -205,6 +228,28 @@ function structuredOutput(
}
}

// Abort (user interrupt) deliberately stays allow in both modes: the turn is
// being cancelled, so nothing the hook would have gated is going to execute.
function failureResult(
failMode: HookFailMode | undefined,
detail: string,
input: {
readonly stdout?: string;
readonly stderr?: string;
readonly exitCode?: number;
readonly timedOut?: boolean;
},
): HookResult {
if (failMode !== 'closed') return allowResult(input);
const reason = `Hook failed (fail_mode=closed): ${detail}`;
return {
action: 'block',
message: reason,
reason,
...input,
};
}

function allowResult(input: {
readonly message?: string;
readonly stdout?: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/agent/externalHooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ export const HOOK_EVENT_TYPES = [

export type HookEventType = (typeof HOOK_EVENT_TYPES)[number];

export type HookFailMode = 'open' | 'closed';

export interface HookDef {
readonly event: HookEventType;
readonly matcher?: string;
readonly command: string;
readonly timeout?: number;
readonly failMode?: HookFailMode;
readonly cwd?: string;
readonly env?: Record<string, string>;
}
Expand Down
17 changes: 14 additions & 3 deletions packages/agent-core-v2/src/app/externalHooksRunner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,22 @@ export async function runMatchedHooks(
const matcherValue = matcherValueText(args.matcherValue);
const cwd = args.cwd ?? '';
const matched: HookDef[] = [];
const seen = new Set<string>();
const seen = new Map<string, number>();
for (const hook of byEvent.get(event) ?? []) {
if (!matches(hook.matcher ?? '', matcherValue)) continue;
const key = (hook.cwd ?? '') + '\0' + hook.command;
if (seen.has(key)) continue;
seen.add(key);
const existing = seen.get(key);
if (existing !== undefined) {
// Identical commands collapse to one run; if any collapsed entry is
// fail-closed, the surviving entry must be too, or dedup order would
// silently weaken a security gate.
const kept = matched[existing];
if (hook.failMode === 'closed' && kept !== undefined && kept.failMode !== 'closed') {
matched[existing] = { ...kept, failMode: 'closed' };
}
continue;
}
seen.set(key, matched.length);
matched.push(hook);
}
if (matched.length === 0) return [];
Expand All @@ -83,6 +93,7 @@ export async function runMatchedHooks(
cwd: hook.cwd ?? (cwd === '' ? undefined : cwd),
env: hook.env,
signal: args.signal,
failMode: hook.failMode,
}),
),
);
Expand Down
16 changes: 15 additions & 1 deletion packages/agent-core-v2/src/app/plugin/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ function readHooks(
}
const out: HookDefConfig[] = [];
raw.forEach((entry, i) => {
const parsed = HookDefSchema.safeParse(entry);
const parsed = HookDefSchema.safeParse(normalizeHookFailMode(entry));
if (!parsed.success) {
diagnostics.push({
severity: 'warn',
Expand All @@ -308,6 +308,20 @@ function readHooks(
return out.length === 0 ? undefined : out;
}

// The same kimi.plugin.json is read by both agent cores, whose hook schemas
// canonicalize the fail-mode field differently (fail_mode in agent-core,
// failMode here). Accept either spelling (exactly one) so a manifest written
// for one core is not silently dropped by the other; an entry carrying both
// spellings falls through to the strict schema and is rejected like any other
// unrecognized-key entry.
function normalizeHookFailMode(entry: unknown): unknown {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return entry;
const record = entry as Record<string, unknown>;
if (!('fail_mode' in record) || 'failMode' in record) return entry;
const { fail_mode: failMode, ...rest } = record;
return { ...rest, failMode };
}

async function readCommands(
pluginRoot: string,
raw: unknown,
Expand Down
80 changes: 80 additions & 0 deletions packages/agent-core-v2/test/agent/externalHooks/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,86 @@ describe('runHook process runner', () => {
});
});

describe('runHook fail_mode=closed', () => {
it('blocks on non-zero, non-2 exit codes', async () => {
const result = await runHook(
hostProcess,
nodeCommand('process.exit(1);'),
{ tool_name: 'Bash' },
{ timeout: 5, failMode: 'closed' },
);

expect(result.action).toBe('block');
expect(result.reason).toContain('fail_mode=closed');
});

it('blocks with timedOut=true when the command exceeds the timeout', async () => {
const result = await runHook(
hostProcess,
nodeCommand('setTimeout(() => {}, 10000);'),
{ tool_name: 'Bash' },
{ timeout: 0.05, failMode: 'closed' },
);

expect(result.action).toBe('block');
expect(result.timedOut).toBe(true);
expect(result.reason).toContain('fail_mode=closed');
});

it('blocks when the hook process cannot be spawned', async () => {
const failingSpawn = {
spawn: async () => {
throw new Error('spawn boom');
},
} as unknown as typeof hostProcess;

const result = await runHook(
failingSpawn,
nodeCommand('process.exit(0);'),
{ tool_name: 'Bash' },
{ timeout: 5, failMode: 'closed' },
);

expect(result.action).toBe('block');
expect(result.reason).toContain('fail_mode=closed');
});

it('still allows a clean exit 0', async () => {
const result = await runHook(
hostProcess,
nodeCommand('process.exit(0);'),
{ tool_name: 'Bash' },
{ timeout: 5, failMode: 'closed' },
);

expect(result.action).toBe('allow');
});

it('blocks when the hook process dies by signal', async () => {
const result = await runHook(
hostProcess,
nodeCommand("process.kill(process.pid, 'SIGKILL');"),
{ tool_name: 'Bash' },
{ timeout: 5, failMode: 'closed' },
);

expect(result.action).toBe('block');
expect(result.reason).toContain('fail_mode=closed');
});

it('still blocks exit 2 with the stderr reason, not the fail-closed reason', async () => {
const result = await runHook(
hostProcess,
nodeCommand('process.stderr.write("nope"); process.exit(2);'),
{ tool_name: 'Bash' },
{ timeout: 5, failMode: 'closed' },
);

expect(result.action).toBe('block');
expect(result.reason).toBe('nope');
});
});

describe('buildHookSpawnOptions (Windows console-window regression)', () => {
it('sets windowsHide:true so hooks do not flash a console on Windows', () => {
expect(buildHookSpawnOptions({}).windowsHide).toBe(true);
Expand Down
Loading