Skip to content
Open
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
8 changes: 6 additions & 2 deletions extensions/cli/src/stream/handleToolCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { stripImages } from "core/util/messageContent.js";
import { createHistoryItem } from "core/util/messageConversion.js";

import { checkToolPermission } from "src/permissions/permissionChecker.js";
import type { ToolPermissions } from "src/permissions/types.js";

import {
SERVICE_NAMES,
Expand Down Expand Up @@ -169,7 +170,10 @@ export async function handleToolCalls(
return false;
}

export async function getRequestTools(isHeadless: boolean) {
export async function getRequestTools(
isHeadless: boolean,
permissionSnapshot?: Readonly<ToolPermissions>,
) {
const availableTools = await getAllAvailableTools(isHeadless);

const permissionsState =
Expand All @@ -181,7 +185,7 @@ export async function getRequestTools(isHeadless: boolean) {
for (const tool of availableTools) {
const result = checkToolPermission(
{ name: tool.name, arguments: {} },
permissionsState.permissions,
permissionSnapshot ?? permissionsState.permissions,
);

if (
Expand Down
9 changes: 8 additions & 1 deletion extensions/cli/src/stream/streamChatResponse.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ export async function checkToolPermissionApproval(
// "ask" tools are excluded in headless so can only get here by policy evaluation
return { approved: false, denialReason: "policy" };
}
// Fail closed: with no way to ask the user, "ask" is a denial
if (!callbacks?.onToolPermissionRequest) {
return { approved: false, denialReason: "policy" };
}
const userApproved = await requestUserPermission(toolCall, callbacks);
return userApproved
? { approved: true }
Expand Down Expand Up @@ -510,8 +514,9 @@ export async function executeStreamedToolCalls(
await serviceContainer.get<ToolPermissionServiceState>(
SERVICE_NAMES.TOOL_PERMISSIONS,
);
// A run with its own snapshot (subagents) ignores the live state
const permissionResult = await checkToolPermissionApproval(
permissionState.permissions,
callbacks?.permissionSnapshot ?? permissionState.permissions,
call,
callbacks,
isHeadless,
Expand Down Expand Up @@ -566,6 +571,8 @@ export async function executeStreamedToolCalls(

const toolResult = await executeToolCall(call, {
parallelToolCallCount,
onToolPermissionRequest: callbacks?.onToolPermissionRequest,
permissionSnapshot: callbacks?.permissionSnapshot,
});
const entry: ToolResultWithStatus = {
role: "tool",
Expand Down
103 changes: 103 additions & 0 deletions extensions/cli/src/stream/streamChatResponse.permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { serviceContainer } from "../services/index.js";

import {
checkToolPermissionApproval,
executeStreamedToolCalls,
} from "./streamChatResponse.helpers.js";

vi.mock("../services/index.js", () => ({
services: {
chatHistory: {
addToolResult: vi.fn(),
updateToolStatus: vi.fn(),
},
},
serviceContainer: { get: vi.fn() },
SERVICE_NAMES: { TOOL_PERMISSIONS: "toolPermissions" },
}));

vi.mock("../tools/index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../tools/index.js")>()),
executeToolCall: vi.fn().mockResolvedValue("ran"),
}));

const askBash = { policies: [{ tool: "Bash", permission: "ask" as const }] };
const allowAll = { policies: [{ tool: "*", permission: "allow" as const }] };

function bashCall() {
return {
id: "call-1",
name: "Bash",
arguments: { command: "echo hi" },
argumentsStr: '{"command":"echo hi"}',
tool: { name: "Bash", run: vi.fn() },
} as any;
}

describe("tool permission fail-closed behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("denies an ask-policy tool when no approval callback is registered", async () => {
const result = await checkToolPermissionApproval(
askBash,
bashCall(),
undefined,
false,
);

expect(result).toEqual({ approved: false, denialReason: "policy" });
});

it("denies an ask-policy tool when callbacks exist without onToolPermissionRequest", async () => {
const result = await checkToolPermissionApproval(
askBash,
bashCall(),
{ onContent: vi.fn() },
false,
);

expect(result).toEqual({ approved: false, denialReason: "policy" });
});

it("does not run an ask-policy Bash call in a headless-style context with no callback", async () => {
// Live state is permissive; the run's own snapshot says ask.
vi.mocked(serviceContainer.get).mockResolvedValue({
permissions: allowAll,
} as any);
const { executeToolCall } = await import("../tools/index.js");

const { hasRejection, chatHistoryEntries } = await executeStreamedToolCalls(
[bashCall()],
{ permissionSnapshot: askBash },
false,
);

expect(hasRejection).toBe(true);
expect(chatHistoryEntries[0].status).toBe("canceled");
expect(executeToolCall).not.toHaveBeenCalled();
});

it("checks tool calls against the run's snapshot, not the live state", async () => {
vi.mocked(serviceContainer.get).mockResolvedValue({
permissions: allowAll,
} as any);
const { executeToolCall } = await import("../tools/index.js");

// Without a snapshot the live allow-all policy applies
await executeStreamedToolCalls([bashCall()], {}, false);
expect(executeToolCall).toHaveBeenCalledTimes(1);

// With a snapshot the ask policy applies and the call is not run
vi.mocked(executeToolCall).mockClear();
await executeStreamedToolCalls(
[bashCall()],
{ permissionSnapshot: askBash },
false,
);
expect(executeToolCall).not.toHaveBeenCalled();
});
});
5 changes: 4 additions & 1 deletion extensions/cli/src/stream/streamChatResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,10 @@ export async function streamChatResponse(
);

// Recompute tools on each iteration to handle mode changes during streaming
const rawTools = await getRequestTools(isHeadless);
const rawTools = await getRequestTools(
isHeadless,
callbacks?.permissionSnapshot,
);
const tools = applyChatCompletionToolOverrides(
rawTools,
model.chatOptions?.toolOverrides,
Expand Down
7 changes: 7 additions & 0 deletions extensions/cli/src/stream/streamChatResponse.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CompletionOptions } from "@continuedev/config-yaml";
import type { ToolStatus } from "core/index.js";
import type { ChatCompletionCreateParamsStreaming } from "openai/resources.mjs";

import type { ToolPermissions } from "../permissions/types.js";
import { ToolCallPreview } from "../tools/types.js";

export interface StreamCallbacks {
Expand All @@ -17,6 +18,12 @@ export interface StreamCallbacks {
preview?: ToolCallPreview[],
) => void;
onSystemMessage?: (message: string) => void;
/**
* Immutable permission policy for this stream. When set, tool calls are
* checked against it instead of the live session state. Subagent runs set
* this so a run cannot be affected by later changes to the shared state.
*/
permissionSnapshot?: Readonly<ToolPermissions>;
}

export function getDefaultCompletionOptions(
Expand Down
95 changes: 95 additions & 0 deletions extensions/cli/src/subagent/executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { serviceContainer } from "../services/ServiceContainer.js";
import { SERVICE_NAMES } from "../services/types.js";
import { streamChatResponse } from "../stream/streamChatResponse.js";

import { executeSubAgent } from "./executor.js";

vi.mock("../stream/streamChatResponse.js", () => ({
streamChatResponse: vi.fn(),
}));

vi.mock("../services/index.js", () => ({
services: {},
}));

describe("executeSubAgent", () => {
const agent = {
model: { name: "test-model" },
llmApi: {},
} as any;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(streamChatResponse).mockResolvedValue(undefined as any);
});

it("keeps the parent session's tool permissions during and after execution", async () => {
const parentPermissionsState = {
permissions: {
policies: [{ tool: "Bash", permission: "ask" as const }],
},
currentMode: "normal" as const,
isHeadless: false,
};
serviceContainer.registerValue(
SERVICE_NAMES.TOOL_PERMISSIONS,
parentPermissionsState,
);

let stateDuringExecution: unknown;
vi.mocked(streamChatResponse).mockImplementation(async () => {
stateDuringExecution = await serviceContainer.get(
SERVICE_NAMES.TOOL_PERMISSIONS,
);
return "";
});

const result = await executeSubAgent({
agent,
prompt: "do a thing",
parentSessionId: "parent-session",
abortController: new AbortController(),
});

expect(result.success).toBe(true);
expect(stateDuringExecution).toEqual(parentPermissionsState);
const stateAfter = await serviceContainer.get(
SERVICE_NAMES.TOOL_PERMISSIONS,
);
expect(stateAfter).toEqual(parentPermissionsState);
});

it("forwards onToolPermissionRequest to the subagent stream callbacks", async () => {
const onToolPermissionRequest = vi.fn();

await executeSubAgent({
agent,
prompt: "do a thing",
parentSessionId: "parent-session",
abortController: new AbortController(),
onToolPermissionRequest,
});

const callbacks = vi.mocked(streamChatResponse).mock.calls[0][4];
expect(callbacks?.onToolPermissionRequest).toBe(onToolPermissionRequest);
});

it("passes the permission snapshot to the subagent stream callbacks", async () => {
const permissionSnapshot = Object.freeze({
policies: [{ tool: "Bash", permission: "ask" as const }],
});

await executeSubAgent({
agent,
prompt: "do a thing",
parentSessionId: "parent-session",
abortController: new AbortController(),
permissionSnapshot,
});

const callbacks = vi.mocked(streamChatResponse).mock.calls[0][4];
expect(callbacks?.permissionSnapshot).toBe(permissionSnapshot);
});
});
55 changes: 27 additions & 28 deletions extensions/cli/src/subagent/executor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { ChatHistoryItem } from "core";

import type { ToolPermissions } from "../permissions/types.js";
import { services } from "../services/index.js";
import { serviceContainer } from "../services/ServiceContainer.js";
import type { ToolPermissionServiceState } from "../services/ToolPermissionService.js";
import { ModelServiceState, SERVICE_NAMES } from "../services/types.js";
import { ModelServiceState } from "../services/types.js";
import { streamChatResponse } from "../stream/streamChatResponse.js";
import type { ToolCallPreview } from "../tools/types.js";
import { escapeEvents } from "../util/cli.js";
import { logger } from "../util/logger.js";

Expand All @@ -17,6 +17,17 @@ export interface SubAgentExecutionOptions {
parentSessionId: string;
abortController: AbortController;
onOutputUpdate?: (output: string) => void;
onToolPermissionRequest?: (
toolName: string,
toolArgs: any,
requestId: string,
preview?: ToolCallPreview[],
) => void;
/**
* Immutable policy the subagent runs under. Computed when the subagent is
* spawned, so later changes to the shared session state do not affect it.
*/
permissionSnapshot?: Readonly<ToolPermissions>;
}

/**
Expand Down Expand Up @@ -54,16 +65,17 @@ async function buildAgentSystemMessage(
/**
* Execute a subagent in a child session
*/
// eslint-disable-next-line complexity
export async function executeSubAgent(
options: SubAgentExecutionOptions,
): Promise<SubAgentResult> {
const { agent: subAgent, prompt, abortController, onOutputUpdate } = options;

const mainAgentPermissionsState =
await serviceContainer.get<ToolPermissionServiceState>(
SERVICE_NAMES.TOOL_PERMISSIONS,
);
const {
agent: subAgent,
prompt,
abortController,
onOutputUpdate,
onToolPermissionRequest,
permissionSnapshot,
} = options;

try {
logger.debug("Starting subagent execution", {
Expand All @@ -75,18 +87,9 @@ export async function executeSubAgent(
throw new Error("Model or LLM API not available");
}

// allow all tools for now
// todo: eventually we want to show the same prompt in a dialog whether asking whether that tool call is allowed or not

serviceContainer.set<ToolPermissionServiceState>(
SERVICE_NAMES.TOOL_PERMISSIONS,
{
...mainAgentPermissionsState,
permissions: {
policies: [{ tool: "*", permission: "allow" }],
},
},
);
// The subagent runs under the permission snapshot taken at spawn time.
// "ask"-policy tool calls surface the approval dialog through
// onToolPermissionRequest; with no callback they are denied.

// Build agent system message
const systemMessage = await buildAgentSystemMessage(subAgent, services);
Expand Down Expand Up @@ -157,6 +160,8 @@ export async function executeSubAgent(
onOutputUpdate(accumulatedOutput);
}
},
onToolPermissionRequest,
permissionSnapshot,
},
false, // Not compacting
);
Expand Down Expand Up @@ -191,12 +196,6 @@ export async function executeSubAgent(
if (chatHistorySvc && originalIsReady) {
chatHistorySvc.isReady = originalIsReady;
}

// Restore original main agent tool permissions
serviceContainer.set<ToolPermissionServiceState>(
SERVICE_NAMES.TOOL_PERMISSIONS,
mainAgentPermissionsState,
);
}
} catch (error: any) {
logger.error("Subagent execution failed", {
Expand Down
Loading
Loading