diff --git a/.changeset/permission-decision-hooks.md b/.changeset/permission-decision-hooks.md new file mode 100644 index 0000000000..44c5415785 --- /dev/null +++ b/.changeset/permission-decision-hooks.md @@ -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. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 680b1b9817..666b7ec877 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -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. @@ -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 @@ -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 | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index b23ec91431..c88b8c2423 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -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 适合做提醒和轻量拦截,但**不应作为唯一的安全防线**。对真正高风险的操作,仍需依赖权限审批和人工确认。 @@ -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。 ::: ## 事件一览 @@ -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` 表示会话被归档而非退出 | diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 99b6a14c4b..08a2f2d293 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -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 diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index 95a2bedf6e..8fda94cfed 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -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, @@ -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); @@ -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; @@ -188,6 +203,56 @@ export class AgentExternalHooksService extends Service implements IAgentExternal ); } + private async runPermissionDecisionHook( + ctx: ToolApprovalRequestHookContext, + next: () => Promise, + ): Promise { + 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) => { diff --git a/packages/agent-core-v2/src/agent/externalHooks/flag.ts b/packages/agent-core-v2/src/agent/externalHooks/flag.ts new file mode 100644 index 0000000000..3aa7475402 --- /dev/null +++ b/packages/agent-core-v2/src/agent/externalHooks/flag.ts @@ -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); diff --git a/packages/agent-core-v2/src/agent/externalHooks/permissionDecision.ts b/packages/agent-core-v2/src/agent/externalHooks/permissionDecision.ts new file mode 100644 index 0000000000..e298292ee2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/externalHooks/permissionDecision.ts @@ -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' + ); +} diff --git a/packages/agent-core-v2/src/agent/externalHooks/runner.ts b/packages/agent-core-v2/src/agent/externalHooks/runner.ts index c25de9fa61..7ba39e5e43 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/runner.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/runner.ts @@ -46,6 +46,7 @@ const HookSpecificOutputSchema = z.preprocess( .looseObject({ message: OptionalStringSchema, permissionDecision: z.unknown().optional(), + permissionRequestId: OptionalStringSchema, permissionDecisionReason: z.unknown().optional(), }) .optional(), @@ -147,6 +148,8 @@ function resultFromExitCode(exitCode: number, stdout: string, stderr: string): H stdout, stderr, exitCode, + permissionDecision: 'deny', + permissionDecisionReason: message, }; } @@ -160,6 +163,9 @@ function resultFromExitCode(exitCode: number, stdout: string, stderr: string): H stderr, exitCode, structuredOutput: structured.structuredOutput, + permissionDecision: structured.permissionDecision, + permissionRequestId: structured.permissionRequestId, + permissionDecisionReason: structured.permissionDecisionReason, }; } @@ -169,12 +175,25 @@ function resultFromExitCode(exitCode: number, stdout: string, stderr: string): H stderr, exitCode, structuredOutput: structured?.structuredOutput, + permissionDecision: structured?.permissionDecision, + permissionRequestId: structured?.permissionRequestId, + permissionDecisionReason: structured?.permissionDecisionReason, }); } function structuredOutput( stdout: string, -): { action?: 'block'; reason?: string; message?: string; structuredOutput: true } | undefined { +): + | { + action?: 'block'; + reason?: string; + message?: string; + structuredOutput: true; + permissionDecision?: 'allow' | 'deny'; + permissionRequestId?: string; + permissionDecisionReason?: string; + } + | undefined { const text = stdout.trim(); if (text.length === 0) return undefined; @@ -184,20 +203,31 @@ function structuredOutput( if (!output.success) return undefined; const { message, hookSpecificOutput } = output.data; + const permissionDecision: HookResult['permissionDecision'] = + hookSpecificOutput?.permissionDecision === 'allow' || + hookSpecificOutput?.permissionDecision === 'deny' + ? hookSpecificOutput.permissionDecision + : undefined; const result = { message: message ?? hookSpecificOutput?.message, structuredOutput: true as const, + permissionDecision, + permissionRequestId: hookSpecificOutput?.permissionRequestId, + permissionDecisionReason: + typeof hookSpecificOutput?.permissionDecisionReason === 'string' + ? hookSpecificOutput.permissionDecisionReason + : undefined, }; - if (hookSpecificOutput?.permissionDecision !== 'deny') { + if (permissionDecision !== 'deny') { return result; } return { action: 'block', message: result.message, - reason: - typeof hookSpecificOutput.permissionDecisionReason === 'string' - ? hookSpecificOutput.permissionDecisionReason - : undefined, + reason: result.permissionDecisionReason, + permissionDecision, + permissionRequestId: result.permissionRequestId, + permissionDecisionReason: result.permissionDecisionReason, structuredOutput: true as const, }; } catch { @@ -212,6 +242,9 @@ function allowResult(input: { readonly exitCode?: number; readonly timedOut?: boolean; readonly structuredOutput?: boolean; + readonly permissionDecision?: 'allow' | 'deny'; + readonly permissionRequestId?: string; + readonly permissionDecisionReason?: string; }): HookResult { return { action: 'allow', @@ -221,6 +254,9 @@ function allowResult(input: { exitCode: input.exitCode, timedOut: input.timedOut, structuredOutput: input.structuredOutput, + permissionDecision: input.permissionDecision, + permissionRequestId: input.permissionRequestId, + permissionDecisionReason: input.permissionDecisionReason, }; } diff --git a/packages/agent-core-v2/src/agent/externalHooks/types.ts b/packages/agent-core-v2/src/agent/externalHooks/types.ts index 1cab03130b..57e35765e2 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/types.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/types.ts @@ -5,6 +5,7 @@ export const HOOK_EVENT_TYPES = [ 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', + 'PermissionDecisionRequest', 'PermissionResult', 'UserPromptSubmit', 'UserPromptQueued', @@ -43,6 +44,9 @@ export interface HookResult { readonly exitCode?: number; readonly timedOut?: boolean; readonly structuredOutput?: boolean; + readonly permissionDecision?: 'allow' | 'deny'; + readonly permissionRequestId?: string; + readonly permissionDecisionReason?: string; } export interface HookBlockDecision { diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts index 7caf10be5d..5c52b634c9 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts @@ -1,5 +1,7 @@ import { createDecorator } from '#/_base/di/instantiation'; +import type { OrderedHookSlot } from '#/hooks'; import type { + ApprovalRequest, ApprovalResponse, PermissionPolicyResolution, PermissionPolicyResult, @@ -9,8 +11,33 @@ import type { ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; +export type PermissionDecisionSource = 'native' | 'external_hook' | 'implicit_no_broker'; + +export type PermissionApprovalRequestContext = ApprovalRequest & { + readonly sessionId?: string; + readonly agentId?: string; + readonly turnId: number; + readonly toolInput: unknown; + readonly permissionRequestId?: string; +}; + +export type PermissionDecisionRequestContext = PermissionApprovalRequestContext & { + readonly id: string; + readonly permissionRequestId: string; +}; + +export interface ToolApprovalRequestHookContext { + readonly request: PermissionDecisionRequestContext; + readonly signal: AbortSignal; + response?: ApprovalResponse; + decisionSource: PermissionDecisionSource; +} + export interface IAgentToolApprovalService { readonly _serviceBrand: undefined; + readonly hooks: { + readonly onWillRequestApproval: OrderedHookSlot; + }; resolvePermissionResolution( result: PermissionPolicyResolution, @@ -29,6 +56,7 @@ export interface IAgentToolApprovalService { formatApprovalRejectionMessage( toolName: string, result: Pick, + source?: PermissionDecisionSource, ): string; } diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 1e4884ed76..26d4330dc1 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -3,11 +3,13 @@ import { randomUUID } from 'node:crypto'; import { IInstantiationService } from '#/_base/di/instantiation'; import { Service } from '#/_base/di/service'; +import { OrderedHookSlot } from '#/hooks'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { abortable, isUserCancellation } from '#/_base/utils/abort'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { + ApprovalRequest, ApprovalResponse, PermissionPolicyResolution, PermissionPolicyResult, @@ -26,19 +28,15 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import { IAgentToolApprovalService } from './toolApproval'; +import { + IAgentToolApprovalService, + type PermissionApprovalRequestContext, + type PermissionDecisionRequestContext, + type PermissionDecisionSource, + type ToolApprovalRequestHookContext, +} from './toolApproval'; -export interface PermissionApprovalRequestedPayload { - readonly id?: string; - readonly sessionId?: string; - readonly agentId?: string; - readonly turnId: number; - readonly toolCallId: string; - readonly toolName: string; - readonly action: string; - readonly display: ToolInputDisplay; - readonly toolInput: unknown; -} +export interface PermissionApprovalRequestedPayload extends PermissionApprovalRequestContext {} export class PermissionApprovalRequested extends Event2 { static override readonly type = 'permission.approval.requested'; @@ -52,6 +50,7 @@ export interface PermissionApprovalResolvedPayload extends PermissionApprovalReq readonly feedback?: string; readonly selectedLabel?: string; readonly error?: string; + readonly decisionSource: PermissionDecisionSource; } export class PermissionApprovalResolved extends Event2 { @@ -63,6 +62,10 @@ export interface PermissionApprovalResolved extends PermissionApprovalResolvedPa export class AgentToolApprovalService extends Service implements IAgentToolApprovalService { declare readonly _serviceBrand: undefined; + readonly hooks = { + onWillRequestApproval: new OrderedHookSlot(), + }; + constructor( @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, @@ -126,21 +129,37 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro }; const approvalContext = { ...approvalRequest, + permissionRequestId: approvalRequest.id, toolInput: context.args, - } satisfies PermissionApprovalRequestedPayload; + } satisfies PermissionDecisionRequestContext; const startedAt = Date.now(); let response: ApprovalResponse; + let decisionSource: PermissionDecisionSource = 'native'; const approvalService = this.tryApprovalService(); if (approvalService === undefined) { + decisionSource = 'implicit_no_broker'; response = { decision: 'approved' }; } else { void this.dispatcher.dispatch(new PermissionApprovalRequested(approvalContext)); try { - response = await abortable( - approvalService.request(approvalRequest), - context.signal, - ); + const resultWithSource = + result.resolveApproval === undefined + ? await this.requestWithParticipants( + approvalService, + approvalRequest, + approvalContext, + context.signal, + ) + : { + response: await abortable( + approvalService.request(approvalRequest), + context.signal, + ), + decisionSource: 'native' as const, + }; + response = resultWithSource.response; + decisionSource = resultWithSource.decisionSource; context.signal.throwIfAborted(); } catch (error) { if (isUserCancellation(error)) throw error; @@ -156,12 +175,14 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro session_cache_written: false, has_feedback: false, trace_id: context.trace?.traceId, + decision_source: decisionSource, }); void this.dispatcher.dispatch( new PermissionApprovalResolved({ ...approvalContext, decision: 'error', error: error instanceof Error ? error.message : String(error), + decisionSource, }), ); const resolved = result.resolveError?.(error); @@ -181,6 +202,7 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro new PermissionApprovalResolved({ ...approvalContext, ...response, + decisionSource, }), ); } @@ -207,6 +229,7 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro session_cache_written: sessionApprovalRule !== undefined, has_feedback: response.feedback !== undefined && response.feedback.length > 0, trace_id: context.trace?.traceId, + decision_source: decisionSource, }); const resolved = result.resolveApproval?.(response); @@ -216,13 +239,55 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro if (response.decision === 'approved') return undefined; return { - veto: denyToolExecution(this.formatApprovalRejectionMessage(name, response)), + veto: denyToolExecution( + this.formatApprovalRejectionMessage(name, response, decisionSource), + ), + }; + } + + private async requestWithParticipants( + approvalService: ISessionApprovalService, + approvalRequest: ApprovalRequest, + approvalContext: PermissionDecisionRequestContext, + signal: AbortSignal, + ): Promise<{ readonly response: ApprovalResponse; readonly decisionSource: PermissionDecisionSource }> { + const hookContext: ToolApprovalRequestHookContext = { + request: approvalContext, + signal, + decisionSource: 'native', + }; + let nativeApprovalPromise: Promise | undefined; + const requestNativeApproval = (requestSignal: AbortSignal): Promise => { + nativeApprovalPromise ??= approvalService.request(approvalRequest); + return abortable(nativeApprovalPromise, requestSignal); + }; + + try { + await this.hooks.onWillRequestApproval.run(hookContext, async (current) => { + try { + current.response = await requestNativeApproval(current.signal); + current.decisionSource = 'native'; + } catch {} + }); + } catch { + signal.throwIfAborted(); + } + + signal.throwIfAborted(); + if (hookContext.response === undefined) { + hookContext.response = await requestNativeApproval(signal); + hookContext.decisionSource = 'native'; + } + return { + response: hookContext.response, + decisionSource: hookContext.decisionSource, }; } formatApprovalRejectionMessage( toolName: string, result: Pick, + source: PermissionDecisionSource = 'native', ): string { const suffix = result.feedback !== undefined && result.feedback.length > 0 @@ -231,7 +296,9 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro const prefix = result.decision === 'cancelled' ? `Tool "${toolName}" was not run because the approval request was cancelled.` - : `Tool "${toolName}" was not run because the user rejected the approval request.`; + : source === 'external_hook' + ? `Tool "${toolName}" was not run because an external approval hook rejected the approval request.` + : `Tool "${toolName}" was not run because the user rejected the approval request.`; if (this.usesWorkerRejectionGuidance()) { return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 61ba9c1dbf..86133912aa 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -159,6 +159,7 @@ export interface PermissionApprovalResultEvent { session_cache_written: boolean; has_feedback: boolean; trace_id?: string; + decision_source: 'native' | 'external_hook' | 'implicit_no_broker'; } export interface PlanSubmittedEvent { @@ -586,6 +587,8 @@ export const telemetryEventDefinitions = { has_feedback: 'Whether the user attached feedback', trace_id: 'Trace id of the LLM request that produced the gated tool call; absent for non-Kimi protocols', + decision_source: + 'Whether the native approval broker, an external hook, or the no-broker fallback decided the request', }, }), plan_submitted: defineAgentTelemetryEvent({ diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 19f3729bae..13ac16a5d9 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -610,6 +610,7 @@ export * from '#/agent/plugin/agentPlugin'; export * from '#/agent/plugin/agentPluginOps'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; +import '#/agent/externalHooks/flag'; export * from '#/agent/externalHooks/externalHooks'; export * from '#/agent/externalHooks/externalHooksService'; export * from '#/agent/fullCompaction/strategy'; diff --git a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts index 495507bebf..2e50bafef5 100644 --- a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts +++ b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts @@ -233,6 +233,7 @@ describe('AgentActivityView', () => { bus.publish( new PermissionApprovalRequested({ id: 'approval_1', + permissionRequestId: 'approval_1', sessionId: 's', agentId: 'main', turnId: 1, @@ -250,6 +251,7 @@ describe('AgentActivityView', () => { bus.publish( new PermissionApprovalResolved({ id: 'approval_1', + permissionRequestId: 'approval_1', sessionId: 's', agentId: 'main', turnId: 1, @@ -259,6 +261,7 @@ describe('AgentActivityView', () => { toolInput: {}, display: { kind: 'command', command: 'ls' }, decision: 'approved', + decisionSource: 'native', }), ); expect(view.state().turn?.pendingApprovals).toEqual([]); diff --git a/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts b/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts index 853ff1076f..475a4f4ebc 100644 --- a/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts +++ b/packages/agent-core-v2/test/agent/externalHooks/runner.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildHookSpawnOptions, runHook } from '#/agent/externalHooks/runner'; +import { reducePermissionDecisionResults } from '#/agent/externalHooks/permissionDecision'; +import type { HookResult } from '#/agent/externalHooks/types'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; const hostProcess = new HostProcessService(); @@ -106,6 +108,25 @@ describe('runHook process runner', () => { expect(result.reason).toBe('use rg'); }); + it('parses a permission decision and its exact request id', async () => { + const result = await runHook( + hostProcess, + nodeCommand( + 'process.stdout.write(JSON.stringify({ hookSpecificOutput: { permissionRequestId: "approval_1", permissionDecision: "allow" } }));', + ), + {}, + { timeout: 5 }, + ); + + expect(result).toMatchObject({ + action: 'allow', + exitCode: 0, + structuredOutput: true, + permissionDecision: 'allow', + permissionRequestId: 'approval_1', + }); + }); + it('writes the input payload to the hook process stdin as JSON', async () => { const result = await runHook( hostProcess, @@ -125,6 +146,69 @@ describe('runHook process runner', () => { }); }); +describe('reducePermissionDecisionResults', () => { + it('allows only when every matched hook returns allow for the current request', () => { + expect( + reducePermissionDecisionResults( + [decisionResult('allow', 'approval_1'), decisionResult('allow', 'approval_1')], + 'approval_1', + ), + ).toEqual({ decision: 'allow' }); + + expect( + reducePermissionDecisionResults( + [decisionResult('allow', 'approval_1'), decisionResult('allow', 'approval_other')], + 'approval_1', + ), + ).toBeUndefined(); + }); + + it('lets a valid deny win and treats exit code 2 as an explicit deny', () => { + expect( + reducePermissionDecisionResults( + [decisionResult('allow', 'approval_1'), decisionResult('deny', 'approval_1', 'no')], + 'approval_1', + ), + ).toEqual({ decision: 'deny', reason: 'no' }); + + expect( + reducePermissionDecisionResults( + [{ action: 'block', exitCode: 2, stderr: 'blocked', reason: 'blocked' }], + 'approval_1', + ), + ).toEqual({ decision: 'deny', reason: 'blocked' }); + }); + + it('falls back when output is empty, malformed, timed out, or bound to another request', () => { + const invalidResults: HookResult[] = [ + { action: 'allow', exitCode: 0 }, + { action: 'allow', exitCode: 1 }, + { action: 'allow', exitCode: 0, timedOut: true }, + decisionResult('allow', 'approval_other'), + ]; + + for (const result of invalidResults) { + expect(reducePermissionDecisionResults([result], 'approval_1')).toBeUndefined(); + } + }); +}); + +function decisionResult( + decision: 'allow' | 'deny', + permissionRequestId: string, + reason?: string, +): HookResult { + return { + action: decision === 'deny' ? 'block' : 'allow', + exitCode: 0, + structuredOutput: true, + permissionDecision: decision, + permissionRequestId, + permissionDecisionReason: reason, + reason, + }; +} + describe('buildHookSpawnOptions (Windows console-window regression)', () => { it('sets windowsHide:true so hooks do not flash a console on Windows', () => { expect(buildHookSpawnOptions({}).windowsHide).toBe(true); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index e3423f6ef1..c3f27d9472 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -27,6 +27,7 @@ import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode, PermissionPolicyResult } from '#/agent/permissionPolicy/types'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { OrderedHookSlot } from '#/hooks'; import { IAgentToolExecutorService, type ToolExecutionResult, @@ -725,6 +726,9 @@ describe('AgentGoalService goal-start review', () => { function approvalStub(): IAgentToolApprovalService { return { _serviceBrand: undefined, + hooks: { + onWillRequestApproval: new OrderedHookSlot(), + }, resolvePermissionResolution: async () => undefined, requestToolApproval: async (_context, result, origin) => { approvalCalls.push({ result, origin }); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index feec8518e4..5fff80b006 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -367,7 +367,7 @@ describe('Agent loop', () => { [wire] token_counting.measured { "length": 2, "tokens": 20, "time": "