feat(core): add a host tool invocation guard (#8032)

* feat(core): add a host tool invocation guard

* docs(core): clarify guard correlation identity

* fix(core): add guard debug logging, narrow doc scope, pin fast path (#8032)

* fix(core): log unrecognized guard decisions before failing closed (#8032)

* test(core): cover blank and non-string guard denial reasons (#8032)

* fix(core): log guard evaluation and document fail-closed contract (#8032)

* test(core): cover speculation guard allow path (#8032)

* fix(core): log speculation guard denial reason at debug level (#8032)

---------

Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: Qwen Code Autofix <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-31 13:46:29 +00:00
1 parent 63780e0d2f
commit 2ed7874409
11 files changed
+911 -2

No files matched your search

@@ -0,0 +1,107 @@
# Host tool invocation guard
## Status
Draft design for issue [#8102](https://github.com/QwenLM/qwen-code/issues/8102) and PR [#8032](https://github.com/QwenLM/qwen-code/pull/8032).
## Problem
An in-process embedding host can evaluate a model-proposed tool call through existing permissions and hooks, but those checks may run before Qwen Code has resolved the canonical tool name or built the final invocation parameters. A host that enforces organization policy therefore cannot prove that it evaluated the same call that reached `invocation.execute()`.
The missing primitive is a final execution-boundary decision over the effective tool call. Product-specific task state, approval workflows, policy storage, and audit transport do not belong in Qwen Code.
## Goals
- Let an in-process host provide one allow/deny function through `ConfigParameters`.
- Evaluate the canonical tool name and cloned final invocation parameters immediately before execution.
- Cover the core scheduler, ACP session runtime, and speculative execution paths.
- Fail closed when a configured guard denies, throws, returns a malformed decision, or cannot receive cloned arguments.
- Preserve the existing execution path when no guard is configured.
- Prevent execution if cancellation occurs before or while awaiting the guard.
## Non-goals
- No CLI flag, settings key, environment variable, daemon route, network client, or external policy transport.
- No task, plan, grant, business authorization, or audit schema.
- No change to permission, hook, sandbox, or approval-mode semantics.
- No claim that model planning or tool implementations become deterministic.
- No result callback or parallel tool-result protocol.
- No interception of an SDK consumer that manually calls `ToolInvocation.execute()` or `Tool.buildAndExecute()` outside a Config-owned runtime.
## Contract
The host supplies a `ToolInvocationGuard` in `ConfigParameters`. The guard receives:
- the runtime-accepted tool-call correlation identifier;
- the canonical tool name;
- a structured clone of the final invocation parameters; and
- the invocation abort signal.
The decision is either `{ allowed: true }` or `{ allowed: false, reason? }`. A missing or blank denial reason uses a stable generic message. Exceptions, malformed decisions, and clone failures use a separate stable failure message and deny execution. A supplied denial reason is user-visible and may enter existing tool-result and telemetry surfaces, so it must not contain secrets or raw provider errors.
The cloned arguments prevent a guard from mutating the invocation that Qwen Code will execute. The contract does not make arbitrary tool arguments secret; an embedding host must treat them as sensitive application data.
The tool-call identifier may originate in a model response. It is useful for
correlating the guard decision with existing lifecycle events, but it is not an
authenticated subject or a standalone idempotency key. A managed host that
needs strong identity must bind it to host-owned session and prompt identity.
## Execution placement
The core scheduler evaluates the guard after tool construction, permission handling, path normalization, and `PreToolUse`, but before the call changes to `executing` and before `invocation.execute()`.
The ACP session evaluates the same contract after tool construction, permission handling, and `PreToolUse`, but before its direct `invocation.execute()` path.
The experimental speculation engine also executes invocations directly instead of using the core scheduler. It evaluates the same guard after building the invocation and converts a denial or cancellation into a speculation boundary with zero executor calls. A future managed external-provider mode must disable speculative apply because copying an overlay into the real filesystem is a separate effect boundary outside `invocation.execute()`.
All three paths use the built invocation parameters rather than the model-provided draft arguments. In the core and ACP paths, a denial produces zero executor calls and a structured `execution_denied` tool result.
Any future Config-owned runtime that executes a `ToolInvocation` directly must evaluate the same guard or route through an already guarded scheduler. This is a code-review invariant, not a claim that arbitrary external callers can be intercepted.
Two agent-dispatch call sites — the `/fork` slash command and the ACP agent
fork handler — build and execute an agent tool invocation directly without
consulting the guard. The spawned subagent shares the caller's `Config`, so
every tool the subagent itself calls is guarded; only the dispatch call itself
is unguarded. A future change may extend the guard to these sites.
## Default-off compatibility
Qwen Code does not populate `toolInvocationGuard` in its CLI or daemon bootstrap. The field is an in-process embedding API only.
Each execution path reads the optional callback and enters the asynchronous evaluator only when the callback exists. When absent, Qwen Code performs no guard promise allocation, argument clone, provider call, capability advertisement, or additional asynchronous yield. Existing CLI and daemon deployments therefore retain their prior execution path.
The intentionally absent in-repository production setter means this change requires maintainer agreement on the public embedding seam before merge. A future external-provider change must remain a separate PR and cannot be assumed as part of this PR's approval.
## Cancellation and failure semantics
The evaluator checks cancellation both before invoking the guard and after its promise settles. Each execution path also checks its active signal immediately after the await and before any executor call.
- cancellation before evaluation: do not call the guard or executor;
- cancellation while awaiting a guard: record cancellation and do not call the executor;
- explicit denial: record `execution_denied` and do not call the executor;
- guard exception, malformed response, or clone failure: fail closed and do not call the executor.
There is no automatic retry. The guard callback owns any provider-specific retry policy, but an embedding host must not retry or execute an ambiguous side effect through this API.
## Evidence
The unit and integration tests cover:
- configured allow and deny decisions;
- default denial reason;
- guard exception, malformed response, and clone failure;
- argument mutation isolation;
- cancellation before and during guard evaluation;
- final normalized arguments in the core and ACP paths;
- speculative execution stops at a boundary on denial;
- zero executor calls on denial and cancellation;
- `execution_denied` parity between core and ACP tool-result records; and
- existing unconfigured execution through the surrounding scheduler and ACP suites.
No E2E plan is required for this PR because it adds no CLI, setting, daemon route, or other user-activatable behavior. Cross-platform CI remains required before merge.
## Follow-up boundary
A future external policy provider may extend the context with trusted runtime-owned session and prompt identity and adapt the in-process callback across the `qwen serve` to ACP child boundary. That follow-up must be default off, independently reviewed, and prove that an unconfigured CLI and daemon do not initialize a provider or change their child process environment.
Result observation should reuse existing structured tool lifecycle events unless a separate issue demonstrates a concrete correlation gap. Product-specific orchestration and policy remain outside Qwen Code.
@@ -588,6 +588,7 @@ describe('Session', () => {
.fn()
.mockReturnValue(mockChatRecordingService),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getToolInvocationGuard: vi.fn().mockReturnValue(undefined),
getFileService: vi.fn().mockReturnValue(fileService),
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
getFileFilteringOptions: vi.fn().mockReturnValue({
@@ -14273,6 +14274,186 @@ describe('Session', () => {
expect(executeSpy).not.toHaveBeenCalled();
});
it('runs the host guard with final params and denies before execution', async () => {
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const guard = vi.fn().mockResolvedValue({
allowed: false,
reason: 'host policy denied',
});
mockConfig.getToolInvocationGuard = vi.fn().mockReturnValue(guard);
const executeSpy = vi.fn();
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/normalized/final.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-guard',
name: 'read_file',
args: { path: './original.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(guard).toHaveBeenCalledWith({
callId: 'call-guard',
toolName: 'read_file',
args: { path: '/normalized/final.txt' },
signal: expect.any(AbortSignal),
});
expect(executeSpy).not.toHaveBeenCalled();
expect(
mockChatRecordingService.recordToolResult,
).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
callId: 'call-guard',
status: 'error',
errorType: core.ToolErrorType.EXECUTION_DENIED,
}),
);
});
it('executes once when the host guard allows the final invocation', async () => {
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const guard = vi.fn().mockResolvedValue({ allowed: true });
mockConfig.getToolInvocationGuard = vi.fn().mockReturnValue(guard);
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'file contents',
returnDisplay: 'success',
});
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/normalized/final.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-guard-allowed',
name: 'read_file',
args: { path: './original.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(guard).toHaveBeenCalledWith({
callId: 'call-guard-allowed',
toolName: 'read_file',
args: { path: '/normalized/final.txt' },
signal: expect.any(AbortSignal),
});
expect(executeSpy).toHaveBeenCalledOnce();
});
it('cancels without execution when aborted while awaiting the host guard', async () => {
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
let resolveGuard!: (decision: { allowed: true }) => void;
const guard = vi.fn(
() =>
new Promise<{ allowed: true }>((resolve) => {
resolveGuard = resolve;
}),
);
mockConfig.getToolInvocationGuard = vi.fn().mockReturnValue(guard);
const executeSpy = vi.fn();
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-guard-aborted',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
const prompt = session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
await vi.waitFor(() => expect(guard).toHaveBeenCalledOnce());
const cancel = session.cancelPendingPrompt();
resolveGuard({ allowed: true });
await cancel;
await prompt;
expect(executeSpy).not.toHaveBeenCalled();
expect(
mockChatRecordingService.recordToolResult,
).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
callId: 'call-guard-aborted',
status: 'cancelled',
}),
);
});
});
describe('PostToolUse hook', () => {
@@ -97,6 +97,7 @@ import {
buildSessionRecoveryPlanFromApiHistory,
TURN_INTERRUPTION_HISTORY_TAIL_COUNT,
evaluatePermissionFlow,
evaluateToolInvocationGuard,
getEffectivePermissionForConfirmation,
needsConfirmation,
isPlanModeBlocked,
@@ -7148,7 +7149,9 @@ export class Session implements SessionContext {
error: Error,
toolName = fc.name ?? 'unknown_tool',
opts?: {
errorType?: ToolErrorType;
recordInvalidToolParams?: boolean;
status?: 'error' | 'cancelled';
stopAfterPermissionCancel?: boolean;
},
) => {
@@ -7165,10 +7168,10 @@ export class Session implements SessionContext {
responseParts: errorParts,
metadata: {
callId,
status: 'error',
status: opts?.status ?? 'error',
resultDisplay: undefined,
error,
errorType: undefined,
errorType: opts?.errorType,
},
});
const loopDetected =
@@ -8005,6 +8008,33 @@ export class Session implements SessionContext {
}
}
const toolInvocationGuard = this.config.getToolInvocationGuard?.();
if (toolInvocationGuard) {
const guardDecision = await evaluateToolInvocationGuard(
toolInvocationGuard,
{
callId,
toolName: policyToolName,
args: invocation.params as Record<string, unknown>,
signal: activeToolAbortSignal,
},
);
if (activeToolAbortSignal.aborted) {
return earlyErrorResponse(
new Error('Tool invocation was cancelled'),
toolName,
{ status: 'cancelled' },
);
}
if (!guardDecision.allowed) {
return earlyErrorResponse(
new Error(guardDecision.reason),
toolName,
{ errorType: ToolErrorType.EXECUTION_DENIED },
);
}
}
const continuedAgentId =
toolName === ToolNames.SEND_MESSAGE &&
typeof args['task_id'] === 'string' &&
+12
View File
@@ -165,6 +165,7 @@ import {
type GoalTurnHost,
} from '../goals/goal-runtime.js';
import { createGoalVerifier } from '../goals/goal-verifier.js';
import type { ToolInvocationGuard } from '../core/tool-invocation-guard.js';
// Utils
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
@@ -1004,6 +1005,11 @@ export interface ConfigParameters {
/** Settings consumed by the AUTO approval mode classifier. */
autoMode?: AutoModeSettings;
};
/**
* Optional host policy evaluated with final tool arguments immediately
* before execution. A configured guard fails closed.
*/
toolInvocationGuard?: ToolInvocationGuard;
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
@@ -1739,6 +1745,7 @@ export class Config {
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
private permissionManager: PermissionManager | null = null;
private readonly toolInvocationGuard: ToolInvocationGuard | undefined;
private modelInvocableCommandsProvider:
| (() => ReadonlyArray<{ name: string; description: string }>)
| null = null;
@@ -2104,6 +2111,7 @@ export class Config {
this.permissionsAsk = params.permissions?.ask || [];
this.permissionsDeny = params.permissions?.deny || [];
this.permissionsAutoMode = params.permissions?.autoMode ?? {};
this.toolInvocationGuard = params.toolInvocationGuard;
this.toolDiscoveryCommand = params.toolDiscoveryCommand;
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
@@ -7644,6 +7652,10 @@ export class Config {
return this.permissionManager;
}
getToolInvocationGuard(): ToolInvocationGuard | undefined {
return this.toolInvocationGuard;
}
/**
* Returns the callback for persisting permission rules to settings files.
* Returns undefined if no callback was provided (e.g. SDK mode).
@@ -14,6 +14,7 @@ import type {
ToolCallConfirmationDetails,
ToolConfirmationPayload,
ToolInvocation,
ToolInvocationGuard,
ToolResult,
ToolResultDisplay,
ToolRegistry,
@@ -329,6 +330,21 @@ vi.mock('../ide/ide-client.js', () => ({
},
}));
const evaluateGuardSpy = vi.hoisted(() => vi.fn());
vi.mock('./tool-invocation-guard.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./tool-invocation-guard.js')>();
return {
...actual,
evaluateToolInvocationGuard: (
...args: Parameters<typeof actual.evaluateToolInvocationGuard>
) => {
evaluateGuardSpy(...args);
return actual.evaluateToolInvocationGuard(...args);
},
};
});
const mockIdeClient = {
openDiff: vi.fn(),
isDiffingEnabled: vi.fn(),
@@ -8671,6 +8687,7 @@ describe('CoreToolScheduler Plan shell routing', () => {
disableHooks?: boolean;
avoidPermissionPrompts?: boolean;
targetDir?: () => string;
toolInvocationGuard?: ToolInvocationGuard;
}) {
const tools = new Map(options.tools.map((tool) => [tool.name, tool]));
const registry = {
@@ -8726,6 +8743,7 @@ describe('CoreToolScheduler Plan shell routing', () => {
getShouldAvoidPermissionPrompts: () =>
options.avoidPermissionPrompts ?? false,
getOnPersistPermissionRule: () => undefined,
getToolInvocationGuard: () => options.toolInvocationGuard,
} as unknown as Config;
return {
@@ -8813,6 +8831,121 @@ describe('CoreToolScheduler Plan shell routing', () => {
expect(getConfirmationDetails).not.toHaveBeenCalled();
});
it('runs the host guard with final params and denies before execution', async () => {
const execute = vi.fn();
const toolInvocationGuard = vi
.fn()
.mockResolvedValue({ allowed: false, reason: 'host policy denied' });
const { scheduler, onAllToolCallsComplete } = buildPlanShellScheduler({
tools: [shellTool({ execute })],
toolInvocationGuard,
targetDir: () => '/workspace',
});
await scheduler.schedule(
[request('guard-denied', 'git status')],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
expect(toolInvocationGuard).toHaveBeenCalledWith({
callId: 'guard-denied',
toolName: ToolNames.SHELL,
args: { command: 'git status', directory: '/workspace' },
signal: expect.any(AbortSignal),
});
expect(execute).not.toHaveBeenCalled();
const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[];
const deniedCall = completed[0];
expect(deniedCall.status).toBe('error');
expect(JSON.stringify(deniedCall)).toContain('host policy denied');
if (deniedCall.status !== 'error') {
throw new Error('Expected the guarded tool call to fail');
}
expect(deniedCall.response.errorType).toBe(ToolErrorType.EXECUTION_DENIED);
});
it('executes once when the host guard allows the final invocation', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const toolInvocationGuard = vi.fn().mockResolvedValue({ allowed: true });
const { scheduler, onAllToolCallsComplete } = buildPlanShellScheduler({
tools: [shellTool({ execute })],
toolInvocationGuard,
targetDir: () => '/workspace',
});
await scheduler.schedule(
[request('guard-allowed', 'git status')],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
expect(toolInvocationGuard).toHaveBeenCalledWith({
callId: 'guard-allowed',
toolName: ToolNames.SHELL,
args: { command: 'git status', directory: '/workspace' },
signal: expect.any(AbortSignal),
});
expect(execute).toHaveBeenCalledOnce();
const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[];
expect(completed[0].status).toBe('success');
});
it('cancels without execution when aborted while awaiting the host guard', async () => {
const execute = vi.fn();
let resolveGuard!: (decision: { allowed: true }) => void;
const toolInvocationGuard = vi.fn(
() =>
new Promise<{ allowed: true }>((resolve) => {
resolveGuard = resolve;
}),
);
const { scheduler, onAllToolCallsComplete } = buildPlanShellScheduler({
tools: [shellTool({ execute })],
toolInvocationGuard,
});
const abortController = new AbortController();
const schedule = scheduler.schedule(
[request('guard-aborted', 'git status')],
abortController.signal,
);
await vi.waitFor(() => expect(toolInvocationGuard).toHaveBeenCalledOnce());
abortController.abort();
resolveGuard({ allowed: true });
await schedule;
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
expect(execute).not.toHaveBeenCalled();
const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[];
expect(completed[0].status).toBe('cancelled');
});
it('skips guard evaluation entirely when no guard is configured', async () => {
evaluateGuardSpy.mockClear();
const execute = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const { scheduler, onAllToolCallsComplete } = buildPlanShellScheduler({
tools: [shellTool({ execute })],
});
await scheduler.schedule(
[request('no-guard', 'git status')],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
expect(evaluateGuardSpy).not.toHaveBeenCalled();
expect(execute).toHaveBeenCalledOnce();
const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[];
expect(completed[0].status).toBe('success');
});
it('limits PM-confirmed read-only shell calls to exact one-off approval', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn().mockResolvedValue({
@@ -177,6 +177,7 @@ import {
getInvocationContext,
runWithInvocationContext,
} from '../utils/invocation-context.js';
import { evaluateToolInvocationGuard } from './tool-invocation-guard.js';
import { goalTurnContext } from '../goals/goal-turn-context.js';
const debugLogger = createDebugLogger('TOOL_SCHEDULER');
@@ -255,6 +256,7 @@ function extractTextFromPartListUnion(c: PartListUnion): string {
const TOOL_FAILURE_KIND_ATTRIBUTE = 'tool.failure_kind';
const TOOL_FAILURE_KIND_PRE_HOOK_BLOCKED = 'pre_hook_blocked';
const TOOL_FAILURE_KIND_INVOCATION_GUARD_DENIED = 'invocation_guard_denied';
const TOOL_FAILURE_KIND_POST_HOOK_STOPPED = 'post_hook_stopped';
const TOOL_FAILURE_KIND_TOOL_ERROR = 'tool_error';
const TOOL_FAILURE_KIND_TOOL_EXCEPTION = 'tool_exception';
@@ -269,6 +271,8 @@ const TOOL_FAILURE_KIND_NON_INTERACTIVE_DENIED = 'non_interactive_denied';
const TOOL_FAILURE_KIND_BACKGROUND_AGENT_DENIED = 'background_agent_denied';
const TOOL_SPAN_STATUS_PRE_HOOK_BLOCKED = 'Tool execution blocked by hook';
const TOOL_SPAN_STATUS_INVOCATION_GUARD_DENIED =
'Tool execution blocked by host policy';
const TOOL_SPAN_STATUS_POST_HOOK_STOPPED = 'Tool execution stopped by hook';
const TOOL_SPAN_STATUS_PERMISSION_DENIED = 'Permission denied for tool';
@@ -4129,6 +4133,42 @@ export class CoreToolScheduler {
}
}
const toolInvocationGuard = this.config.getToolInvocationGuard?.();
if (toolInvocationGuard) {
const guardDecision = await evaluateToolInvocationGuard(
toolInvocationGuard,
{
callId,
toolName: canonicalName,
args: invocation.params as Record<string, unknown>,
signal,
},
);
if (signal.aborted) {
this.setStatusInternal(
callId,
'cancelled',
'Tool call cancelled by user.',
);
setToolSpanCancelled(span);
return;
}
if (!guardDecision.allowed) {
const errorResponse = createErrorResponse(
scheduledCall.request,
new Error(guardDecision.reason),
ToolErrorType.EXECUTION_DENIED,
);
this.setStatusInternal(callId, 'error', errorResponse);
setToolSpanFailure(
span,
TOOL_FAILURE_KIND_INVOCATION_GUARD_DENIED,
TOOL_SPAN_STATUS_INVOCATION_GUARD_DENIED,
);
return;
}
}
this.setStatusInternal(callId, 'executing');
const liveOutputCallback = scheduledCall.tool.canUpdateOutput
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { evaluateToolInvocationGuard } from './tool-invocation-guard.js';
const context = () => ({
callId: 'call-1',
toolName: 'read_file',
args: { path: '/tmp/input.txt', nested: { value: 'original' } },
signal: new AbortController().signal,
});
describe('evaluateToolInvocationGuard', () => {
it('allows calls when the guard permits them', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ allowed: true }),
context(),
),
).resolves.toEqual({ allowed: true });
});
it('returns the configured denial reason', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ allowed: false, reason: 'blocked' }),
context(),
),
).resolves.toEqual({ allowed: false, reason: 'blocked' });
});
it('uses a stable reason when the guard denies without one', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ allowed: false }),
context(),
),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation denied by host policy',
});
});
it('uses a stable reason when the guard denies with a blank reason', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ allowed: false, reason: ' ' }),
context(),
),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation denied by host policy',
});
});
it('uses a stable reason when the guard denies with a non-string reason', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ allowed: false, reason: 42 }),
context(),
),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation denied by host policy',
});
});
it('fails closed when the guard throws', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockRejectedValue(new Error('sensitive provider error')),
context(),
),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation guard failed',
});
});
it('fails closed when the guard returns a malformed decision', async () => {
await expect(
evaluateToolInvocationGuard(
vi.fn().mockResolvedValue({ result: 'allow' }),
context(),
),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation guard failed',
});
});
it('fails closed when invocation arguments cannot be cloned', async () => {
const guard = vi.fn();
await expect(
evaluateToolInvocationGuard(guard, {
...context(),
args: { callback: () => undefined },
}),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation guard failed',
});
expect(guard).not.toHaveBeenCalled();
});
it('does not call the guard for an already-aborted invocation', async () => {
const controller = new AbortController();
const guard = vi.fn();
controller.abort();
await expect(
evaluateToolInvocationGuard(guard, {
...context(),
signal: controller.signal,
}),
).resolves.toEqual({
allowed: false,
reason: 'Tool invocation guard failed',
});
expect(guard).not.toHaveBeenCalled();
});
it('fails closed when the invocation is aborted while awaiting the guard', async () => {
const controller = new AbortController();
let resolveGuard!: (decision: { allowed: true }) => void;
const guard = vi.fn(
() =>
new Promise<{ allowed: true }>((resolve) => {
resolveGuard = resolve;
}),
);
const evaluation = evaluateToolInvocationGuard(guard, {
...context(),
signal: controller.signal,
});
await vi.waitFor(() => expect(guard).toHaveBeenCalledOnce());
controller.abort();
resolveGuard({ allowed: true });
await expect(evaluation).resolves.toEqual({
allowed: false,
reason: 'Tool invocation guard failed',
});
});
it('does not let the guard mutate invocation arguments', async () => {
const original = context();
await evaluateToolInvocationGuard((received) => {
(received.args['nested'] as { value: string }).value = 'changed';
return { allowed: true };
}, original);
expect(original.args['nested'].value).toBe('original');
});
});
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { createDebugLogger } from '../utils/debugLogger.js';
export interface ToolInvocationGuardContext {
callId: string;
toolName: string;
args: Readonly<Record<string, unknown>>;
signal: AbortSignal;
}
export type ToolInvocationGuardDecision =
| { allowed: true }
| {
allowed: false;
/** User-visible denial reason. It must not contain secrets. */
reason?: string;
};
export type ToolInvocationGuard = (
context: ToolInvocationGuardContext,
) => ToolInvocationGuardDecision | Promise<ToolInvocationGuardDecision>;
export type EvaluatedToolInvocationGuardDecision =
| { allowed: true }
| { allowed: false; reason: string };
const debugLogger = createDebugLogger('TOOL_INVOCATION_GUARD');
const DENIED_MESSAGE = 'Tool invocation denied by host policy';
const FAILED_MESSAGE = 'Tool invocation guard failed';
/**
* Evaluate a host-supplied guard against a pending tool invocation. The guard
* fails closed: an aborted signal, a guard exception, a malformed decision, or
* a cloning failure all yield `{ allowed: false, reason: FAILED_MESSAGE }`.
* Because an aborted signal is reported in the same shape as a policy failure,
* callers must re-derive cancellation from `context.signal` instead of reading
* it off the returned decision.
*/
export async function evaluateToolInvocationGuard(
guard: ToolInvocationGuard,
context: ToolInvocationGuardContext,
): Promise<EvaluatedToolInvocationGuardDecision> {
if (context.signal.aborted) {
return { allowed: false, reason: FAILED_MESSAGE };
}
try {
debugLogger.debug('Evaluating tool invocation guard', {
callId: context.callId,
toolName: context.toolName,
});
const decision = await guard({
...context,
args: structuredClone(context.args),
});
if (context.signal.aborted) {
return { allowed: false, reason: FAILED_MESSAGE };
}
if (decision?.allowed === true) {
return { allowed: true };
}
if (decision?.allowed === false) {
return {
allowed: false,
reason:
typeof decision.reason === 'string' && decision.reason.trim()
? decision.reason
: DENIED_MESSAGE,
};
}
debugLogger.debug(
'Tool invocation guard returned an unrecognized decision; failing closed',
decision,
);
} catch (error) {
// A configured guard is an enforcement boundary. Provider and cloning
// failures must deny the call instead of falling back to execution.
debugLogger.debug('Tool invocation guard evaluation failed', error);
}
return { allowed: false, reason: FAILED_MESSAGE };
}
@@ -43,6 +43,134 @@ afterEach(() => {
});
describe('startSpeculation', () => {
it('stops at a boundary when the host guard denies a speculative invocation', async () => {
const execute = vi.fn();
const guard = vi.fn().mockResolvedValue({
allowed: false,
reason: 'host policy denied',
});
const toolRegistry = {
ensureTool: vi.fn().mockResolvedValue({
build: vi.fn().mockReturnValue({
params: { path: '/normalized/a.ts' },
execute,
}),
}),
};
const config = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getCwd: vi.fn().mockReturnValue(process.cwd()),
getFastModel: vi.fn().mockReturnValue(undefined),
getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
getToolInvocationGuard: vi.fn().mockReturnValue(guard),
} as unknown as Config;
forkedAgentMocks.runForkedAgent.mockResolvedValue({
jsonResult: { suggestion: '' },
});
forkedAgentMocks.sendMessageStream.mockImplementation(async function* () {
if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) {
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'call-speculation-guard',
name: 'read_file',
args: { path: 'a.ts' },
},
},
],
},
},
],
},
};
}
});
const state = await startSpeculation(config, 'read a.ts');
await vi.waitFor(() => expect(state.status).toBe('boundary'));
expect(guard).toHaveBeenCalledWith({
callId: 'call-speculation-guard',
toolName: 'read_file',
args: { path: '/normalized/a.ts' },
signal: expect.any(AbortSignal),
});
expect(execute).not.toHaveBeenCalled();
await abortSpeculation(state);
});
it('proceeds to execution when the host guard allows a speculative invocation', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'file contents',
returnDisplay: 'file contents',
});
const guard = vi.fn().mockResolvedValue({ allowed: true });
const toolRegistry = {
ensureTool: vi.fn().mockResolvedValue({
build: vi.fn().mockReturnValue({
params: { path: '/normalized/a.ts' },
execute,
}),
}),
};
const config = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getCwd: vi.fn().mockReturnValue(process.cwd()),
getFastModel: vi.fn().mockReturnValue(undefined),
getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
getToolInvocationGuard: vi.fn().mockReturnValue(guard),
} as unknown as Config;
forkedAgentMocks.runForkedAgent.mockResolvedValue({
jsonResult: { suggestion: '' },
});
forkedAgentMocks.sendMessageStream.mockImplementation(async function* () {
if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) {
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'call-speculation-guard-allow',
name: 'read_file',
args: { path: 'a.ts' },
},
},
],
},
},
],
},
};
}
});
const state = await startSpeculation(config, 'read a.ts');
await vi.waitFor(() => expect(state.status).toBe('completed'));
expect(guard).toHaveBeenCalledWith({
callId: 'call-speculation-guard-allow',
toolName: 'read_file',
args: { path: '/normalized/a.ts' },
signal: expect.any(AbortSignal),
});
expect(execute).toHaveBeenCalledOnce();
await abortSpeculation(state);
});
it('preserves generated tool call ids in paired responses', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'file contents',
+28
View File
@@ -20,9 +20,11 @@ import type { Config } from '../config/config.js';
import type { GeminiClient } from '../core/client.js';
import { StreamEventType } from '../core/geminiChat.js';
import {
canonicalToolName,
convertToFunctionErrorResponse,
convertToFunctionResponse,
} from '../core/coreToolScheduler.js';
import { evaluateToolInvocationGuard } from '../core/tool-invocation-guard.js';
import { stripToolResultImages } from '../services/visionBridge/tool-result-vision-bridge.js';
import { OverlayFs } from './overlayFs.js';
import { evaluateToolCall, rewritePathArgs } from './speculationToolGate.js';
@@ -32,6 +34,7 @@ import {
runForkedAgent,
runWithForkedChatModel,
} from '../utils/forkedAgent.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { getFilterReason, SUGGESTION_PROMPT } from './suggestionGenerator.js';
import {
finalizeToolResponses,
@@ -42,6 +45,8 @@ import {
// Constants
// ---------------------------------------------------------------------------
const debugLogger = createDebugLogger('SPECULATION');
const MAX_SPECULATION_TURNS = 20;
const MAX_SPECULATION_MESSAGES = 100;
@@ -333,6 +338,29 @@ async function runSpeculativeLoop(
}
const invocation = tool.build(args);
const toolInvocationGuard = config.getToolInvocationGuard?.();
if (toolInvocationGuard) {
const guardDecision = await evaluateToolInvocationGuard(
toolInvocationGuard,
{
callId: persistenceCallId,
toolName: canonicalToolName(name),
args: invocation.params as Record<string, unknown>,
signal: state.abortController!.signal,
},
);
if (state.abortController!.signal.aborted) {
hitBoundary = true;
break;
}
if (!guardDecision.allowed) {
debugLogger.debug(
`Speculative guard denial: ${guardDecision.reason}`,
);
hitBoundary = true;
break;
}
}
const result = await invocation.execute(
state.abortController!.signal,
);
+1
View File
@@ -93,6 +93,7 @@ export * from './core/session-recovery.js';
export * from './core/tokenLimits.js';
export * from './core/tool-call-preparation.js';
export * from './core/toolCallIdUtils.js';
export * from './core/tool-invocation-guard.js';
export * from './core/turn.js';
export * from './core/turn-interruption.js';