Merge branch 'main' into mb/prompts

This commit is contained in:
Michael Bleigh
2026-03-23 10:57:24 -07:00
committed by GitHub
42 changed files with 2575 additions and 597 deletions
@@ -32,9 +32,7 @@ describe('AgentSession', () => {
await session.abort();
expect(
session.events.some(
(e) =>
e.type === 'agent_end' &&
(e as AgentEvent<'agent_end'>).reason === 'aborted',
(e) => e.type === 'agent_end' && e.reason === 'aborted',
),
).toBe(true);
});
@@ -0,0 +1,733 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { FinishReason } from '@google/genai';
import { ToolErrorType } from '../tools/tool-error.js';
import {
translateEvent,
createTranslationState,
mapFinishReason,
mapHttpToGrpcStatus,
mapError,
mapUsage,
type TranslationState,
} from './event-translator.js';
import { GeminiEventType } from '../core/turn.js';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import type { AgentEvent } from './types.js';
describe('createTranslationState', () => {
it('creates state with default streamId', () => {
const state = createTranslationState();
expect(state.streamId).toBeDefined();
expect(state.streamStartEmitted).toBe(false);
expect(state.model).toBeUndefined();
expect(state.eventCounter).toBe(0);
expect(state.pendingToolNames.size).toBe(0);
});
it('creates state with custom streamId', () => {
const state = createTranslationState('custom-stream');
expect(state.streamId).toBe('custom-stream');
});
});
describe('translateEvent', () => {
let state: TranslationState;
beforeEach(() => {
state = createTranslationState('test-stream');
});
describe('Content events', () => {
it('emits agent_start + message for first content event', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'Hello world',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(2);
expect(result[0]?.type).toBe('agent_start');
expect(result[1]?.type).toBe('message');
const msg = result[1] as AgentEvent<'message'>;
expect(msg.role).toBe('agent');
expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]);
});
it('skips agent_start for subsequent content events', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'more text',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('message');
});
});
describe('Thought events', () => {
it('emits thought content with metadata', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: { subject: 'Planning', description: 'I am thinking...' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const msg = result[0] as AgentEvent<'message'>;
expect(msg.content).toEqual([
{ type: 'thought', thought: 'I am thinking...' },
]);
expect(msg._meta?.['subject']).toBe('Planning');
});
});
describe('ToolCallRequest events', () => {
it('emits tool_request and tracks pending tool name', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'call-1',
name: 'read_file',
args: { path: '/tmp/test' },
isClientInitiated: false,
prompt_id: 'p1',
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const req = result[0] as AgentEvent<'tool_request'>;
expect(req.requestId).toBe('call-1');
expect(req.name).toBe('read_file');
expect(req.args).toEqual({ path: '/tmp/test' });
expect(state.pendingToolNames.get('call-1')).toBe('read_file');
});
});
describe('ToolCallResponse events', () => {
it('emits tool_response with content from responseParts', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-1', 'read_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-1',
responseParts: [{ text: 'file contents' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.requestId).toBe('call-1');
expect(resp.name).toBe('read_file');
expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]);
expect(resp.isError).toBe(false);
expect(state.pendingToolNames.has('call-1')).toBe(false);
});
it('uses error.message for content when tool errored', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-2', 'write_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-2',
responseParts: [{ text: 'stale parts' }],
resultDisplay: 'Permission denied',
error: new Error('Permission denied to write'),
errorType: ToolErrorType.PERMISSION_DENIED,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.isError).toBe(true);
// Should use error.message, not responseParts
expect(resp.content).toEqual([
{ type: 'text', text: 'Permission denied to write' },
]);
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Permission denied' },
]);
expect(resp.data).toEqual({ errorType: 'permission_denied' });
});
it('uses "unknown" name for untracked tool calls', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'untracked',
responseParts: [{ text: 'data' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.name).toBe('unknown');
});
it('stringifies object resultDisplay correctly', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-3', 'diff_tool');
const objectDisplay = {
fileDiff: '@@ -1 +1 @@\n-a\n+b',
fileName: 'test.txt',
filePath: '/tmp/test.txt',
originalContent: 'a',
newContent: 'b',
};
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-3',
responseParts: [{ text: 'diff result' }],
resultDisplay: objectDisplay,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: JSON.stringify(objectDisplay) },
]);
});
it('passes through string resultDisplay as-is', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-4', 'shell');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-4',
responseParts: [{ text: 'output' }],
resultDisplay: 'Command output text',
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.displayContent).toEqual([
{ type: 'text', text: 'Command output text' },
]);
});
it('preserves outputFile and contentLength in data', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-5', 'write_file');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-5',
responseParts: [{ text: 'written' }],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
outputFile: '/tmp/out.txt',
contentLength: 42,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.data?.['outputFile']).toBe('/tmp/out.txt');
expect(resp.data?.['contentLength']).toBe(42);
});
it('handles multi-part responses (text + inlineData)', () => {
state.streamStartEmitted = true;
state.pendingToolNames.set('call-6', 'screenshot');
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallResponse,
value: {
callId: 'call-6',
responseParts: [
{ text: 'Here is the screenshot' },
{ inlineData: { data: 'base64img', mimeType: 'image/png' } },
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
};
const result = translateEvent(event, state);
const resp = result[0] as AgentEvent<'tool_response'>;
expect(resp.content).toEqual([
{ type: 'text', text: 'Here is the screenshot' },
{ type: 'media', data: 'base64img', mimeType: 'image/png' },
]);
expect(resp.isError).toBe(false);
});
});
describe('Error events', () => {
it('emits error event for structured errors', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: { error: { message: 'Rate limited', status: 429 } },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('RESOURCE_EXHAUSTED');
expect(err.message).toBe('Rate limited');
expect(err.fatal).toBe(true);
});
it('emits error event for Error instances', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: { error: new Error('Something broke') },
};
const result = translateEvent(event, state);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Something broke');
});
});
describe('ModelInfo events', () => {
it('emits agent_start and session_update when no stream started yet', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ModelInfo,
value: 'gemini-2.5-pro',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(2);
expect(result[0]?.type).toBe('agent_start');
expect(result[1]?.type).toBe('session_update');
const sessionUpdate = result[1] as AgentEvent<'session_update'>;
expect(sessionUpdate.model).toBe('gemini-2.5-pro');
expect(state.model).toBe('gemini-2.5-pro');
expect(state.streamStartEmitted).toBe(true);
});
it('emits session_update when stream already started', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ModelInfo,
value: 'gemini-2.5-flash',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('session_update');
});
});
describe('AgentExecutionStopped events', () => {
it('emits agent_end with the final stop message in data.message', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionStopped,
value: {
reason: 'before_model',
systemMessage: 'Stopped by hook',
contextCleared: true,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.type).toBe('agent_end');
expect(streamEnd.reason).toBe('completed');
expect(streamEnd.data).toEqual({ message: 'Stopped by hook' });
});
it('uses reason when systemMessage is not set', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionStopped,
value: { reason: 'hook' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.data).toEqual({ message: 'hook' });
});
});
describe('AgentExecutionBlocked events', () => {
it('emits non-fatal error event (non-terminal, stream continues)', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Policy violation' },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.type).toBe('error');
expect(err.fatal).toBe(false);
expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED');
expect(err.message).toBe('Agent execution blocked: Policy violation');
});
it('uses systemMessage in the final error message when available', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.AgentExecutionBlocked,
value: {
reason: 'hook_blocked',
systemMessage: 'Blocked by policy hook',
contextCleared: true,
},
};
const result = translateEvent(event, state);
const err = result[0] as AgentEvent<'error'>;
expect(err.message).toBe(
'Agent execution blocked: Blocked by policy hook',
);
});
});
describe('LoopDetected events', () => {
it('emits a non-fatal warning error event', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.LoopDetected,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe('error');
const loopWarning = result[0] as AgentEvent<'error'>;
expect(loopWarning.fatal).toBe(false);
expect(loopWarning.message).toBe('Loop detected, stopping execution');
expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED');
});
});
describe('MaxSessionTurns events', () => {
it('emits agent_end with max_turns', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.MaxSessionTurns,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const streamEnd = result[0] as AgentEvent<'agent_end'>;
expect(streamEnd.type).toBe('agent_end');
expect(streamEnd.reason).toBe('max_turns');
expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' });
});
});
describe('Finished events', () => {
it('emits usage for STOP', () => {
state.streamStartEmitted = true;
state.model = 'gemini-2.5-pro';
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: {
reason: FinishReason.STOP,
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
},
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const usage = result[0] as AgentEvent<'usage'>;
expect(usage.model).toBe('gemini-2.5-pro');
expect(usage.inputTokens).toBe(100);
expect(usage.outputTokens).toBe(50);
expect(usage.cachedTokens).toBe(10);
});
it('emits nothing when no usage metadata is present', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
};
const result = translateEvent(event, state);
expect(result).toHaveLength(0);
});
});
describe('Citation events', () => {
it('emits message with citation meta', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 'Source: example.com',
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const msg = result[0] as AgentEvent<'message'>;
expect(msg.content).toEqual([
{ type: 'text', text: 'Source: example.com' },
]);
expect(msg._meta?.['citation']).toBe(true);
});
});
describe('UserCancelled events', () => {
it('emits agent_end with reason aborted', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.UserCancelled,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const end = result[0] as AgentEvent<'agent_end'>;
expect(end.type).toBe('agent_end');
expect(end.reason).toBe('aborted');
});
});
describe('ContextWindowWillOverflow events', () => {
it('emits fatal error', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ContextWindowWillOverflow,
value: {
estimatedRequestTokenCount: 150000,
remainingTokenCount: 10000,
},
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('RESOURCE_EXHAUSTED');
expect(err.fatal).toBe(true);
expect(err.message).toContain('150000');
expect(err.message).toContain('10000');
});
});
describe('InvalidStream events', () => {
it('emits fatal error', () => {
state.streamStartEmitted = true;
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.InvalidStream,
};
const result = translateEvent(event, state);
expect(result).toHaveLength(1);
const err = result[0] as AgentEvent<'error'>;
expect(err.status).toBe('INTERNAL');
expect(err.message).toBe('Invalid stream received from model');
expect(err.fatal).toBe(true);
});
});
describe('Events with no output', () => {
it('returns empty for Retry', () => {
const result = translateEvent({ type: GeminiEventType.Retry }, state);
expect(result).toEqual([]);
});
it('returns empty for ChatCompressed with null', () => {
const result = translateEvent(
{ type: GeminiEventType.ChatCompressed, value: null },
state,
);
expect(result).toEqual([]);
});
it('returns empty for ToolCallConfirmation', () => {
// ToolCallConfirmation is skipped in non-interactive mode (elicitations
// are deferred to the interactive runtime adaptation).
const event = {
type: GeminiEventType.ToolCallConfirmation,
value: {
request: {
callId: 'c1',
name: 'tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' },
},
} as ServerGeminiStreamEvent;
const result = translateEvent(event, state);
expect(result).toEqual([]);
});
});
describe('Event IDs', () => {
it('generates sequential IDs', () => {
state.streamStartEmitted = true;
const e1 = translateEvent(
{ type: GeminiEventType.Content, value: 'a' },
state,
);
const e2 = translateEvent(
{ type: GeminiEventType.Content, value: 'b' },
state,
);
expect(e1[0]?.id).toBe('test-stream-0');
expect(e2[0]?.id).toBe('test-stream-1');
});
it('includes streamId in events', () => {
const events = translateEvent(
{ type: GeminiEventType.Content, value: 'hi' },
state,
);
for (const e of events) {
expect(e.streamId).toBe('test-stream');
}
});
});
});
describe('mapFinishReason', () => {
it('maps STOP to completed', () => {
expect(mapFinishReason(FinishReason.STOP)).toBe('completed');
});
it('maps undefined to completed', () => {
expect(mapFinishReason(undefined)).toBe('completed');
});
it('maps MAX_TOKENS to max_budget', () => {
expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget');
});
it('maps SAFETY to refusal', () => {
expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal');
});
it('maps MALFORMED_FUNCTION_CALL to failed', () => {
expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe(
'failed',
);
});
it('maps RECITATION to refusal', () => {
expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal');
});
it('maps LANGUAGE to refusal', () => {
expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal');
});
it('maps BLOCKLIST to refusal', () => {
expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal');
});
it('maps OTHER to failed', () => {
expect(mapFinishReason(FinishReason.OTHER)).toBe('failed');
});
it('maps PROHIBITED_CONTENT to refusal', () => {
expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal');
});
it('maps IMAGE_SAFETY to refusal', () => {
expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal');
});
it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => {
expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe(
'refusal',
);
});
it('maps UNEXPECTED_TOOL_CALL to failed', () => {
expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed');
});
it('maps NO_IMAGE to failed', () => {
expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed');
});
});
describe('mapHttpToGrpcStatus', () => {
it('maps 400 to INVALID_ARGUMENT', () => {
expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT');
});
it('maps 401 to UNAUTHENTICATED', () => {
expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED');
});
it('maps 429 to RESOURCE_EXHAUSTED', () => {
expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED');
});
it('maps undefined to INTERNAL', () => {
expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL');
});
it('maps unknown codes to INTERNAL', () => {
expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL');
});
});
describe('mapError', () => {
it('maps structured errors with status', () => {
const result = mapError({ message: 'Rate limit', status: 429 });
expect(result.status).toBe('RESOURCE_EXHAUSTED');
expect(result.message).toBe('Rate limit');
expect(result.fatal).toBe(true);
expect(result._meta?.['rawError']).toEqual({
message: 'Rate limit',
status: 429,
});
});
it('maps Error instances', () => {
const result = mapError(new Error('Something failed'));
expect(result.status).toBe('INTERNAL');
expect(result.message).toBe('Something failed');
});
it('preserves error name in _meta', () => {
class CustomError extends Error {
constructor(msg: string) {
super(msg);
}
}
const result = mapError(new CustomError('test'));
expect(result._meta?.['errorName']).toBe('CustomError');
});
it('maps non-Error values to string', () => {
const result = mapError('raw string error');
expect(result.message).toBe('raw string error');
expect(result.status).toBe('INTERNAL');
});
});
describe('mapUsage', () => {
it('maps all fields', () => {
const result = mapUsage(
{
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 25,
},
'gemini-2.5-pro',
);
expect(result).toEqual({
model: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 50,
cachedTokens: 25,
});
});
it('uses "unknown" for missing model', () => {
const result = mapUsage({});
expect(result.model).toBe('unknown');
});
});
+457
View File
@@ -0,0 +1,457 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Pure, stateless-per-call translation functions that convert
* ServerGeminiStreamEvent objects into AgentEvent objects.
*
* No side effects, no generators. Each call to `translateEvent` takes an event
* and mutable TranslationState, returning zero or more AgentEvents.
*/
import type { FinishReason } from '@google/genai';
import { GeminiEventType } from '../core/turn.js';
import type {
ServerGeminiStreamEvent,
StructuredError,
GeminiFinishedEventValue,
} from '../core/turn.js';
import type {
AgentEvent,
StreamEndReason,
ErrorData,
Usage,
AgentEventType,
} from './types.js';
import {
geminiPartsToContentParts,
toolResultDisplayToContentParts,
buildToolResponseData,
} from './content-utils.js';
// ---------------------------------------------------------------------------
// Translation State
// ---------------------------------------------------------------------------
export interface TranslationState {
streamId: string;
streamStartEmitted: boolean;
model: string | undefined;
eventCounter: number;
/** Tracks callId → tool name from requests so responses can reference the name. */
pendingToolNames: Map<string, string>;
}
export function createTranslationState(streamId?: string): TranslationState {
return {
streamId: streamId ?? crypto.randomUUID(),
streamStartEmitted: false,
model: undefined,
eventCounter: 0,
pendingToolNames: new Map(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeEvent<T extends AgentEventType>(
type: T,
state: TranslationState,
payload: Partial<AgentEvent<T>>,
): AgentEvent {
const id = `${state.streamId}-${state.eventCounter++}`;
// TypeScript cannot preserve the specific discriminated union member across
// this generic object assembly, so keep the narrowing local to the event
// constructor boundary.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...payload,
id,
timestamp: new Date().toISOString(),
streamId: state.streamId,
type,
} as AgentEvent;
}
function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void {
if (!state.streamStartEmitted) {
out.push(makeEvent('agent_start', state, {}));
state.streamStartEmitted = true;
}
}
// ---------------------------------------------------------------------------
// Core Translator
// ---------------------------------------------------------------------------
/**
* Translates a single ServerGeminiStreamEvent into zero or more AgentEvents.
* Mutates `state` (counter, flags) as a side effect.
*/
export function translateEvent(
event: ServerGeminiStreamEvent,
state: TranslationState,
): AgentEvent[] {
const out: AgentEvent[] = [];
switch (event.type) {
case GeminiEventType.ModelInfo:
state.model = event.value;
ensureStreamStart(state, out);
out.push(makeEvent('session_update', state, { model: event.value }));
break;
case GeminiEventType.Content:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'text', text: event.value }],
}),
);
break;
case GeminiEventType.Thought:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'thought', thought: event.value.description }],
_meta: event.value.subject
? { source: 'agent', subject: event.value.subject }
: { source: 'agent' },
}),
);
break;
case GeminiEventType.Citation:
ensureStreamStart(state, out);
out.push(
makeEvent('message', state, {
role: 'agent',
content: [{ type: 'text', text: event.value }],
_meta: { source: 'agent', citation: true },
}),
);
break;
case GeminiEventType.Finished:
handleFinished(event.value, state, out);
break;
case GeminiEventType.Error:
handleError(event.value.error, state, out);
break;
case GeminiEventType.UserCancelled:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'aborted',
}),
);
break;
case GeminiEventType.MaxSessionTurns:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'max_turns',
data: {
code: 'MAX_TURNS_EXCEEDED',
},
}),
);
break;
case GeminiEventType.LoopDetected:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Loop detected, stopping execution',
fatal: false,
_meta: { code: 'LOOP_DETECTED' },
}),
);
break;
case GeminiEventType.ContextWindowWillOverflow:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'RESOURCE_EXHAUSTED',
message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`,
fatal: true,
}),
);
break;
case GeminiEventType.AgentExecutionStopped:
ensureStreamStart(state, out);
out.push(
makeEvent('agent_end', state, {
reason: 'completed',
data: {
message: event.value.systemMessage?.trim() || event.value.reason,
},
}),
);
break;
case GeminiEventType.AgentExecutionBlocked:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'PERMISSION_DENIED',
message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`,
fatal: false,
_meta: { code: 'AGENT_EXECUTION_BLOCKED' },
}),
);
break;
case GeminiEventType.InvalidStream:
ensureStreamStart(state, out);
out.push(
makeEvent('error', state, {
status: 'INTERNAL',
message: 'Invalid stream received from model',
fatal: true,
}),
);
break;
case GeminiEventType.ToolCallRequest:
ensureStreamStart(state, out);
state.pendingToolNames.set(event.value.callId, event.value.name);
out.push(
makeEvent('tool_request', state, {
requestId: event.value.callId,
name: event.value.name,
args: event.value.args,
}),
);
break;
case GeminiEventType.ToolCallResponse: {
ensureStreamStart(state, out);
const displayContent = toolResultDisplayToContentParts(
event.value.resultDisplay,
);
const data = buildToolResponseData(event.value);
out.push(
makeEvent('tool_response', state, {
requestId: event.value.callId,
name: state.pendingToolNames.get(event.value.callId) ?? 'unknown',
content: event.value.error
? [{ type: 'text', text: event.value.error.message }]
: geminiPartsToContentParts(event.value.responseParts),
isError: event.value.error !== undefined,
...(displayContent ? { displayContent } : {}),
...(data ? { data } : {}),
}),
);
state.pendingToolNames.delete(event.value.callId);
break;
}
case GeminiEventType.ToolCallConfirmation:
// Elicitations are handled separately by the session layer
break;
// Internal concerns — no AgentEvent emitted
case GeminiEventType.ChatCompressed:
case GeminiEventType.Retry:
break;
default:
((x: never) => {
throw new Error(`Unhandled event type: ${JSON.stringify(x)}`);
})(event);
break;
}
return out;
}
// ---------------------------------------------------------------------------
// Finished Event Handling
// ---------------------------------------------------------------------------
function handleFinished(
value: GeminiFinishedEventValue,
state: TranslationState,
out: AgentEvent[],
): void {
if (value.usageMetadata) {
ensureStreamStart(state, out);
const usage = mapUsage(value.usageMetadata, state.model);
out.push(makeEvent('usage', state, usage));
}
}
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
function handleError(
error: unknown,
state: TranslationState,
out: AgentEvent[],
): void {
ensureStreamStart(state, out);
const mapped = mapError(error);
out.push(makeEvent('error', state, mapped));
}
// ---------------------------------------------------------------------------
// Public Mapping Functions
// ---------------------------------------------------------------------------
/**
* Maps a Gemini FinishReason to an AgentEnd reason.
*/
export function mapFinishReason(
reason: FinishReason | undefined,
): StreamEndReason {
if (!reason) return 'completed';
switch (reason) {
case 'STOP':
case 'FINISH_REASON_UNSPECIFIED':
return 'completed';
case 'MAX_TOKENS':
return 'max_budget';
case 'SAFETY':
case 'RECITATION':
case 'LANGUAGE':
case 'BLOCKLIST':
case 'PROHIBITED_CONTENT':
case 'SPII':
case 'IMAGE_SAFETY':
case 'IMAGE_PROHIBITED_CONTENT':
return 'refusal';
case 'MALFORMED_FUNCTION_CALL':
case 'OTHER':
case 'UNEXPECTED_TOOL_CALL':
case 'NO_IMAGE':
return 'failed';
default:
return 'failed';
}
}
/**
* Maps an HTTP status code to a gRPC-style status string.
*/
export function mapHttpToGrpcStatus(
httpStatus: number | undefined,
): ErrorData['status'] {
if (httpStatus === undefined) return 'INTERNAL';
switch (httpStatus) {
case 400:
return 'INVALID_ARGUMENT';
case 401:
return 'UNAUTHENTICATED';
case 403:
return 'PERMISSION_DENIED';
case 404:
return 'NOT_FOUND';
case 409:
return 'ALREADY_EXISTS';
case 429:
return 'RESOURCE_EXHAUSTED';
case 500:
return 'INTERNAL';
case 501:
return 'UNIMPLEMENTED';
case 503:
return 'UNAVAILABLE';
case 504:
return 'DEADLINE_EXCEEDED';
default:
return 'INTERNAL';
}
}
/**
* Maps a StructuredError (or unknown error value) to an ErrorData payload.
* Preserves selected error metadata in _meta and includes raw structured
* errors for lossless debugging.
*/
export function mapError(
error: unknown,
): ErrorData & { _meta?: Record<string, unknown> } {
const meta: Record<string, unknown> = {};
if (error instanceof Error) {
meta['errorName'] = error.constructor.name;
if ('exitCode' in error && typeof error.exitCode === 'number') {
meta['exitCode'] = error.exitCode;
}
if ('code' in error) {
meta['code'] = error.code;
}
}
if (isStructuredError(error)) {
const structuredMeta = { ...meta, rawError: error };
return {
status: mapHttpToGrpcStatus(error.status),
message: error.message,
fatal: true,
_meta: structuredMeta,
};
}
if (error instanceof Error) {
return {
status: 'INTERNAL',
message: error.message,
fatal: true,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
};
}
return {
status: 'INTERNAL',
message: String(error),
fatal: true,
};
}
function isStructuredError(error: unknown): error is StructuredError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
);
}
/**
* Maps Gemini usageMetadata to Usage.
*/
export function mapUsage(
metadata: {
promptTokenCount?: number;
candidatesTokenCount?: number;
cachedContentTokenCount?: number;
},
model?: string,
): Usage {
return {
model: model ?? 'unknown',
inputTokens: metadata.promptTokenCount,
outputTokens: metadata.candidatesTokenCount,
cachedTokens: metadata.cachedContentTokenCount,
};
}
+2
View File
@@ -86,6 +86,7 @@ export class MockAgentProtocol implements AgentProtocol {
) {
const now = new Date().toISOString();
for (const eventData of events) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const event: AgentEvent = {
...eventData,
id: eventData.id ?? `e-${this._nextEventId++}`,
@@ -126,6 +127,7 @@ export class MockAgentProtocol implements AgentProtocol {
// Helper to normalize and prepare for emission
const normalize = (eventData: MockAgentEvent): AgentEvent =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
...eventData,
id: eventData.id ?? `e-${this._nextEventId++}`,
+11 -2
View File
@@ -81,9 +81,18 @@ export type AgentEventData<
EventType extends keyof AgentEvents = keyof AgentEvents,
> = AgentEvents[EventType] & { type: EventType };
/**
* Mapped type that produces a proper discriminated union when `EventType` is
* the default (all keys), enabling `switch (event.type)` narrowing.
* When a specific EventType is provided, resolves to a single variant.
*/
export type AgentEvent<
EventType extends keyof AgentEvents = keyof AgentEvents,
> = AgentEventCommon & AgentEventData<EventType>;
> = {
[K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K };
}[EventType];
export type AgentEventType = keyof AgentEvents;
export interface AgentEvents {
/** MUST be the first event emitted in a session. */
@@ -263,7 +272,7 @@ export interface AgentStart {
streamId: string;
}
type StreamEndReason =
export type StreamEndReason =
| 'completed'
| 'failed'
| 'aborted'
@@ -0,0 +1,246 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Config, type ConfigParameters } from './config.js';
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { SubagentTool } from '../agents/subagent-tool.js';
// Mock minimum dependencies that have side effects or external calls
vi.mock('../core/client.js', () => ({
GeminiClient: vi.fn().mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
isInitialized: vi.fn().mockReturnValue(true),
setTools: vi.fn().mockResolvedValue(undefined),
updateSystemInstruction: vi.fn(),
})),
}));
vi.mock('../core/contentGenerator.js');
vi.mock('../telemetry/index.js');
vi.mock('../core/tokenLimits.js');
vi.mock('../services/fileDiscoveryService.js');
vi.mock('../services/gitService.js');
vi.mock('../services/trackerService.js');
describe('Config Agents Reload Integration', () => {
let tmpDir: string;
beforeEach(async () => {
// Create a temporary directory for the test
tmpDir = await createTmpDir({});
// Create the .gemini/agents directory structure
await fs.mkdir(path.join(tmpDir, '.gemini', 'agents'), { recursive: true });
});
afterEach(async () => {
await cleanupTmpDir(tmpDir);
vi.clearAllMocks();
});
it('should unregister subagents as tools when they are disabled after being enabled', async () => {
const agentName = 'test-agent';
const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`);
// Create agent definition file
const agentContent = `---
name: ${agentName}
description: Test Agent Description
tools: []
---
Test System Prompt`;
await fs.writeFile(agentPath, agentContent);
// Initialize Config with agent enabled to start
const baseParams: ConfigParameters = {
sessionId: 'test-session',
targetDir: tmpDir,
model: 'test-model',
cwd: tmpDir,
debugMode: false,
enableAgents: true,
agents: {
overrides: {
[agentName]: { enabled: true },
},
},
};
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(
config.getAcknowledgedAgentsService(),
'isAcknowledged',
).mockResolvedValue(true);
await config.initialize();
const toolRegistry = config.getToolRegistry();
// Verify the tool was registered initially
// Note: Subagent tools use the agent name as the tool name.
const initialTools = toolRegistry.getAllToolNames();
expect(initialTools).toContain(agentName);
const toolInstance = toolRegistry.getTool(agentName);
expect(toolInstance).toBeInstanceOf(SubagentTool);
// Disable agent in settings for reload simulation
vi.spyOn(config, 'getAgentsSettings').mockReturnValue({
overrides: {
[agentName]: { enabled: false },
},
});
// Trigger the refresh action that follows reloading
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
// 4. Verify the tool is UNREGISTERED
const finalTools = toolRegistry.getAllToolNames();
expect(finalTools).not.toContain(agentName);
expect(toolRegistry.getTool(agentName)).toBeUndefined();
});
it('should not register subagents as tools when agents are disabled from the start', async () => {
const agentName = 'test-agent-disabled';
const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`);
const agentContent = `---
name: ${agentName}
description: Test Agent Description
tools: []
---
Test System Prompt`;
await fs.writeFile(agentPath, agentContent);
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: tmpDir,
model: 'test-model',
cwd: tmpDir,
debugMode: false,
enableAgents: true,
agents: {
overrides: {
[agentName]: { enabled: false },
},
},
};
const config = new Config(params);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(
config.getAcknowledgedAgentsService(),
'isAcknowledged',
).mockResolvedValue(true);
await config.initialize();
const toolRegistry = config.getToolRegistry();
const tools = toolRegistry.getAllToolNames();
expect(tools).not.toContain(agentName);
expect(toolRegistry.getTool(agentName)).toBeUndefined();
});
it('should register subagents as tools even when they are not in allowedTools', async () => {
const agentName = 'test-agent-allowed';
const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`);
const agentContent = `---
name: ${agentName}
description: Test Agent Description
tools: []
---
Test System Prompt`;
await fs.writeFile(agentPath, agentContent);
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: tmpDir,
model: 'test-model',
cwd: tmpDir,
debugMode: false,
enableAgents: true,
allowedTools: ['ls'], // test-agent-allowed is NOT here
agents: {
overrides: {
[agentName]: { enabled: true },
},
},
};
const config = new Config(params);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(
config.getAcknowledgedAgentsService(),
'isAcknowledged',
).mockResolvedValue(true);
await config.initialize();
const toolRegistry = config.getToolRegistry();
const tools = toolRegistry.getAllToolNames();
expect(tools).toContain(agentName);
});
it('should register subagents as tools when they are enabled after being disabled', async () => {
const agentName = 'test-agent-enable';
const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`);
const agentContent = `---
name: ${agentName}
description: Test Agent Description
tools: []
---
Test System Prompt`;
await fs.writeFile(agentPath, agentContent);
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: tmpDir,
model: 'test-model',
cwd: tmpDir,
debugMode: false,
enableAgents: true,
agents: {
overrides: {
[agentName]: { enabled: false },
},
},
};
const config = new Config(params);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(
config.getAcknowledgedAgentsService(),
'isAcknowledged',
).mockResolvedValue(true);
await config.initialize();
const toolRegistry = config.getToolRegistry();
expect(toolRegistry.getAllToolNames()).not.toContain(agentName);
// Enable agent in settings for reload simulation
vi.spyOn(config, 'getAgentsSettings').mockReturnValue({
overrides: {
[agentName]: { enabled: true },
},
});
// Trigger refresh
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
expect(toolRegistry.getAllToolNames()).toContain(agentName);
});
});
+1 -118
View File
@@ -185,6 +185,7 @@ vi.mock('../agents/registry.js', () => {
const AgentRegistryMock = vi.fn();
AgentRegistryMock.prototype.initialize = vi.fn();
AgentRegistryMock.prototype.getAllDefinitions = vi.fn(() => []);
AgentRegistryMock.prototype.getAllDiscoveredAgentNames = vi.fn(() => []);
AgentRegistryMock.prototype.getDefinition = vi.fn();
return { AgentRegistry: AgentRegistryMock };
});
@@ -1237,124 +1238,6 @@ describe('Server Config (config.ts)', () => {
expect(wasReadFileToolRegistered).toBe(false);
});
it('should register subagents as tools when agents.overrides.codebase_investigator.enabled is true', async () => {
const params: ConfigParameters = {
...baseParams,
agents: {
overrides: {
codebase_investigator: { enabled: true },
},
},
};
const config = new Config(params);
const mockAgentDefinition = {
name: 'codebase_investigator',
description: 'Agent 1',
instructions: 'Inst 1',
};
const AgentRegistryMock = (
(await vi.importMock('../agents/registry.js')) as {
AgentRegistry: Mock;
}
).AgentRegistry;
AgentRegistryMock.prototype.getDefinition.mockReturnValue(
mockAgentDefinition,
);
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
mockAgentDefinition,
]);
const SubAgentToolMock = (
(await vi.importMock('../agents/subagent-tool.js')) as {
SubagentTool: Mock;
}
).SubagentTool;
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
expect(SubAgentToolMock).toHaveBeenCalledTimes(1);
expect(SubAgentToolMock).toHaveBeenCalledWith(
expect.anything(), // AgentRegistry
config,
expect.anything(), // MessageBus
);
const calls = registerToolMock.mock.calls;
const registeredWrappers = calls.filter(
(call) => call[0] instanceof SubAgentToolMock,
);
expect(registeredWrappers).toHaveLength(1);
});
it('should register subagents as tools even when they are not in allowedTools', async () => {
const params: ConfigParameters = {
...baseParams,
allowedTools: ['read_file'], // codebase_investigator is NOT here
agents: {
overrides: {
codebase_investigator: { enabled: true },
},
},
};
const config = new Config(params);
const mockAgentDefinition = {
name: 'codebase_investigator',
description: 'Agent 1',
instructions: 'Inst 1',
};
const AgentRegistryMock = (
(await vi.importMock('../agents/registry.js')) as {
AgentRegistry: Mock;
}
).AgentRegistry;
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
mockAgentDefinition,
]);
const SubAgentToolMock = (
(await vi.importMock('../agents/subagent-tool.js')) as {
SubagentTool: Mock;
}
).SubagentTool;
await config.initialize();
expect(SubAgentToolMock).toHaveBeenCalled();
});
it('should not register subagents as tools when agents are disabled', async () => {
const params: ConfigParameters = {
...baseParams,
agents: {
overrides: {
codebase_investigator: { enabled: false },
cli_help: { enabled: false },
},
},
};
const config = new Config(params);
const SubAgentToolMock = (
(await vi.importMock('../agents/subagent-tool.js')) as {
SubagentTool: Mock;
}
).SubagentTool;
await config.initialize();
expect(SubAgentToolMock).not.toHaveBeenCalled();
});
it('should register EnterPlanModeTool and ExitPlanModeTool when plan is enabled', async () => {
const params: ConfigParameters = {
...baseParams,
+21 -2
View File
@@ -3301,9 +3301,28 @@ export class Config implements McpContext, AgentLoopContext {
*/
private registerSubAgentTools(registry: ToolRegistry): void {
const agentsOverrides = this.getAgentsSettings().overrides ?? {};
const definitions = this.agentRegistry.getAllDefinitions();
const discoveredDefinitions =
this.agentRegistry.getAllDiscoveredAgentNames();
for (const definition of definitions) {
// First, unregister any agents that are now disabled
for (const agentName of discoveredDefinitions) {
if (
!this.isAgentsEnabled() ||
agentsOverrides[agentName]?.enabled === false
) {
const tool = registry.getTool(agentName);
if (tool instanceof SubagentTool) {
registry.unregisterTool(agentName);
}
}
}
const discoveredNames = this.agentRegistry.getAllDiscoveredAgentNames();
for (const agentName of discoveredNames) {
const definition = this.agentRegistry.getDiscoveredDefinition(agentName);
if (!definition) {
continue;
}
try {
if (
!this.isAgentsEnabled() ||
@@ -2409,6 +2409,8 @@ For example:
## Development Lifecycle
Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle.
**State Transition Override:** You are now in **Execution Mode**. All previous "Read-Only", "Plan Mode", and "ONLY FOR PLANS" constraints are **immediately lifted**. You are explicitly authorized and required to use tools to modify source code and environment files to implement the approved plan. Begin executing the steps of the plan immediately.
1. **Research:** Systematically map the codebase and validate assumptions. Use \`grep_search\` and \`glob\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to validate all assumptions. **Prioritize empirical reproduction of reported issues to confirm the failure state.**
2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').
3. **Execution:** For each sub-task:
+6 -3
View File
@@ -30,7 +30,10 @@ import { type MessageBus } from '../confirmation-bus/message-bus.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME, SENSITIVE_TOOLS } from '../tools/tool-names.js';
import {
SHELL_TOOL_NAME,
TOOLS_REQUIRING_NARROWING,
} from '../tools/tool-names.js';
import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX } from '../tools/mcp-tool.js';
@@ -560,7 +563,7 @@ export function createPolicyUpdater(
: WORKSPACE_POLICY_TIER;
const priority = tier + getAlwaysAllowPriorityFraction() / 1000;
if (SENSITIVE_TOOLS.has(toolName) && !message.commandPrefix) {
if (TOOLS_REQUIRING_NARROWING.has(toolName) && !message.commandPrefix) {
debugLogger.warn(
`Attempted to update policy for sensitive tool '${toolName}' without a commandPrefix. Skipping.`,
);
@@ -600,7 +603,7 @@ export function createPolicyUpdater(
: WORKSPACE_POLICY_TIER;
const priority = tier + getAlwaysAllowPriorityFraction() / 1000;
if (SENSITIVE_TOOLS.has(toolName) && !message.argsPattern) {
if (TOOLS_REQUIRING_NARROWING.has(toolName) && !message.argsPattern) {
debugLogger.warn(
`Attempted to update policy for sensitive tool '${toolName}' without an argsPattern. Skipping.`,
);
@@ -74,6 +74,12 @@ type = "in-process"
name = "allowed-path"
required_context = ["environment"]
[[rule]]
toolName = "web_fetch"
decision = "allow"
priority = 15
modes = ["autoEdit"]
[[rule]]
toolName = "web_fetch"
decision = "ask_user"
+6 -1
View File
@@ -315,11 +315,16 @@ export function renderPrimaryWorkflows(
options?: PrimaryWorkflowsOptions,
): string {
if (!options) return '';
const transitionOverride = options.approvedPlan
? `\n\n**State Transition Override:** You are now in **Execution Mode**. All previous "Read-Only", "Plan Mode", and "ONLY FOR PLANS" constraints are **immediately lifted**. You are explicitly authorized and required to use tools to modify source code and environment files to implement the approved plan. Begin executing the steps of the plan immediately.`
: '';
return `
# Primary Workflows
## Development Lifecycle
Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle.
Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle.${transitionOverride}
${workflowStepResearch(options)}
${workflowStepStrategy(options)}
@@ -4,24 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
describe('LinuxSandboxManager', () => {
const workspace = '/home/user/workspace';
let manager: LinuxSandboxManager;
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => {
const manager = new LinuxSandboxManager({ workspace });
const req: SandboxRequest = {
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
};
beforeEach(() => {
manager = new LinuxSandboxManager({ workspace });
});
const getBwrapArgs = async (req: SandboxRequest) => {
const result = await manager.prepareCommand(req);
expect(result.program).toBe('sh');
expect(result.args[0]).toBe('-c');
expect(result.args[1]).toBe(
@@ -29,8 +25,17 @@ describe('LinuxSandboxManager', () => {
);
expect(result.args[2]).toBe('_');
expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/);
return result.args.slice(4);
};
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
});
const bwrapArgs = result.args.slice(4);
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
@@ -56,55 +61,48 @@ describe('LinuxSandboxManager', () => {
});
it('maps allowedPaths to bwrap binds', async () => {
const manager = new LinuxSandboxManager({
workspace,
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
});
const req: SandboxRequest = {
const bwrapArgs = await getBwrapArgs({
command: 'node',
args: ['script.js'],
cwd: workspace,
env: {},
};
policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
},
});
const result = await manager.prepareCommand(req);
// Verify the specific bindings were added correctly
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
expect(result.program).toBe('sh');
expect(result.args[0]).toBe('-c');
expect(result.args[1]).toBe(
'bpf_path="$1"; shift; exec bwrap "$@" 9< "$bpf_path"',
);
expect(result.args[2]).toBe('_');
expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/);
const bwrapArgs = result.args.slice(4);
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
'--die-with-parent',
'--ro-bind',
'/',
'/',
'--dev',
'/dev',
'--proc',
'/proc',
'--tmpfs',
'/tmp',
expect(binds).toEqual([
'--bind',
workspace,
workspace,
'--bind',
'--bind-try',
'/tmp/cache',
'/tmp/cache',
'--bind',
'--bind-try',
'/opt/tools',
'/opt/tools',
'--seccomp',
'9',
'--',
'node',
'script.js',
]);
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace + '/'],
},
});
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
// Should only contain the primary workspace bind, not the second one with a trailing slash
expect(binds).toEqual(['--bind', workspace, workspace]);
});
});
@@ -4,18 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { join } from 'node:path';
import { join, normalize } from 'node:path';
import { writeFileSync } from 'node:fs';
import os from 'node:os';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
let cachedBpfPath: string | undefined;
@@ -76,28 +77,15 @@ function getSeccompBpfPath(): string {
return bpfPath;
}
/**
* Options for configuring the LinuxSandboxManager.
*/
export interface LinuxSandboxOptions {
/** The primary workspace path to bind into the sandbox. */
workspace: string;
/** Additional paths to bind into the sandbox. */
allowedPaths?: string[];
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
}
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export class LinuxSandboxManager implements SandboxManager {
constructor(private readonly options: LinuxSandboxOptions) {}
constructor(private readonly options: GlobalSandboxOptions) {}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.config?.sanitizationConfig,
this.options.sanitizationConfig,
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
@@ -121,13 +109,20 @@ export class LinuxSandboxManager implements SandboxManager {
this.options.workspace,
];
const allowedPaths = this.options.allowedPaths ?? [];
for (const path of allowedPaths) {
if (path !== this.options.workspace) {
bwrapArgs.push('--bind', path, path);
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = normalize(this.options.workspace).replace(
/\/$/,
'',
);
for (const allowedPath of allowedPaths) {
const normalizedAllowedPath = normalize(allowedPath).replace(/\/$/, '');
if (normalizedAllowedPath !== normalizedWorkspace) {
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
}
}
// TODO: handle forbidden paths
const bpfPath = getSeccompBpfPath();
bwrapArgs.push('--seccomp', '9');
@@ -116,7 +116,6 @@ describe.skipIf(os.platform() !== 'darwin')(
try {
const manager = new MacOsSandboxManager({
workspace: process.cwd(),
allowedPaths: [allowedDir],
});
const testFile = path.join(allowedDir, 'test.txt');
@@ -125,6 +124,9 @@ describe.skipIf(os.platform() !== 'darwin')(
args: [testFile],
cwd: process.cwd(),
env: process.env,
policy: {
allowedPaths: [allowedDir],
},
});
const execResult = await runCommand(command);
@@ -183,13 +185,15 @@ describe.skipIf(os.platform() !== 'darwin')(
it('should grant network access when explicitly allowed', async () => {
const manager = new MacOsSandboxManager({
workspace: process.cwd(),
networkAccess: true,
});
const command = await manager.prepareCommand({
command: 'curl',
args: ['-s', '--connect-timeout', '1', testServerUrl],
cwd: process.cwd(),
env: process.env,
policy: {
networkAccess: true,
},
});
const execResult = await runCommand(command);
@@ -3,105 +3,182 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import fs from 'node:fs';
import os from 'node:os';
describe('MacOsSandboxManager', () => {
const mockWorkspace = '/test/workspace';
const mockAllowedPaths = ['/test/allowed'];
const mockNetworkAccess = true;
const mockPolicy: ExecutionPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
let manager: MacOsSandboxManager;
let buildArgsSpy: MockInstance<typeof seatbeltArgsBuilder.buildSeatbeltArgs>;
beforeEach(() => {
manager = new MacOsSandboxManager({
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
});
buildArgsSpy = vi
.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs')
.mockReturnValue([
'-p',
'(mock profile)',
'-D',
'WORKSPACE=/test/workspace',
]);
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should correctly invoke buildSeatbeltArgs with the configured options', async () => {
await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
describe('prepareCommand', () => {
it('should build a strict allowlist profile allowing the workspace via param', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
policy: { networkAccess: false },
});
expect(result.program).toBe('/usr/bin/sandbox-exec');
const profile = result.args[1];
expect(profile).toContain('(version 1)');
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network*)');
expect(result.args).toContain('-D');
expect(result.args).toContain('WORKSPACE=/test/workspace');
expect(result.args).toContain(`TMPDIR=${os.tmpdir()}`);
});
expect(buildArgsSpy).toHaveBeenCalledWith({
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
});
});
it('should allow network when networkAccess is true in policy', async () => {
const result = await manager.prepareCommand({
command: 'curl',
args: ['example.com'],
cwd: mockWorkspace,
env: {},
policy: { networkAccess: true },
});
it('should format the executable and arguments correctly for sandbox-exec', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
const profile = result.args[1];
expect(profile).toContain('(allow network*)');
});
expect(result.program).toBe('/usr/bin/sandbox-exec');
expect(result.args).toEqual([
'-p',
'(mock profile)',
'-D',
'WORKSPACE=/test/workspace',
'--',
'echo',
'hello',
]);
});
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p as string;
});
it('should correctly pass through the cwd to the resulting command', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: '/test/different/cwd',
env: {},
const result = await manager.prepareCommand({
command: 'ls',
args: ['/custom/path1'],
cwd: mockWorkspace,
env: {},
policy: {
allowedPaths: ['/custom/path1', '/test/symlink'],
},
});
const profile = result.args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(result.args).toContain('-D');
expect(result.args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(result.args).toContain('ALLOWED_PATH_1=/test/real_path');
});
expect(result.cwd).toBe('/test/different/cwd');
});
it('should format the executable and arguments correctly for sandbox-exec', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
policy: mockPolicy,
});
it('should apply environment sanitization via the default mechanisms', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {
SAFE_VAR: '1',
GITHUB_TOKEN: 'sensitive',
},
expect(result.program).toBe('/usr/bin/sandbox-exec');
expect(result.args.slice(-3)).toEqual(['--', 'echo', 'hello']);
});
expect(result.env['SAFE_VAR']).toBe('1');
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
it('should correctly pass through the cwd to the resulting command', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: '/test/different/cwd',
env: {},
policy: mockPolicy,
});
expect(result.cwd).toBe('/test/different/cwd');
});
it('should apply environment sanitization via the default mechanisms', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {
SAFE_VAR: '1',
GITHUB_TOKEN: 'sensitive',
},
policy: mockPolicy,
});
expect(result.env['SAFE_VAR']).toBe('1');
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
});
it('should resolve parent directories if a file does not exist', async () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink/nonexistent.txt') {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === '/test/symlink') {
return '/test/real_path';
}
return p as string;
});
const dynamicManager = new MacOsSandboxManager({
workspace: '/test/symlink/nonexistent.txt',
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: '/test/symlink/nonexistent.txt',
env: {},
});
expect(dynamicResult.args).toContain(
'WORKSPACE=/test/real_path/nonexistent.txt',
);
});
it('should throw if realpathSync throws a non-ENOENT error', async () => {
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
const error = new Error('Permission denied');
Object.assign(error, { code: 'EACCES' });
throw error;
});
const errorManager = new MacOsSandboxManager({
workspace: '/test/workspace',
});
await expect(
errorManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
}),
).rejects.toThrow('Permission denied');
});
});
});
@@ -4,51 +4,40 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
type ExecutionPolicy,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
/**
* Options for configuring the MacOsSandboxManager.
*/
export interface MacOsSandboxOptions {
/** The primary workspace path to allow access to within the sandbox. */
workspace: string;
/** Additional paths to allow access to within the sandbox. */
allowedPaths?: string[];
/** Whether network access is allowed. */
networkAccess?: boolean;
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
}
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
/**
* A SandboxManager implementation for macOS that uses Seatbelt.
*/
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: MacOsSandboxOptions) {}
constructor(private readonly options: GlobalSandboxOptions) {}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.config?.sanitizationConfig,
this.options.sanitizationConfig,
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const sandboxArgs = buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: this.options.allowedPaths,
networkAccess: this.options.networkAccess,
});
const sandboxArgs = this.buildSeatbeltArgs(this.options, req.policy);
return {
program: '/usr/bin/sandbox-exec',
@@ -57,4 +46,65 @@ export class MacOsSandboxManager implements SandboxManager {
cwd: req.cwd,
};
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
private buildSeatbeltArgs(
options: GlobalSandboxOptions,
policy?: ExecutionPolicy,
): string[] {
const profileLines = [BASE_SEATBELT_PROFILE];
const args: string[] = [];
const workspacePath = this.tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
const tmpPath = this.tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const allowedPaths = sanitizePaths(policy?.allowedPaths) || [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = this.tryRealpath(allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profileLines.push(
`(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))`,
);
}
// TODO: handle forbidden paths
if (policy?.networkAccess) {
profileLines.push(NETWORK_SEATBELT_PROFILE);
}
args.unshift('-p', profileLines.join('\n'));
return args;
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
* Other errors (e.g. EACCES) are re-thrown.
*/
private tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(this.tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
}
@@ -1,97 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
describe('seatbeltArgsBuilder', () => {
it('should build a strict allowlist profile allowing the workspace via param', () => {
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
expect(args[0]).toBe('-p');
const profile = args[1];
expect(profile).toContain('(version 1)');
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network*)');
expect(args).toContain('-D');
expect(args).toContain('WORKSPACE=/Users/test/workspace');
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
vi.restoreAllMocks();
});
it('should allow network when networkAccess is true', () => {
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
const profile = args[1];
expect(profile).toContain('(allow network*)');
});
it('should parameterize allowed paths and normalize them', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
const profile = args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(args).toContain('-D');
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
vi.restoreAllMocks();
});
it('should resolve parent directories if a file does not exist', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink/nonexistent.txt') {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === '/test/symlink') {
return '/test/real_path';
}
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test/symlink/nonexistent.txt',
});
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
vi.restoreAllMocks();
});
it('should throw if realpathSync throws a non-ENOENT error', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
const error = new Error('Permission denied');
Object.assign(error, { code: 'EACCES' });
throw error;
});
expect(() =>
buildSeatbeltArgs({
workspace: '/test/workspace',
}),
).toThrow('Permission denied');
vi.restoreAllMocks();
});
});
@@ -1,80 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
/**
* Options for building macOS Seatbelt arguments.
*/
export interface SeatbeltArgsOptions {
/** The primary workspace path to allow access to. */
workspace: string;
/** Additional paths to allow access to. */
allowedPaths?: string[];
/** Whether to allow network access. */
networkAccess?: boolean;
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
* Other errors (e.g. EACCES) are re-thrown.
*/
function tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
let profile = BASE_SEATBELT_PROFILE + '\n';
const args: string[] = [];
const workspacePath = tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
const tmpPath = tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
if (options.allowedPaths) {
for (let i = 0; i < options.allowedPaths.length; i++) {
const allowedPath = tryRealpath(options.allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
}
}
if (options.networkAccess) {
profile += NETWORK_SEATBELT_PROFILE;
}
args.unshift('-p', profile);
return args;
}
@@ -6,12 +6,30 @@
import os from 'node:os';
import { describe, expect, it, vi } from 'vitest';
import { NoopSandboxManager } from './sandboxManager.js';
import { NoopSandboxManager, sanitizePaths } from './sandboxManager.js';
import { createSandboxManager } from './sandboxManagerFactory.js';
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
describe('sanitizePaths', () => {
it('should return undefined if no paths are provided', () => {
expect(sanitizePaths(undefined)).toBeUndefined();
});
it('should deduplicate paths and return them', () => {
const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo'];
expect(sanitizePaths(paths)).toEqual(['/workspace/foo', '/workspace/bar']);
});
it('should throw an error if a path is not absolute', () => {
const paths = ['/workspace/foo', 'relative/path'];
expect(() => sanitizePaths(paths)).toThrow(
'Sandbox path must be absolute: relative/path',
);
});
});
describe('NoopSandboxManager', () => {
const sandboxManager = new NoopSandboxManager();
@@ -58,7 +76,7 @@ describe('NoopSandboxManager', () => {
env: {
API_KEY: 'sensitive-key',
},
config: {
policy: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: false,
},
@@ -80,7 +98,7 @@ describe('NoopSandboxManager', () => {
MY_SAFE_VAR: 'safe-value',
MY_TOKEN: 'secret-token',
},
config: {
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
},
@@ -103,7 +121,7 @@ describe('NoopSandboxManager', () => {
SAFE_VAR: 'safe-value',
BLOCKED_VAR: 'blocked-value',
},
config: {
policy: {
sanitizationConfig: {
blockedEnvironmentVariables: ['BLOCKED_VAR'],
},
+60 -7
View File
@@ -4,11 +4,37 @@
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import path from 'node:path';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
/**
* Security boundaries and permissions applied to a specific sandboxed execution.
*/
export interface ExecutionPolicy {
/** Additional absolute paths to grant full read/write access to. */
allowedPaths?: string[];
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
/** Whether network access is allowed. */
networkAccess?: boolean;
/** Rules for scrubbing sensitive environment variables. */
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
}
/**
* Global configuration options used to initialize a SandboxManager.
*/
export interface GlobalSandboxOptions {
/**
* The primary workspace path the sandbox is anchored to.
* This directory is granted full read and write access.
*/
workspace: string;
}
/**
* Request for preparing a command to run in a sandbox.
@@ -22,12 +48,8 @@ export interface SandboxRequest {
cwd: string;
/** Environment variables to be passed to the program. */
env: NodeJS.ProcessEnv;
/** Optional sandbox-specific configuration. */
config?: {
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
allowedPaths?: string[];
networkAccess?: boolean;
};
/** Policy to use for this request. */
policy?: ExecutionPolicy;
}
/**
@@ -65,7 +87,7 @@ export class NoopSandboxManager implements SandboxManager {
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.config?.sanitizationConfig,
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
@@ -87,4 +109,35 @@ export class LocalSandboxManager implements SandboxManager {
}
}
/**
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
*/
export function sanitizePaths(paths?: string[]): string[] | undefined {
if (!paths) return undefined;
// We use a Map to deduplicate paths based on their normalized,
// platform-specific identity e.g. handling case-insensitivity on Windows)
// while preserving the original string casing.
const uniquePathsMap = new Map<string, string>();
for (const p of paths) {
if (!path.isAbsolute(p)) {
throw new Error(`Sandbox path must be absolute: ${p}`);
}
// Normalize the path (resolves slashes and redundant components)
let key = path.normalize(p);
// Windows file systems are case-insensitive, so we lowercase the key for
// deduplication
if (os.platform() === 'win32') {
key = key.toLowerCase();
}
if (!uniquePathsMap.has(key)) {
uniquePathsMap.set(key, p);
}
}
return Array.from(uniquePathsMap.values());
}
export { createSandboxManager } from './sandboxManagerFactory.js';
@@ -28,7 +28,7 @@ export function createSandboxManager(
isWindows &&
(sandbox?.enabled || sandbox?.command === 'windows-native')
) {
return new WindowsSandboxManager();
return new WindowsSandboxManager({ workspace });
}
if (sandbox?.enabled) {
@@ -437,7 +437,7 @@ export class ShellExecutionService {
args: spawnArgs,
env: baseEnv,
cwd,
config: {
policy: {
...shellExecutionConfig,
...(shellExecutionConfig.sandboxConfig || {}),
sanitizationConfig,
@@ -4,12 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'node:os';
import path from 'node:path';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import type { SandboxRequest } from './sandboxManager.js';
import { spawnAsync } from '../utils/shell-utils.js';
vi.mock('../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(),
}));
describe('WindowsSandboxManager', () => {
const manager = new WindowsSandboxManager('win32');
let manager: WindowsSandboxManager;
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
manager = new WindowsSandboxManager({ workspace: '/test/workspace' });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
@@ -17,7 +33,7 @@ describe('WindowsSandboxManager', () => {
args: ['/groups'],
cwd: '/test/cwd',
env: { TEST_VAR: 'test_value' },
config: {
policy: {
networkAccess: false,
},
};
@@ -34,7 +50,7 @@ describe('WindowsSandboxManager', () => {
args: [],
cwd: '/test/cwd',
env: {},
config: {
policy: {
networkAccess: true,
},
};
@@ -52,7 +68,7 @@ describe('WindowsSandboxManager', () => {
API_KEY: 'secret',
PATH: '/usr/bin',
},
config: {
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
@@ -65,4 +81,30 @@ describe('WindowsSandboxManager', () => {
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: '/test/cwd',
env: {},
policy: {
allowedPaths: ['/test/allowed1'],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/workspace'),
'/setintegritylevel',
'Low',
]);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/allowed1'),
'/setintegritylevel',
'Low',
]);
});
});
@@ -6,15 +6,18 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import type {
SandboxManager,
SandboxRequest,
SandboxedCommand,
import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
type GlobalSandboxOptions,
sanitizePaths,
} from './sandboxManager.js';
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
getSecureSanitizationConfig,
} from './environmentSanitization.js';
import { debugLogger } from '../utils/debugLogger.js';
import { spawnAsync } from '../utils/shell-utils.js';
@@ -29,18 +32,16 @@ const __dirname = path.dirname(__filename);
*/
export class WindowsSandboxManager implements SandboxManager {
private readonly helperPath: string;
private readonly platform: string;
private initialized = false;
private readonly lowIntegrityCache = new Set<string>();
constructor(platform: string = process.platform) {
this.platform = platform;
constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (this.platform !== 'win32') {
if (os.platform() !== 'win32') {
this.initialized = true;
return;
}
@@ -145,36 +146,31 @@ export class WindowsSandboxManager implements SandboxManager {
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await this.ensureInitialized();
const sanitizationConfig: EnvironmentSanitizationConfig = {
allowedEnvironmentVariables:
req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [],
blockedEnvironmentVariables:
req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [],
enableEnvironmentVariableRedaction:
req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ??
true,
};
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the CWD.
await this.grantLowIntegrityAccess(req.cwd);
// Grant "Low Mandatory Level" write access to the workspace.
await this.grantLowIntegrityAccess(this.options.workspace);
// Grant "Low Mandatory Level" read access to allowedPaths.
if (req.config?.allowedPaths) {
for (const allowedPath of req.config.allowedPaths) {
await this.grantLowIntegrityAccess(allowedPath);
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
for (const allowedPath of allowedPaths) {
await this.grantLowIntegrityAccess(allowedPath);
}
// TODO: handle forbidden paths
// 2. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
const program = this.helperPath;
// If the command starts with __, it's an internal command for the sandbox helper itself.
const args = [
req.config?.networkAccess ? '1' : '0',
req.policy?.networkAccess ? '1' : '0',
req.cwd,
req.command,
...req.args,
@@ -191,7 +187,7 @@ export class WindowsSandboxManager implements SandboxManager {
* Grants "Low Mandatory Level" access to a path using icacls.
*/
private async grantLowIntegrityAccess(targetPath: string): Promise<void> {
if (this.platform !== 'win32') {
if (os.platform() !== 'win32') {
return;
}
+3 -4
View File
@@ -155,14 +155,13 @@ export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anyth
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
/**
* Tools that can access local files or remote resources and should be
* treated with extra caution when updating policies.
* Tools that require mandatory argument narrowing (e.g., file paths, command prefixes)
* when granting persistent or session-wide approval.
*/
export const SENSITIVE_TOOLS = new Set([
export const TOOLS_REQUIRING_NARROWING = new Set([
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
+18
View File
@@ -752,6 +752,24 @@ describe('WebFetchTool', () => {
});
});
describe('getPolicyUpdateOptions', () => {
it('should return empty object for any outcome to allow global approval', () => {
const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool.build({ prompt: 'fetch https://example.com' });
expect(
invocation.getPolicyUpdateOptions!(
ToolConfirmationOutcome.ProceedAlways,
),
).toEqual({});
expect(
invocation.getPolicyUpdateOptions!(
ToolConfirmationOutcome.ProceedAlwaysAndSave,
),
).toEqual({});
});
});
describe('Message Bus Integration', () => {
let policyEngine: PolicyEngine;
let messageBus: MessageBus;
+2 -12
View File
@@ -5,16 +5,15 @@
*/
import {
type ToolConfirmationOutcome,
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolCallConfirmationDetails,
type ToolInvocation,
type ToolResult,
type ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from './tools.js';
import { buildParamArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -509,16 +508,7 @@ ${aggregatedContent}
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (this.params.url) {
return {
argsPattern: buildParamArgsPattern('url', this.params.url),
};
} else if (this.params.prompt) {
return {
argsPattern: buildParamArgsPattern('prompt', this.params.prompt),
};
}
return undefined;
return {};
}
protected override async getConfirmationDetails(
+4 -2
View File
@@ -119,8 +119,10 @@ describe('getCommandRoots', () => {
expect(getCommandRoots('ls -l')).toEqual(['ls']);
});
it('should handle paths and return the binary name', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']);
it('should handle paths and return the full path', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([
'/usr/local/bin/node',
]);
});
it('should return an empty array for an empty string', () => {
+1 -5
View File
@@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string {
return raw.slice(1, -1);
}
}
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.split(/[\\/]/).pop() ?? trimmed;
return raw.trim();
}
function extractNameFromNode(node: Node): string | null {