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/permission-decision-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add an experimental hook that can answer ordinary tool approvals. Enable `permission-decision-hook` under `[experimental]` in `config.toml` to use it.
55 changes: 53 additions & 2 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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.
For the existing blockable events, a script error or timeout does not interrupt your work. The experimental `PermissionDecisionRequest` event is stricter: an invalid or missing result falls back to Kimi Code CLI's native approval instead of allowing the tool.

::: 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.
Expand Down Expand Up @@ -93,7 +93,57 @@ You can also return a JSON object via stdout to block:
```

::: info Which events support blocking?
Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return values that affect the main flow. All other events are **observation-only events** — they fire and forget; the main flow is unaffected regardless of what the script returns.
The established blockable events are `PreToolUse`, `Stop`, and `UserPromptSubmit`. The experimental `PermissionDecisionRequest` event can answer an ordinary tool approval using the stricter protocol below. All other events are **observation-only events** — they fire and forget; the main flow is unaffected regardless of what the script returns.
:::

## Experimental: answering tool approvals

`PermissionRequest` remains observation-only. To let a hook answer an ordinary tool approval, enable the experimental `PermissionDecisionRequest` event:

```toml
# ~/.kimi-code/config.toml
[experimental]
permission-decision-hook = true

[[hooks]]
event = "PermissionDecisionRequest"
matcher = "Bash"
command = "node ~/.kimi-code/hooks/approve-bash.mjs"
timeout = 5
```

You can also enable the feature with `KIMI_CODE_EXPERIMENTAL_PERMISSION_DECISION_HOOK=true`. The master `KIMI_CODE_EXPERIMENTAL_FLAG=true` switch enables it as well.

The hook receives the ordinary event fields plus `permission_request_id`, `agent_id`, `turn_id`, `tool_call_id`, `tool_name`, `action`, `tool_input`, and `display`. It must copy the exact request ID into a structured response:

```js
// approve-bash.mjs
let input = '';
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
const payload = JSON.parse(input);
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
permissionRequestId: payload.permission_request_id,
permissionDecision: 'allow',
},
}));
});
```

To deny, return `permissionDecision: "deny"` with the same `permissionRequestId` and an optional `permissionDecisionReason`. Exiting with code `2` also denies the current request and uses stderr as the reason.

The decision rules are deliberately conservative:

- Any valid deny from a matching hook wins.
- Allow is accepted only when every matching hook returns a structured allow for the current request ID.
- No matching hook, a mismatched request ID, malformed output, another exit code, a crash, or a timeout produces no hook decision and opens the native approval flow.
- Cancellation still cancels the approval; it is never converted into a native fallback.

This event only handles ordinary tool approvals. Approval flows with a custom continuation, including plan review, always use the native flow. Main agents and sub-agents use the same rules. In `kimi web` / kap-server mode the hook runs on the server host, and the first blocking request waits for configured and plugin-provided hooks to finish loading.

::: danger
The master `KIMI_CODE_EXPERIMENTAL_FLAG=true` switch enables this feature too. Once enabled, matching configured hooks—and hooks contributed by enabled plugins—have the authority to approve tools without a click. Review their code, command, matcher, and update source. Do not enable approval hooks from an untrusted plugin.
:::

## Event Reference
Expand All @@ -108,6 +158,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return
| `PostToolUse` | Tool name | — | Triggered after a tool executes successfully (observation only) |
| `PostToolUseFailure` | Tool name | — | Triggered after a tool fails or is blocked (observation only) |
| `PermissionRequest` | Tool name | — | Triggered just before waiting for user approval (observation only) |
| `PermissionDecisionRequest` | Tool name | Experimental | Can answer an ordinary tool approval when `permission-decision-hook` is enabled; otherwise the native approval flow is used |
| `PermissionResult` | Tool name | — | Triggered after approval completes (observation only) |
| `SessionStart` | `startup` or `resume` | — | Triggered after a new session starts or a previous session resumes; the payload includes `source`, `model`, and `profile` |
| `SessionEnd` | `exit` or `archive` | — | Triggered after a session closes; `archive` means the session was archived rather than exited |
Expand Down
55 changes: 53 additions & 2 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Hooks(钩子)是一种自动触发机制:你预先告诉 Kimi Code CLI"每
- **退出码**(exit code,程序结束时向操作系统报告的状态数字):`0` 表示放行,`2` 表示阻断,其他数字默认放行
- **标准输出**(stdout,就是你用 `console.log` 或 `print` 打印出来的内容):可以附带说明文字

即使脚本报错、超时,CLI 也**不会因此中断你的工作**——这种"出错就放行"的设计叫 fail-open(失败开放),避免 hook 异常变成绊脚石
对于现有的可阻断事件,即使脚本报错、超时,CLI 也不会因此中断工作。实验性的 `PermissionDecisionRequest` 更严格:结果缺失或不可信时,会退回 Kimi Code CLI 原生审批,而不是直接放行工具

::: warning 注意
正因为 fail-open,Hooks 适合做提醒和轻量拦截,但**不应作为唯一的安全防线**。对真正高风险的操作,仍需依赖权限审批和人工确认。
Expand Down Expand Up @@ -93,7 +93,57 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
```

::: info 哪些事件支持阻断?
只有**可阻断事件**(`PreToolUse`、`Stop`、`UserPromptSubmit`)的返回值会影响主流程。其余事件属于**观察型事件**——触发后即发即忘,不管脚本返回什么,主流程都不会改变。
现有的可阻断事件是 `PreToolUse`、`Stop` 和 `UserPromptSubmit`。实验性的 `PermissionDecisionRequest` 可以按下文的严格协议回答普通工具审批。其余事件属于**观察型事件**——触发后即发即忘,不管脚本返回什么,主流程都不会改变。
:::

## 实验功能:回答工具审批

`PermissionRequest` 仍然只是观察事件。要让 hook 回答普通工具审批,需要启用实验性的 `PermissionDecisionRequest`:

```toml
# ~/.kimi-code/config.toml
[experimental]
permission-decision-hook = true

[[hooks]]
event = "PermissionDecisionRequest"
matcher = "Bash"
command = "node ~/.kimi-code/hooks/approve-bash.mjs"
timeout = 5
```

也可以设置环境变量 `KIMI_CODE_EXPERIMENTAL_PERMISSION_DECISION_HOOK=true` 来启用。实验功能总开关 `KIMI_CODE_EXPERIMENTAL_FLAG=true` 也会同时启用本功能。

Hook 除了收到普通事件字段,还会收到 `permission_request_id`、`agent_id`、`turn_id`、`tool_call_id`、`tool_name`、`action`、`tool_input` 和 `display`。脚本必须把本次请求的 ID 原样写回结构化响应:

```js
// approve-bash.mjs
let input = '';
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
const payload = JSON.parse(input);
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
permissionRequestId: payload.permission_request_id,
permissionDecision: 'allow',
},
}));
});
```

要拒绝请求,请返回相同的 `permissionRequestId`、`permissionDecision: "deny"`,并可附带 `permissionDecisionReason`。退出码 `2` 也会拒绝当前请求,并把 stderr 作为原因。

判定规则有意设计得比较保守:

- 任意一条有效的拒绝结果都会生效。
- 只有所有匹配的 hook 都针对当前请求 ID 返回结构化放行时,工具才会被放行。
- 没有匹配 hook、请求 ID 不一致、输出格式错误、其他退出码、崩溃或超时,都视为没有 hook 决定,并打开原生审批流程。
- 取消信号仍然会取消审批,不会被转换成原生回退。

这个事件只处理普通工具审批。带自定义后续流程的审批(包括 Plan 模式审阅)始终走原生流程。main agent 和 subagent 使用相同规则。在 `kimi web` / kap-server 模式下,hook 在服务器所在机器上运行;第一次阻塞请求会等待配置和插件提供的 hooks 加载完成。

::: danger 警告
实验功能总开关 `KIMI_CODE_EXPERIMENTAL_FLAG=true` 也会启用本功能。启用后,匹配的本地配置 hook,以及已启用插件提供的 hook,都可能无需点击就批准工具。请审查它们的代码、命令、matcher 和更新来源;不要启用不可信插件提供的审批 hook。
:::

## 事件一览
Expand All @@ -108,6 +158,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
| `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) |
| `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) |
| `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) |
| `PermissionDecisionRequest` | 工具名 | 实验功能 | 启用 `permission-decision-hook` 后可回答普通工具审批;否则使用原生审批流程 |
| `PermissionResult` | 工具名 | — | 审批结束后触发(观察用) |
| `SessionStart` | `startup` 或 `resume` | — | 新会话启动或历史会话恢复后触发;payload 含 `source`、`model` 和 `profile` |
| `SessionEnd` | `exit` 或 `archive` | — | 会话关闭后触发;`archive` 表示会话被归档而非退出 |
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/config-manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ extra_skill_dirs = []

# one [[hooks]] table per entry:
# [[hooks]]
# event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "UserPromptQueued" | "TurnStarted" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SessionHeartbeat" | "SubagentStart" | "SubagentStop" | "TaskStarted" | "PreCompact" | "PostCompact" | "Notification"
# event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionDecisionRequest" | "PermissionResult" | "UserPromptSubmit" | "UserPromptQueued" | "TurnStarted" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SessionHeartbeat" | "SubagentStart" | "SubagentStop" | "TaskStarted" | "PreCompact" | "PostCompact" | "Notification"
# matcher: string
# command: string
# timeout: integer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,25 @@ import {
} from '#/agent/toolApproval/toolApprovalService';
import { IEventBus } from '#/app/event/eventBus';
import { Event2 } from '#/app/event/event2';
import { IFlagService } from '#/app/flag/flag';
import type { ExecutableToolResult } from '#/tool/toolContract';
import type { ResolvedToolExecutionHookContext, ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks';
import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import {
IAgentToolApprovalService,
type ToolApprovalRequestHookContext,
} from '#/agent/toolApproval/toolApproval';
import { toKimiErrorPayload } from '#/errors';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { IEventDispatcher } from '#/state/eventDispatcher';

import { IAgentExternalHooksService } from './externalHooks';
import { PERMISSION_DECISION_HOOK_FLAG_ID } from './flag';
import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner';
import type { HookMatcherValue } from './types';
import { reducePermissionDecisionResults } from './permissionDecision';
import type { HookMatcherValue, HookResult as ExternalHookResult } from './types';
import {
renderUserPromptHookBlockResult,
renderUserPromptHookResult,
Expand Down Expand Up @@ -77,6 +84,8 @@ export class AgentExternalHooksService extends Service implements IAgentExternal
@ISessionMetadata private readonly sessionMetadata: ISessionMetadata,
@IAgentStateService private readonly states: IAgentStateService,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService,
@IFlagService private readonly flags: IFlagService,
) {
super();
this.states.contributeState(externalHooksStopHookContinuationUsedKey);
Expand Down Expand Up @@ -174,6 +183,12 @@ export class AgentExternalHooksService extends Service implements IAgentExternal
}

private registerPermissionHooks(): void {
this._register(
this.toolApproval.hooks.onWillRequestApproval.register(
'externalHooks',
async (ctx, next) => this.runPermissionDecisionHook(ctx, next),
),
);
this._register(
this.eventBus.subscribe(PermissionApprovalRequested, (e) => {
const { type: _type, time: _time, ...inputData } = e;
Expand All @@ -188,6 +203,56 @@ export class AgentExternalHooksService extends Service implements IAgentExternal
);
}

private async runPermissionDecisionHook(
ctx: ToolApprovalRequestHookContext,
next: () => Promise<void>,
): Promise<void> {
if (!this.flags.enabled(PERMISSION_DECISION_HOOK_FLAG_ID)) {
await next();
return;
}

let results: ExternalHookResult[];
try {
ctx.signal.throwIfAborted();
results = await this.runner.trigger('PermissionDecisionRequest', {
matcherValue: ctx.request.toolName,
signal: ctx.signal,
sessionId: ctx.request.sessionId,
inputData: this.withSessionFacts({
permissionRequestId: ctx.request.permissionRequestId,
agentId: ctx.request.agentId,
turnId: ctx.request.turnId,
toolCallId: ctx.request.toolCallId,
toolName: ctx.request.toolName,
action: ctx.request.action,
toolInput: ctx.request.toolInput,
display: ctx.request.display,
}),
});
ctx.signal.throwIfAborted();
} catch {
ctx.signal.throwIfAborted();
await next();
return;
}

const decision = reducePermissionDecisionResults(
results,
ctx.request.permissionRequestId,
);
if (decision === undefined) {
await next();
return;
}

ctx.decisionSource = 'external_hook';
ctx.response =
decision.decision === 'allow'
? { decision: 'approved' }
: { decision: 'rejected', feedback: decision.reason };
}

private registerPromptHooks(prompt: IAgentPromptService): void {
this._register(
prompt.hooks.onBeforeSubmitPrompt.register('externalHooks', async (ctx, next) => {
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/src/agent/externalHooks/flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';

export const PERMISSION_DECISION_HOOK_FLAG_ID = 'permission-decision-hook';
export const PERMISSION_DECISION_HOOK_FLAG_ENV =
'KIMI_CODE_EXPERIMENTAL_PERMISSION_DECISION_HOOK';

export const permissionDecisionHookFlag: FlagDefinitionInput = {
id: PERMISSION_DECISION_HOOK_FLAG_ID,
title: 'Permission decision hook',
description:
'Let a blocking PermissionDecisionRequest hook allow or deny an ordinary tool approval before falling back to the native approval surface.',
env: PERMISSION_DECISION_HOOK_FLAG_ENV,
default: false,
surface: 'core',
};

registerFlagDefinition(permissionDecisionHookFlag);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { HookResult } from './types';

export type PermissionHookDecision =
| { readonly decision: 'allow' }
| { readonly decision: 'deny'; readonly reason?: string };

export function reducePermissionDecisionResults(
results: readonly HookResult[],
permissionRequestId: string,
): PermissionHookDecision | undefined {
const deny = results.find((result) => isExplicitDeny(result, permissionRequestId));
if (deny !== undefined) {
const reason = (deny.permissionDecisionReason ?? deny.reason)?.trim();
return {
decision: 'deny',
reason: reason === undefined || reason.length === 0 ? undefined : reason,
};
}

if (results.length === 0) return undefined;
return results.every((result) => isAllowForRequest(result, permissionRequestId))
? { decision: 'allow' }
: undefined;
}

function isExplicitDeny(result: HookResult, permissionRequestId: string): boolean {
if (result.exitCode === 2) return true;
return (
result.exitCode === 0 &&
result.structuredOutput === true &&
result.permissionRequestId === permissionRequestId &&
result.permissionDecision === 'deny'
);
}

function isAllowForRequest(result: HookResult, permissionRequestId: string): boolean {
return (
result.exitCode === 0 &&
result.structuredOutput === true &&
result.permissionRequestId === permissionRequestId &&
result.permissionDecision === 'allow'
);
}
Loading