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
14 changes: 14 additions & 0 deletions .changeset/fix-unattended-approval.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(['Bash']);
const AUTO_MODE_EXCLUDED_TOOLS = new Set<string>(['Bash', 'FetchURL']);

/**
* Escape hatch for operators who accept the risk and need the previous
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
Loading