diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 13b71ffa3..ee9c1bfcc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,6 +45,10 @@ jobs: run: |- npm run build + - name: 'Bundle CLI for E2E tests' + run: |- + npm run bundle + - name: 'Set up Docker' if: |- ${{ matrix.sandbox == 'sandbox:docker' }} @@ -103,6 +107,10 @@ jobs: run: |- npm run build + - name: 'Bundle CLI for E2E tests' + run: |- + npm run bundle + - name: 'Run E2E tests' env: OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index 566f63c21..2a5646746 100644 --- a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -314,22 +314,13 @@ describe('AbortController and Process Lifecycle (E2E)', () => { }); it('should handle control responses when stdin closes before replies', async () => { + const testFilePath = await helper.getPath('test.txt'); await helper.createFile('test.txt', 'original content'); + let canUseToolCalled = false; let canUseToolCalledResolve: () => void = () => {}; - const canUseToolCalledPromise = new Promise((resolve, reject) => { + const canUseToolCalledPromise = new Promise((resolve) => { canUseToolCalledResolve = resolve; - setTimeout(() => { - reject(new Error('canUseTool callback not called')); - }, 15000); - }); - - let inputStreamDoneResolve: () => void = () => {}; - const inputStreamDonePromise = new Promise((resolve, reject) => { - inputStreamDoneResolve = resolve; - setTimeout(() => { - reject(new Error('inputStreamDonePromise timeout')); - }, 15000); }); let firstResultResolve: () => void = () => {}; @@ -362,12 +353,10 @@ describe('AbortController and Process Lifecycle (E2E)', () => { session_id: sessionId, message: { role: 'user', - content: - 'Write "updated" to test.txt. Stop if any exception occurs.', + content: `Use the write_file tool to write "updated" to the file at ${testFilePath}. Then reply with "done".`, }, parent_tool_use_id: null, }; - await inputStreamDonePromise; } const q = query({ @@ -378,10 +367,8 @@ describe('AbortController and Process Lifecycle (E2E)', () => { permissionMode: 'default', coreTools: ['read_file', 'write_file'], canUseTool: async (toolName, input) => { - inputStreamDoneResolve(); - await new Promise((resolve) => setTimeout(resolve, 1000)); + canUseToolCalled = true; canUseToolCalledResolve(); - return { behavior: 'allow', updatedInput: input, @@ -394,10 +381,8 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { const loop = async () => { let resultCount = 0; - for await (const _message of q) { - console.log(JSON.stringify(_message, null, 2)); - // Consume messages until completion. - if (isSDKResultMessage(_message)) { + for await (const message of q) { + if (isSDKResultMessage(message)) { resultCount += 1; if (resultCount === 1) { firstResultResolve(); @@ -416,8 +401,12 @@ describe('AbortController and Process Lifecycle (E2E)', () => { await canUseToolCalledPromise; await secondResultPromise; + // Signal stdin is done so CLI stops waiting + q.endInput(); + const content = await helper.readFile('test.txt'); - expect(content).toBe('original content'); + expect(canUseToolCalled).toBe(true); + expect(content).toBe('updated'); } finally { await q.close(); } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a35b1a569..299754cd2 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -37,8 +37,6 @@ import { readManyFiles, Storage, ToolNames, - buildPermissionCheckContext, - evaluatePermissionRules, fireNotificationHook, firePermissionRequestHook, firePreToolUseHook, @@ -53,6 +51,9 @@ import { getPlanModeSystemReminder, getSubagentSystemReminder, getArenaSystemReminder, + evaluatePermissionFlow, + needsConfirmation, + isPlanModeBlocked, } from '@qwen-code/qwen-code-core'; import { RequestError } from '@agentclientprotocol/sdk'; @@ -1383,39 +1384,22 @@ export class Session implements SessionContext { // The VS Code extension is just a UI layer for requestPermission. const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION; - // ---- L3: Tool's default permission ---- - // In YOLO mode, force 'allow' for everything except ask_user_question. - const defaultPermission = - this.config.getApprovalMode() !== ApprovalMode.YOLO || - isAskUserQuestionTool - ? await invocation.getDefaultPermission() - : 'allow'; - - // ---- L4: PermissionManager override (if relevant rules exist) ---- + // ---- L3→L4: Shared permission flow ---- const toolParams = invocation.params as Record; - const pmCtx = buildPermissionCheckContext( + const flowResult = await evaluatePermissionFlow( + this.config, + invocation, fc.name, toolParams, - this.config.getTargetDir?.() ?? '', ); - const { finalPermission, pmForcedAsk } = await evaluatePermissionRules( - pm, - defaultPermission, - pmCtx, - ); - - const needsConfirmation = finalPermission === 'ask'; + const { finalPermission, pmForcedAsk, pmCtx, denyMessage } = flowResult; // ---- L5: ApprovalMode overrides ---- const isPlanMode = approvalMode === ApprovalMode.PLAN; if (finalPermission === 'deny') { return earlyErrorResponse( - new Error( - defaultPermission === 'deny' - ? `Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.` - : `Tool "${fc.name}" is denied by permission rules.`, - ), + new Error(denyMessage ?? `Tool "${fc.name}" is denied.`), fc.name, ); } @@ -1423,7 +1407,7 @@ export class Session implements SessionContext { let didRequestPermission = false; let confirmationDetails: ToolCallConfirmationDetails | undefined; - if (needsConfirmation) { + if (needsConfirmation(finalPermission, approvalMode, fc.name)) { confirmationDetails = await invocation.getConfirmationDetails(abortSignal); @@ -1431,10 +1415,12 @@ export class Session implements SessionContext { injectPermissionRulesIfMissing(confirmationDetails, pmCtx); if ( - isPlanMode && - !isExitPlanModeTool && - !isAskUserQuestionTool && - confirmationDetails.type !== 'info' + isPlanModeBlocked( + isPlanMode, + isExitPlanModeTool, + isAskUserQuestionTool, + confirmationDetails, + ) ) { return earlyErrorResponse( new Error( diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 09a6c4ccf..86761e548 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -52,11 +52,15 @@ import { CONCURRENCY_SAFE_KINDS } from '../tools/tools.js'; import { isShellCommandReadOnly } from '../utils/shellReadOnlyChecker.js'; import { stripShellWrapper } from '../utils/shell-utils.js'; import { - buildPermissionCheckContext, - evaluatePermissionRules, injectPermissionRulesIfMissing, persistPermissionOutcome, } from './permission-helpers.js'; +import { + evaluatePermissionFlow, + needsConfirmation, + isPlanModeBlocked, + isAutoEditApproved, +} from './permissionFlow.js'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; import type { ModifyContext } from '../tools/modifiable-tool.js'; import { @@ -987,20 +991,16 @@ export class CoreToolScheduler { // L3→L4→L5 Permission Flow // ================================================================= - // ---- L3: Tool's default permission ---- - const defaultPermission: string = - await invocation.getDefaultPermission(); - - // ---- L4: PermissionManager override (if relevant rules exist) ---- - const pm = this.config.getPermissionManager?.(); + // ---- L3→L4: Shared permission flow ---- const toolParams = invocation.params as Record; - const pmCtx = buildPermissionCheckContext( + const flowResult = await evaluatePermissionFlow( + this.config, + invocation, reqInfo.name, toolParams, - this.config.getTargetDir?.() ?? '', ); - const { finalPermission, pmForcedAsk } = - await evaluatePermissionRules(pm, defaultPermission, pmCtx); + const { finalPermission, pmForcedAsk, pmCtx, denyMessage } = + flowResult; // ---- L5: Final decision based on permission + ApprovalMode ---- const approvalMode = this.config.getApprovalMode(); @@ -1019,22 +1019,12 @@ export class CoreToolScheduler { if (finalPermission === 'deny') { // Hard deny: security violation or PM explicit deny - let denyMessage: string; - if (defaultPermission === 'deny') { - denyMessage = `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`; - } else { - const matchingRule = pm?.findMatchingDenyRule(pmCtx); - const ruleInfo = matchingRule - ? ` Matching deny rule: "${matchingRule}".` - : ''; - denyMessage = `Tool "${reqInfo.name}" is denied by permission rules.${ruleInfo}`; - } this.setStatusInternal( reqInfo.callId, 'error', createErrorResponse( reqInfo, - new Error(denyMessage), + new Error(denyMessage ?? `Tool "${reqInfo.name}" is denied.`), ToolErrorType.EXECUTION_DENIED, ), ); @@ -1049,7 +1039,7 @@ export class CoreToolScheduler { reqInfo.name === ToolNames.ASK_USER_QUESTION; let confirmationDetails: ToolCallConfirmationDetails | undefined; - if (approvalMode === ApprovalMode.YOLO && !isAskUserQuestionTool) { + if (!needsConfirmation(finalPermission, approvalMode, reqInfo.name)) { this.setToolCallOutcome( reqInfo.callId, ToolConfirmationOutcome.ProceedAlways, @@ -1063,10 +1053,12 @@ export class CoreToolScheduler { injectPermissionRulesIfMissing(confirmationDetails, pmCtx); if ( - isPlanMode && - !isExitPlanModeTool && - !isAskUserQuestionTool && - confirmationDetails.type !== 'info' + isPlanModeBlocked( + isPlanMode, + isExitPlanModeTool, + isAskUserQuestionTool, + confirmationDetails, + ) ) { this.setStatusInternal(reqInfo.callId, 'error', { callId: reqInfo.callId, @@ -1083,11 +1075,7 @@ export class CoreToolScheduler { } // AUTO_EDIT mode: auto-approve edit-like and info tools - if ( - approvalMode === ApprovalMode.AUTO_EDIT && - (confirmationDetails.type === 'edit' || - confirmationDetails.type === 'info') - ) { + if (isAutoEditApproved(approvalMode, confirmationDetails)) { this.setToolCallOutcome( reqInfo.callId, ToolConfirmationOutcome.ProceedAlways, @@ -1917,22 +1905,17 @@ export class CoreToolScheduler { for (const pendingTool of pendingTools) { try { // Re-run L3→L4 to see if the tool can now be auto-approved - const defaultPermission = - await pendingTool.invocation.getDefaultPermission(); const toolParams = pendingTool.invocation.params as Record< string, unknown >; - const pmCtx = buildPermissionCheckContext( + const flowResult = await evaluatePermissionFlow( + this.config, + pendingTool.invocation, pendingTool.request.name, toolParams, - this.config.getTargetDir?.() ?? '', - ); - const { finalPermission } = await evaluatePermissionRules( - this.config.getPermissionManager?.(), - defaultPermission, - pmCtx, ); + const { finalPermission } = flowResult; if (finalPermission === 'allow') { this.setToolCallOutcome( diff --git a/packages/core/src/core/permissionFlow.test.ts b/packages/core/src/core/permissionFlow.test.ts new file mode 100644 index 000000000..2715a0afe --- /dev/null +++ b/packages/core/src/core/permissionFlow.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Config } from '../index.js'; +import type { AnyToolInvocation } from '../index.js'; +import { ApprovalMode, ToolNames } from '../index.js'; +import type { ToolCallConfirmationDetails } from '../tools/tools.js'; + +// Import the functions we're testing +import { + evaluatePermissionFlow, + needsConfirmation, + isPlanModeBlocked, + isAutoEditApproved, +} from './permissionFlow.js'; + +// Mock types for testing +const mockConfig = (overrides: Partial = {}): Config => + ({ + getPermissionManager: vi.fn().mockReturnValue(null), + getTargetDir: vi.fn().mockReturnValue('/test'), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + ...overrides, + }) as unknown as Config; + +const mockInvocation = ( + overrides: Partial = {}, +): AnyToolInvocation => + ({ + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + getConfirmationDetails: vi.fn().mockResolvedValue({ + type: 'exec', + title: 'Test', + command: 'echo hello', + }), + params: {}, + ...overrides, + }) as unknown as AnyToolInvocation; + +describe('evaluatePermissionFlow', () => { + it('should return deny result with correct message when defaultPermission is deny', async () => { + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('deny'), + }); + + const result = await evaluatePermissionFlow( + mockConfig(), + invocation, + 'shell', + { command: 'rm -rf /' }, + ); + + expect(result.finalPermission).toBe('deny'); + expect(result.denyMessage).toContain("tool's default permission is 'deny'"); + expect(result.pmCtx).toBeDefined(); + }); + + it('should return deny result with PM rule info when PM denies', async () => { + const mockPm = { + hasRelevantRules: vi.fn().mockReturnValue(true), + evaluate: vi.fn().mockResolvedValue('deny'), + findMatchingDenyRule: vi.fn().mockReturnValue('deny rm -rf *'), + hasMatchingAskRule: vi.fn().mockReturnValue(false), + }; + + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + }); + + const config = mockConfig({ + getPermissionManager: vi.fn().mockReturnValue(mockPm), + }); + + const result = await evaluatePermissionFlow(config, invocation, 'shell', { + command: 'rm -rf /', + }); + + expect(result.finalPermission).toBe('deny'); + expect(result.denyMessage).toContain('denied by permission rules'); + expect(result.denyMessage).toContain('Matching deny rule'); + }); + + it('should return ask permission when PM has no relevant rules', async () => { + const mockPm = { + hasRelevantRules: vi.fn().mockReturnValue(false), + }; + + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + }); + + const config = mockConfig({ + getPermissionManager: vi.fn().mockReturnValue(mockPm), + }); + + const result = await evaluatePermissionFlow(config, invocation, 'shell', { + command: 'echo hello', + }); + + expect(result.finalPermission).toBe('ask'); + expect(result.denyMessage).toBeUndefined(); + }); + + it('should set pmForcedAsk when PM has matching ask rule', async () => { + const mockPm = { + hasRelevantRules: vi.fn().mockReturnValue(true), + evaluate: vi.fn().mockResolvedValue('ask'), + hasMatchingAskRule: vi.fn().mockReturnValue(true), + }; + + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + }); + + const config = mockConfig({ + getPermissionManager: vi.fn().mockReturnValue(mockPm), + }); + + const result = await evaluatePermissionFlow(config, invocation, 'shell', { + command: 'echo hello', + }); + + expect(result.finalPermission).toBe('ask'); + expect(result.pmForcedAsk).toBe(true); + }); +}); + +describe('needsConfirmation', () => { + it('should return false for YOLO mode non-ask_user_question tools', () => { + expect(needsConfirmation('ask', ApprovalMode.YOLO, 'shell')).toBe(false); + expect(needsConfirmation('default', ApprovalMode.YOLO, 'read_file')).toBe( + false, + ); + }); + + it('should return true for ask_user_question in YOLO mode', () => { + expect( + needsConfirmation('ask', ApprovalMode.YOLO, ToolNames.ASK_USER_QUESTION), + ).toBe(true); + }); + + it('should return true when finalPermission is ask or default', () => { + expect(needsConfirmation('ask', ApprovalMode.DEFAULT, 'shell')).toBe(true); + expect(needsConfirmation('default', ApprovalMode.DEFAULT, 'shell')).toBe( + true, + ); + }); + + it('should return false when finalPermission is allow or deny', () => { + expect(needsConfirmation('allow', ApprovalMode.DEFAULT, 'shell')).toBe( + false, + ); + expect(needsConfirmation('deny', ApprovalMode.DEFAULT, 'shell')).toBe( + false, + ); + }); +}); + +describe('isPlanModeBlocked', () => { + const mockConfirmationDetails = (type: string): ToolCallConfirmationDetails => + ({ type }) as unknown as ToolCallConfirmationDetails; + + it('should block non-info tools in plan mode', () => { + expect( + isPlanModeBlocked(true, false, false, mockConfirmationDetails('exec')), + ).toBe(true); + + expect( + isPlanModeBlocked(true, false, false, mockConfirmationDetails('edit')), + ).toBe(true); + }); + + it('should not block info-type tools in plan mode', () => { + expect( + isPlanModeBlocked(true, false, false, mockConfirmationDetails('info')), + ).toBe(false); + }); + + it('should not block exit_plan_mode tool', () => { + expect( + isPlanModeBlocked(true, true, false, mockConfirmationDetails('exec')), + ).toBe(false); + }); + + it('should not block ask_user_question tool', () => { + expect( + isPlanModeBlocked(true, false, true, mockConfirmationDetails('exec')), + ).toBe(false); + }); + + it('should not block when not in plan mode', () => { + expect( + isPlanModeBlocked(false, false, false, mockConfirmationDetails('exec')), + ).toBe(false); + }); +}); + +describe('isAutoEditApproved', () => { + const mockConfirmationDetails = (type: string): ToolCallConfirmationDetails => + ({ type }) as unknown as ToolCallConfirmationDetails; + + it('should auto-approve edit-type tools in AUTO_EDIT mode', () => { + expect( + isAutoEditApproved( + ApprovalMode.AUTO_EDIT, + mockConfirmationDetails('edit'), + ), + ).toBe(true); + }); + + it('should auto-approve info-type tools in AUTO_EDIT mode', () => { + expect( + isAutoEditApproved( + ApprovalMode.AUTO_EDIT, + mockConfirmationDetails('info'), + ), + ).toBe(true); + }); + + it('should not auto-approve exec-type tools in AUTO_EDIT mode', () => { + expect( + isAutoEditApproved( + ApprovalMode.AUTO_EDIT, + mockConfirmationDetails('exec'), + ), + ).toBe(false); + }); + + it('should not auto-approve in non-AUTO_EDIT mode', () => { + expect( + isAutoEditApproved(ApprovalMode.DEFAULT, mockConfirmationDetails('edit')), + ).toBe(false); + }); +}); diff --git a/packages/core/src/core/permissionFlow.ts b/packages/core/src/core/permissionFlow.ts new file mode 100644 index 000000000..96e6b225f --- /dev/null +++ b/packages/core/src/core/permissionFlow.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared permission flow (L3→L4) for tool execution. + * + * Used by both `CoreToolScheduler` (CLI mode) and `Session` (ACP mode) + * to ensure consistent permission evaluation. + * + * L3: Tool's intrinsic default permission + * L4: PermissionManager rule override + * + * L5 overrides (ApprovalMode: YOLO, AUTO_EDIT, PLAN) are handled by + * the callers because some (plan mode, AUTO_EDIT) need + * `confirmationDetails.type` which is only available after calling + * `invocation.getConfirmationDetails()`. + */ + +import type { AnyToolInvocation, Config } from '../index.js'; +import { ApprovalMode, ToolNames } from '../index.js'; +import { + buildPermissionCheckContext, + evaluatePermissionRules, +} from './permission-helpers.js'; +import type { ToolCallConfirmationDetails } from '../tools/tools.js'; + +export type PermissionFlowPermission = 'allow' | 'deny' | 'ask' | 'default'; + +export interface PermissionFlowResult { + /** The final permission after L3→L4 (allow | deny | ask | default) */ + finalPermission: PermissionFlowPermission; + /** Whether PM forced 'ask' (hides "Always Allow" buttons) */ + pmForcedAsk: boolean; + /** Deny message (only set when finalPermission === 'deny') */ + denyMessage?: string; + /** Permission check context (needed for injectPermissionRulesIfMissing) */ + pmCtx: ReturnType; +} + +/** + * Execute the L3→L4 permission flow. + * + * @param config - The CLI config + * @param invocation - The tool invocation + * @param toolName - Name of the tool being called + * @param toolParams - Parameters passed to the tool + * @returns The permission decision and related metadata. + * `finalPermission` can be 'allow', 'deny', 'ask', or 'default'. + * The 'default' state is produced when the tool's default permission + * returns something other than the standard values (e.g. an edge case + * in the tool's getDefaultPermission implementation). + */ +export async function evaluatePermissionFlow( + config: Config, + invocation: AnyToolInvocation, + toolName: string, + toolParams: Record, +): Promise { + // ── L3: Tool's default permission ─────────────────────────────────── + const defaultPermission: string = await invocation.getDefaultPermission(); + + // ── L4: PermissionManager override ────────────────────────────────── + const pm = config.getPermissionManager?.(); + const pmCtx = buildPermissionCheckContext( + toolName, + toolParams, + config.getTargetDir?.() ?? '', + ); + const { finalPermission, pmForcedAsk } = await evaluatePermissionRules( + pm, + defaultPermission, + pmCtx, + ); + + // Build result + const result: PermissionFlowResult = { + finalPermission: finalPermission as PermissionFlowPermission, + pmForcedAsk, + pmCtx, + }; + + // Add deny message if denied + if (finalPermission === 'deny') { + if (defaultPermission === 'deny') { + result.denyMessage = `Tool "${toolName}" is denied: the tool's default permission is 'deny'.`; + } else { + const matchingRule = pm?.findMatchingDenyRule(pmCtx); + const ruleInfo = matchingRule + ? ` Matching deny rule: "${matchingRule}".` + : ''; + result.denyMessage = `Tool "${toolName}" is denied by permission rules.${ruleInfo}`; + } + } + + return result; +} + +/** + * Check if the tool needs user confirmation based on the permission flow + * result and the current ApprovalMode. + * + * This handles the YOLO mode override (L5) which doesn't require + * confirmationDetails. + * + * Note: Plan mode and AUTO_EDIT mode are L5 overrides that need + * confirmationDetails.type - callers must handle those separately. + */ +export function needsConfirmation( + finalPermission: PermissionFlowPermission, + approvalMode: ApprovalMode, + toolName: string, +): boolean { + const isAskUserQuestionTool = toolName === ToolNames.ASK_USER_QUESTION; + + // YOLO mode auto-approves everything except ask_user_question + if (approvalMode === ApprovalMode.YOLO && !isAskUserQuestionTool) { + return false; + } + + return finalPermission === 'ask' || finalPermission === 'default'; +} + +/** + * Check if plan mode blocks the tool execution. + * + * This must be called AFTER getting confirmationDetails because it needs + * `confirmationDetails.type`. + */ +export function isPlanModeBlocked( + isPlanMode: boolean, + isExitPlanModeTool: boolean, + isAskUserQuestionTool: boolean, + confirmationDetails?: ToolCallConfirmationDetails, +): boolean { + return ( + isPlanMode && + !isExitPlanModeTool && + !isAskUserQuestionTool && + confirmationDetails?.type !== 'info' + ); +} + +/** + * Check if AUTO_EDIT mode auto-approves the tool. + * + * This must be called AFTER getting confirmationDetails because it needs + * `confirmationDetails.type`. + */ +export function isAutoEditApproved( + approvalMode: ApprovalMode, + confirmationDetails?: ToolCallConfirmationDetails, +): boolean { + return ( + approvalMode === ApprovalMode.AUTO_EDIT && + (confirmationDetails?.type === 'edit' || + confirmationDetails?.type === 'info') + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2e8b5d69..ed751c931 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,7 @@ export * from './output/types.js'; export * from './core/client.js'; export * from './core/contentGenerator.js'; export * from './core/coreToolScheduler.js'; +export * from './core/permissionFlow.js'; export * from './core/permission-helpers.js'; export * from './core/geminiChat.js'; export * from './core/geminiRequest.js';