Skip to content

Commit 7cda9c3

Browse files
authored
feat: add permission approval hooks (#336)
1 parent 80164c2 commit 7cda9c3

6 files changed

Lines changed: 132 additions & 3 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": minor
3+
"@moonshot-ai/kimi-code": minor
4+
---
5+
6+
Add approval lifecycle hook events for observing pending and completed permission prompts.

docs/en/customization/hooks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ If stdout is JSON and `hookSpecificOutput.permissionDecision` is `deny`, the res
7474
}
7575
```
7676

77-
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.
77+
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.
7878

7979
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.
8080

@@ -88,6 +88,8 @@ The following events are triggered automatically today:
8888
| `PreToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id` | Fires before permission checks. If blocked, the tool does not run |
8989
| `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 |
9090
| `PostToolUseFailure` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `error` | Fires after a tool call fails or is blocked by a hook |
91+
| `PermissionRequest` | Tool name | `turn_id`, `tool_call_id`, `tool_name`, `action`, `tool_input`, `display` | Fires asynchronously immediately before the CLI waits for user approval |
92+
| `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 |
9193
| `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 |
9294
| `StopFailure` | Error type | `error_type`, `error_message` | Fires after the current turn fails with a non-cancellation error |
9395
| `SessionStart` | `startup` or `resume` | `source` | Fires after the main agent is created for a new session, or after a historical session is resumed |

docs/zh/customization/hooks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Hook 命令的退出码和 stdout 会被解释为以下结果:
7474
}
7575
```
7676

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

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

@@ -88,6 +88,8 @@ Hook 命令的退出码和 stdout 会被解释为以下结果:
8888
| `PreToolUse` | 工具名 | `tool_name``tool_input``tool_call_id` | 在权限检查前触发;阻断后工具不会执行 |
8989
| `PostToolUse` | 工具名 | `tool_name``tool_input``tool_call_id``tool_output` | 工具成功后触发;`tool_output` 被截断至前 2000 个字符 |
9090
| `PostToolUseFailure` | 工具名 | `tool_name``tool_input``tool_call_id``error` | 工具失败或被 hook 阻断后触发 |
91+
| `PermissionRequest` | 工具名 | `turn_id``tool_call_id``tool_name``action``tool_input``display` | CLI 即将等待用户审批前异步触发 |
92+
| `PermissionResult` | 工具名 | `turn_id``tool_call_id``tool_name``action``decision``scope``feedback``selected_label``error` | 审批请求结束或失败后异步触发 |
9193
| `Stop` | 空字符串 | `stop_hook_active` | 模型准备停止时触发;阻断后会把原因直接作为系统触发的 User 消息追加进上下文,并最多继续一次 |
9294
| `StopFailure` | 错误类型 | `error_type``error_message` | 当前轮次因非取消错误失败后触发 |
9395
| `SessionStart` | `startup``resume` | `source` | 新会话主 Agent 创建后,或历史会话恢复完成后触发 |

packages/agent-core/src/agent/permission/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,20 @@ export class PermissionManager {
131131
const startedAt = Date.now();
132132

133133
let response: ApprovalResponse;
134+
let requestedApproval = false;
134135
if (this.agent.rpc?.requestApproval) {
136+
requestedApproval = true;
137+
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionRequest', {
138+
matcherValue: name,
139+
inputData: {
140+
turnId: Number(context.turnId),
141+
toolCallId: id,
142+
toolName: name,
143+
action,
144+
toolInput: context.args,
145+
display,
146+
},
147+
});
135148
try {
136149
response = await this.agent.rpc.requestApproval(
137150
{
@@ -154,6 +167,17 @@ export class PermissionManager {
154167
session_cache_written: false,
155168
has_feedback: false,
156169
});
170+
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionResult', {
171+
matcherValue: name,
172+
inputData: {
173+
turnId: Number(context.turnId),
174+
toolCallId: id,
175+
toolName: name,
176+
action,
177+
decision: 'error',
178+
error: error instanceof Error ? error.message : String(error),
179+
},
180+
});
157181
const resolved = result.resolveError?.(error);
158182
return resolved === undefined
159183
? Promise.reject(error)
@@ -170,6 +194,22 @@ export class PermissionManager {
170194
? context.execution.approvalRule
171195
: undefined;
172196

197+
if (requestedApproval) {
198+
void this.agent.hooks?.fireAndForgetTrigger?.('PermissionResult', {
199+
matcherValue: name,
200+
inputData: {
201+
turnId: Number(context.turnId),
202+
toolCallId: id,
203+
toolName: name,
204+
action,
205+
decision: response.decision,
206+
scope: response.scope,
207+
feedback: response.feedback,
208+
selectedLabel: response.selectedLabel,
209+
},
210+
});
211+
}
212+
173213
this.recordApprovalResult({
174214
turnId: Number(context.turnId),
175215
toolCallId: id,

packages/agent-core/src/session/hooks/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export const HOOK_EVENT_TYPES = [
44
'PreToolUse',
55
'PostToolUse',
66
'PostToolUseFailure',
7+
'PermissionRequest',
8+
'PermissionResult',
79
'UserPromptSubmit',
810
'Stop',
911
'StopFailure',

packages/agent-core/test/agent/permission.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2293,6 +2293,82 @@ describe('Agent-local approve for session', () => {
22932293
});
22942294

22952295
describe('Approval telemetry', () => {
2296+
it('fires observer hooks while waiting for user approval', async () => {
2297+
const triggerBlock = vi.fn(async () => undefined);
2298+
const fireAndForgetTrigger = vi.fn(async () => []);
2299+
const { manager, requestApproval } = makePermissionManager(
2300+
async () => {
2301+
expect(fireAndForgetTrigger).toHaveBeenCalledWith('PermissionRequest', {
2302+
matcherValue: 'Bash',
2303+
inputData: {
2304+
turnId: 0,
2305+
toolCallId: 'call_approval_hooks',
2306+
toolName: 'Bash',
2307+
action: 'run command',
2308+
toolInput: { command: 'printf first', timeout: 60 },
2309+
display: expect.objectContaining({ kind: 'command' }),
2310+
},
2311+
});
2312+
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
2313+
'PermissionResult',
2314+
expect.anything(),
2315+
);
2316+
return {
2317+
decision: 'approved',
2318+
selectedLabel: 'Approve once',
2319+
};
2320+
},
2321+
{
2322+
hooks: { triggerBlock, fireAndForgetTrigger } as unknown as Agent['hooks'],
2323+
},
2324+
);
2325+
2326+
await expect(manager.beforeToolCall(hookContext({ id: 'call_approval_hooks' }))).resolves
2327+
.toBeUndefined();
2328+
2329+
expect(requestApproval).toHaveBeenCalledTimes(1);
2330+
expect(fireAndForgetTrigger).toHaveBeenCalledWith('PermissionResult', {
2331+
matcherValue: 'Bash',
2332+
inputData: {
2333+
turnId: 0,
2334+
toolCallId: 'call_approval_hooks',
2335+
toolName: 'Bash',
2336+
action: 'run command',
2337+
decision: 'approved',
2338+
selectedLabel: 'Approve once',
2339+
scope: undefined,
2340+
feedback: undefined,
2341+
},
2342+
});
2343+
});
2344+
2345+
it('does not fire approval observer hooks without an approval request', async () => {
2346+
const triggerBlock = vi.fn(async () => undefined);
2347+
const fireAndForgetTrigger = vi.fn(async () => []);
2348+
const { manager, requestApproval } = makePermissionManager(
2349+
async () => ({
2350+
decision: 'approved',
2351+
}),
2352+
{
2353+
approvalRpc: false,
2354+
hooks: { triggerBlock, fireAndForgetTrigger } as unknown as Agent['hooks'],
2355+
},
2356+
);
2357+
2358+
await expect(manager.beforeToolCall(hookContext({ id: 'call_no_approval_rpc' }))).resolves
2359+
.toBeUndefined();
2360+
2361+
expect(requestApproval).not.toHaveBeenCalled();
2362+
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
2363+
'PermissionRequest',
2364+
expect.anything(),
2365+
);
2366+
expect(fireAndForgetTrigger).not.toHaveBeenCalledWith(
2367+
'PermissionResult',
2368+
expect.anything(),
2369+
);
2370+
});
2371+
22962372
it('tracks cancelled approval requests', async () => {
22972373
const { manager, telemetryTrack } = makePermissionManager(async () => ({
22982374
decision: 'cancelled',
@@ -3562,6 +3638,7 @@ function makePermissionManager(
35623638
readonly cwd?: string;
35633639
readonly agentType?: Agent['type'];
35643640
readonly hooks?: Agent['hooks'];
3641+
readonly approvalRpc?: boolean;
35653642
} = {},
35663643
): {
35673644
manager: PermissionManager;
@@ -3580,7 +3657,7 @@ function makePermissionManager(
35803657
emitStatusUpdated: vi.fn(),
35813658
records: { logRecord: record },
35823659
replayBuilder: { push: vi.fn() },
3583-
rpc: { requestApproval },
3660+
rpc: options.approvalRpc === false ? undefined : { requestApproval },
35843661
hooks: options.hooks,
35853662
telemetry: { track: telemetryTrack },
35863663
planMode: {

0 commit comments

Comments
 (0)