diff --git a/.changeset/fix-unattended-approval.md b/.changeset/fix-unattended-approval.md new file mode 100644 index 00000000..98ea0e73 --- /dev/null +++ b/.changeset/fix-unattended-approval.md @@ -0,0 +1,14 @@ +--- +'@moonshot-ai/agent-core-v2': patch +--- + +Refuse instead of blocking when a tool needs approval in an unattended +session. Auto mode is documented as "fully autonomous, the agent will not ask +questions" and headless runs (`kimi -p`) turn it on for the whole session, so a +tool that falls outside auto-approval had nobody to answer its request and the +process waited forever. It now resolves as a refusal that names the tool and +says how to grant it deliberately. + +`FetchURL` joins `Bash` in the set auto mode does not blanket-approve: it sends +caller-chosen bytes to a caller-chosen host, and an unattended session is +exactly where that would go unnoticed. diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts index 9a2cf273..16c2fc06 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts @@ -14,11 +14,15 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/tool * model picked up — including one that arrived in a repo file, an issue, or a * fetched page — into an unreviewed shell execution. * - * Excluding it here does not deny it: the call falls through to the rest of - * the chain, so a user `[permission] allow` rule still authorizes it. That + * `FetchURL` is here for the matching reason on the way out: it sends + * caller-chosen bytes to a caller-chosen host, and an unattended session is + * exactly where nobody would notice it happening. + * + * Excluding them here does not deny them: the call falls through to the rest + * of the chain, so a user `[permission] allow` rule still authorizes it. That * makes the grant explicit and auditable instead of implied by the mode. */ -const AUTO_MODE_EXCLUDED_TOOLS = new Set(['Bash']); +const AUTO_MODE_EXCLUDED_TOOLS = new Set(['Bash', 'FetchURL']); /** * Escape hatch for operators who accept the risk and need the previous diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 863fc274..e0eaf966 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -129,15 +129,27 @@ export class AgentToolApprovalService extends Service implements IAgentToolAppro const startedAt = Date.now(); let response: ApprovalResponse; - const approvalService = this.tryApprovalService(); + // Auto mode is documented as "fully autonomous, the agent will not ask + // questions", and headless runs (`kimi -p`) turn it on for the whole + // session. Nobody is there to answer, so going to the broker would block + // until the process is killed. Treat it like a missing broker and refuse. + // Mirrors `auto-mode-ask-user-question-deny`, which solves the same + // problem for AskUserQuestion. + const unattended = this.modeService.mode === 'auto'; + const approvalService = unattended ? undefined : this.tryApprovalService(); if (approvalService === undefined) { - // Fail closed. Reaching here means a policy decided this call needs - // confirmation, but no broker is bound to ask (an embedding host that - // never wired one up). Treating "nobody to ask" as consent would let - // every gated tool call through unreviewed. + // Fail closed. A policy decided this call needs confirmation and there + // is no one to confirm it — either the session is unattended, or no + // broker was bound (an embedding host that never wired one up). + // Treating "nobody to ask" as consent would let every gated tool call + // through unreviewed. response = { decision: 'rejected', - feedback: 'No approval broker is available to confirm this tool call.', + feedback: unattended + ? `"${name}" needs approval and this session is running unattended (auto mode). ` + + `Allow it explicitly with a [permission] allow rule in config.toml, ` + + `or run interactively so the request can be answered.` + : 'No approval broker is available to confirm this tool call.', }; } else { this.eventBus.publish({ type: 'permission.approval.requested', ...approvalContext }); diff --git a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts index b47d08ee..50f71e6a 100644 --- a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts +++ b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts @@ -277,6 +277,24 @@ describe('AgentToolApprovalService', () => { }); }); + + it('refuses instead of blocking when the session is unattended', async () => { + // Auto mode is documented as never asking, and headless runs turn it on. + // Going to the broker there would wait for a decision that never comes. + mode = 'auto'; + const request = useBroker(async () => ({ decision: 'approved' })); + + const result = await make().requestToolApproval( + makeContext('Bash', { command: 'echo hi' }), + ask(), + 'fallback-ask', + ); + + expect(request).not.toHaveBeenCalled(); + expect(result).toMatchObject({ veto: { isError: true } }); + expect((result as { veto: { output: string } }).veto.output).toContain('unattended'); + }); + it('publishes approval events around the broker round-trip', async () => { const events = subscribeApprovalEvents(); const request = useBroker(async () => ({