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
6 changes: 6 additions & 0 deletions .changeset/permission-approval-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---

Add approval lifecycle hook events for observing pending and completed permission prompts.
4 changes: 3 additions & 1 deletion docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ If stdout is JSON and `hookSpecificOutput.permissionDecision` is `deny`, the res
}
```

Blocking only applies to events that participate in control flow. For example, `PreToolUse` can block a tool call, and `Stop` can append one continuation message to the current turn. Observer events (such as `PostToolUse`, `PostToolUseFailure`, `PostCompact`, `SubagentStop`, `StopFailure`, and `Notification`) are dispatched asynchronously in a fire-and-forget fashion; their return values are ignored and do not change the main flow. `PreCompact` is invoked with `trigger` (not `triggerBlock`); its return value is likewise completely ignored, and it is not a blockable event.
Blocking only applies to events that participate in control flow. For example, `PreToolUse` can block a tool call, and `Stop` can append one continuation message to the current turn. Observer events (such as `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `PostCompact`, `SubagentStop`, `StopFailure`, and `Notification`) are dispatched asynchronously in a fire-and-forget fashion; their return values are ignored and do not change the main flow. `PreCompact` is invoked with `trigger` (not `triggerBlock`); its return value is likewise completely ignored, and it is not a blockable event.

When a block takes effect, if the script does not provide a reason through stderr or JSON output, the CLI falls back to `Blocked by <event> hook` as a placeholder reason. A `PreToolUse` block is written back into context as a failed tool result, so the model can choose an alternative based on the reason.

Expand All @@ -88,6 +88,8 @@ The following events are triggered automatically today:
| `PreToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id` | Fires before permission checks. If blocked, the tool does not run |
| `PostToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `tool_output` | Fires after a successful tool call. `tool_output` is truncated to the first 2000 characters |
| `PostToolUseFailure` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `error` | Fires after a tool call fails or is blocked by a hook |
| `PermissionRequest` | Tool name | `turn_id`, `tool_call_id`, `tool_name`, `action`, `tool_input`, `display` | Fires asynchronously immediately before the CLI waits for user approval |
| `PermissionResult` | Tool name | `turn_id`, `tool_call_id`, `tool_name`, `action`, `decision`, `scope`, `feedback`, `selected_label`, `error` | Fires asynchronously after the approval request resolves or fails |
| `Stop` | Empty string | `stop_hook_active` | Fires when the model is about to stop. If blocked, the reason is appended directly to context as a system-triggered user message, and the turn may continue once |
| `StopFailure` | Error type | `error_type`, `error_message` | Fires after the current turn fails with a non-cancellation error |
| `SessionStart` | `startup` or `resume` | `source` | Fires after the main agent is created for a new session, or after a historical session is resumed |
Expand Down
4 changes: 3 additions & 1 deletion docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Hook 命令的退出码和 stdout 会被解释为以下结果:
}
```

阻断只对支持控制流的事件生效。例如 `PreToolUse` 可以阻断工具调用,`Stop` 可以让当前轮次追加一次继续消息。观察型事件(例如 `PostToolUse`、`PostToolUseFailure`、`PostCompact`、`SubagentStop`、`StopFailure`、`Notification`)以「即发即忘(fire-and-forget)」方式异步触发,返回值被忽略,不会改变主流程。`PreCompact` 使用 `trigger`(而非 `triggerBlock`)调用,返回值同样被完全忽略,不属于可阻断事件。
阻断只对支持控制流的事件生效。例如 `PreToolUse` 可以阻断工具调用,`Stop` 可以让当前轮次追加一次继续消息。观察型事件(例如 `PostToolUse`、`PostToolUseFailure`、`PermissionRequest`、`PermissionResult`、`PostCompact`、`SubagentStop`、`StopFailure`、`Notification`)以「即发即忘(fire-and-forget)」方式异步触发,返回值被忽略,不会改变主流程。`PreCompact` 使用 `trigger`(而非 `triggerBlock`)调用,返回值同样被完全忽略,不属于可阻断事件。

阻断生效时,如果脚本未通过 stderr 或 JSON 输出提供原因,CLI 会回退到 `Blocked by <event> hook` 作为占位原因。`PreToolUse` 阻断会作为工具失败结果写回上下文,模型可以根据原因选择替代方案。

Expand All @@ -88,6 +88,8 @@ Hook 命令的退出码和 stdout 会被解释为以下结果:
| `PreToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id` | 在权限检查前触发;阻断后工具不会执行 |
| `PostToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`tool_output` | 工具成功后触发;`tool_output` 被截断至前 2000 个字符 |
| `PostToolUseFailure` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`error` | 工具失败或被 hook 阻断后触发 |
| `PermissionRequest` | 工具名 | `turn_id`、`tool_call_id`、`tool_name`、`action`、`tool_input`、`display` | CLI 即将等待用户审批前异步触发 |
| `PermissionResult` | 工具名 | `turn_id`、`tool_call_id`、`tool_name`、`action`、`decision`、`scope`、`feedback`、`selected_label`、`error` | 审批请求结束或失败后异步触发 |
| `Stop` | 空字符串 | `stop_hook_active` | 模型准备停止时触发;阻断后会把原因直接作为系统触发的 User 消息追加进上下文,并最多继续一次 |
| `StopFailure` | 错误类型 | `error_type`、`error_message` | 当前轮次因非取消错误失败后触发 |
| `SessionStart` | `startup` 或 `resume` | `source` | 新会话主 Agent 创建后,或历史会话恢复完成后触发 |
Expand Down
40 changes: 40 additions & 0 deletions packages/agent-core/src/agent/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,20 @@ export class PermissionManager {
const startedAt = Date.now();

let response: ApprovalResponse;
let requestedApproval = false;
if (this.agent.rpc?.requestApproval) {
requestedApproval = true;
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionRequest', {
matcherValue: name,
inputData: {
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
toolInput: context.args,
display,
},
});
try {
response = await this.agent.rpc.requestApproval(
{
Expand All @@ -154,6 +167,17 @@ export class PermissionManager {
session_cache_written: false,
has_feedback: false,
});
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionResult', {
matcherValue: name,
inputData: {
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
decision: 'error',
error: error instanceof Error ? error.message : String(error),
},
});
const resolved = result.resolveError?.(error);
return resolved === undefined
? Promise.reject(error)
Expand All @@ -170,6 +194,22 @@ export class PermissionManager {
? context.execution.approvalRule
: undefined;

if (requestedApproval) {
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionResult', {
matcherValue: name,
inputData: {
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
decision: response.decision,
scope: response.scope,
feedback: response.feedback,
selectedLabel: response.selectedLabel,
},
});
}

this.recordApprovalResult({
turnId: Number(context.turnId),
toolCallId: id,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/session/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const HOOK_EVENT_TYPES = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest',
'PermissionResult',
'UserPromptSubmit',
'Stop',
'StopFailure',
Expand Down
79 changes: 78 additions & 1 deletion packages/agent-core/test/agent/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2293,6 +2293,82 @@ describe('Agent-local approve for session', () => {
});

describe('Approval telemetry', () => {
it('fires observer hooks while waiting for user approval', async () => {
const triggerBlock = vi.fn(async () => undefined);
const fireAndForgetTrigger = vi.fn(async () => []);
const { manager, requestApproval } = makePermissionManager(
async () => {
expect(fireAndForgetTrigger).toHaveBeenCalledWith('PermissionRequest', {
matcherValue: 'Bash',
inputData: {
turnId: 0,
toolCallId: 'call_approval_hooks',
toolName: 'Bash',
action: 'run command',
toolInput: { command: 'printf first', timeout: 60 },
display: expect.objectContaining({ kind: 'command' }),
},
});
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
'PermissionResult',
expect.anything(),
);
return {
decision: 'approved',
selectedLabel: 'Approve once',
};
},
{
hooks: { triggerBlock, fireAndForgetTrigger } as unknown as Agent['hooks'],
},
);

await expect(manager.beforeToolCall(hookContext({ id: 'call_approval_hooks' }))).resolves
.toBeUndefined();

expect(requestApproval).toHaveBeenCalledTimes(1);
expect(fireAndForgetTrigger).toHaveBeenCalledWith('PermissionResult', {
matcherValue: 'Bash',
inputData: {
turnId: 0,
toolCallId: 'call_approval_hooks',
toolName: 'Bash',
action: 'run command',
decision: 'approved',
selectedLabel: 'Approve once',
scope: undefined,
feedback: undefined,
},
});
});

it('does not fire approval observer hooks without an approval request', async () => {
const triggerBlock = vi.fn(async () => undefined);
const fireAndForgetTrigger = vi.fn(async () => []);
const { manager, requestApproval } = makePermissionManager(
async () => ({
decision: 'approved',
}),
{
approvalRpc: false,
hooks: { triggerBlock, fireAndForgetTrigger } as unknown as Agent['hooks'],
},
);

await expect(manager.beforeToolCall(hookContext({ id: 'call_no_approval_rpc' }))).resolves
.toBeUndefined();

expect(requestApproval).not.toHaveBeenCalled();
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
'PermissionRequest',
expect.anything(),
);
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
'PermissionResult',
expect.anything(),
);
});

it('tracks cancelled approval requests', async () => {
const { manager, telemetryTrack } = makePermissionManager(async () => ({
decision: 'cancelled',
Expand Down Expand Up @@ -3562,6 +3638,7 @@ function makePermissionManager(
readonly cwd?: string;
readonly agentType?: Agent['type'];
readonly hooks?: Agent['hooks'];
readonly approvalRpc?: boolean;
} = {},
): {
manager: PermissionManager;
Expand All @@ -3580,7 +3657,7 @@ function makePermissionManager(
emitStatusUpdated: vi.fn(),
records: { logRecord: record },
replayBuilder: { push: vi.fn() },
rpc: { requestApproval },
rpc: options.approvalRpc === false ? undefined : { requestApproval },
hooks: options.hooks,
telemetry: { track: telemetryTrack },
planMode: {
Expand Down
Loading