From ade0c0738fba15fa1183f6d6a676b0b57e70a9e7 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 2 Jun 2026 19:33:58 +0800 Subject: [PATCH] feat: add permission approval hooks --- .changeset/permission-approval-hooks.md | 6 ++ docs/en/customization/hooks.md | 4 +- docs/zh/customization/hooks.md | 4 +- .../agent-core/src/agent/permission/index.ts | 40 ++++++++++ .../agent-core/src/session/hooks/types.ts | 2 + .../agent-core/test/agent/permission.test.ts | 79 ++++++++++++++++++- 6 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 .changeset/permission-approval-hooks.md diff --git a/.changeset/permission-approval-hooks.md b/.changeset/permission-approval-hooks.md new file mode 100644 index 0000000000..097955af57 --- /dev/null +++ b/.changeset/permission-approval-hooks.md @@ -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. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index b3fbffe243..f2d68309ac 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -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 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. @@ -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 | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index 8a15c1122e..6979ab87ba 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -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 hook` 作为占位原因。`PreToolUse` 阻断会作为工具失败结果写回上下文,模型可以根据原因选择替代方案。 @@ -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 创建后,或历史会话恢复完成后触发 | diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts index ddd51d5298..d73df03402 100644 --- a/packages/agent-core/src/agent/permission/index.ts +++ b/packages/agent-core/src/agent/permission/index.ts @@ -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( { @@ -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) @@ -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, diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts index f1ee9ce2be..c8c1d95665 100644 --- a/packages/agent-core/src/session/hooks/types.ts +++ b/packages/agent-core/src/session/hooks/types.ts @@ -4,6 +4,8 @@ export const HOOK_EVENT_TYPES = [ 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', + 'PermissionRequest', + 'PermissionResult', 'UserPromptSubmit', 'Stop', 'StopFailure', diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 88e63ad9b5..6443cb0ccd 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -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', @@ -3562,6 +3638,7 @@ function makePermissionManager( readonly cwd?: string; readonly agentType?: Agent['type']; readonly hooks?: Agent['hooks']; + readonly approvalRpc?: boolean; } = {}, ): { manager: PermissionManager; @@ -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: {