Repository navigation
fix(agent): prevent session context poisoning and infinite loops on interrupted turns - #29397
dylanyunlon wants to merge 8 commits into
Conversation
…nterrupted turns
When an agentic loop stream is interrupted (via SIGINT, timeout, or
aborted tool execution), the CLI appends a synthetic assistant turn
containing the raw interruption placeholder directly into the chat
session history. On subsequent turns the Gemini model recognizes this
string as the expected completion pattern and parrots it back, breaking
the agentic loop and blocking tool executions until the session is
reset.
Changes:
1. New sanitization module (interruptionSanitizer.ts)
- isInterruptionPlaceholder, isInterruptionContent for detection
- sanitizePart, sanitizeContent, sanitizeInterruptedTurns for replacement
- Maps the dangerous placeholder to a benign model turn ("Continuing.")
2. Source fix (geminiChat.ts)
- closeUnansweredToolResponseTurn() now injects the benign replacement
directly instead of the raw placeholder
- extractCuratedHistory() sanitizes model output turns before they
enter the curated history sent to the Gemini API
3. Next-speaker short circuit (nextSpeakerChecker.ts)
- Detects interruption placeholder turns and immediately returns
{ next_speaker: 'model' } without making an LLM call
4. Compression guard (chatCompressionService.ts)
- Sanitizes curated history before feeding it to the summarizer
5. History hardening (historyHardening.ts)
- scrubHistory() and scrubContents() run interruption sanitization
as part of the scrubbing pipeline
6. Downstream consumers
- agentChatHistory.ts: diagnostic helper containsInterruptionPlaceholder()
- local-executor.ts: abort path uses BENIGN_INTERRUPTION_REPLACEMENT
- index.ts: export interruptionSanitizer from core package
- build_package.js: build-time validation that the sanitizer compiled
7. E2E integration test (e2e-interruption-test.mjs)
- Starts a mock Gemini HTTP server on localhost
- Imports the built dist modules and verifies sanitization end-to-end
- Validates no poisoned text reaches the API request body
AST call chain (5 levels):
SIGINT/abort -> sendMessageStream() finally -> rollback
-> closeUnansweredToolResponseTurn() [SOURCE FIX]
-> getHistoryTurns(curated=true)
-> extractCuratedHistory() [SANITIZATION]
-> makeApiCallAndProcessStream() -> API [CLEAN]
-> checkNextSpeaker() [SHORT CIRCUIT]
-> ChatCompressionService.compress() [GUARD]
-> scrubHistory() [DEFENSE IN DEPTH]
Resolves google-gemini#29264
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
|
📊 PR Size: size/XL
|
|
@gemini-code-assist please review this PR. This fixes session context poisoning (issue #29264) where interrupted agentic loop streams inject a raw placeholder string into chat history, causing the model to parrot it back on subsequent turns. Key changes:
All pre-commit hooks (eslint, prettier) pass. Full build and typecheck clean. |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a critical issue where interrupted agentic turns cause 'session context poisoning,' leading to infinite loops and broken tool execution. By introducing a centralized sanitization module and integrating it across history extraction, compression, and speaker checking, the agent now gracefully handles interruptions without leaking the problematic placeholder into the model's context. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements session context poisoning prevention on interrupted turns (Issue #29264) by introducing an interruptionSanitizer utility that replaces raw interruption placeholders with a benign 'Continuing.' string. This sanitization is integrated across the core chat history, compression, and execution flows, and is backed by extensive unit and end-to-end tests. The feedback suggests improving the nextSpeakerChecker to correctly identify both raw and sanitized interruption placeholders, importing the necessary constant, and updating the corresponding tests to verify both cases using self-contained literal values.
Note: Security Review did not run due to the size of the PR.
| import type { BaseLlmClient } from '../core/baseLlmClient.js'; | ||
| import type { GeminiChat } from '../core/geminiChat.js'; | ||
| import { isFunctionResponse } from './messageInspectors.js'; | ||
| import { isInterruptionContent } from './interruptionSanitizer.js'; |
There was a problem hiding this comment.
Import the BENIGN_INTERRUPTION_REPLACEMENT constant from the sanitizer module so it can be used to check for sanitized interruption placeholders.
| import { isInterruptionContent } from './interruptionSanitizer.js'; | |
| import { isInterruptionContent, BENIGN_INTERRUPTION_REPLACEMENT } from './interruptionSanitizer.js'; |
| if ( | ||
| lastComprehensiveMessage && | ||
| isInterruptionContent(lastComprehensiveMessage) | ||
| ) { |
There was a problem hiding this comment.
The current implementation of isInterruptionContent only checks for the raw interruption placeholder (INTERRUPTED_RESPONSE_TEXT). However, if the stream was interrupted, the turn is sanitized and stored as BENIGN_INTERRUPTION_REPLACEMENT ("Continuing.").
To ensure the short-circuit logic correctly identifies both raw and sanitized interruption placeholders (as intended by the comment), we should also check if any part of the last model message matches BENIGN_INTERRUPTION_REPLACEMENT.
if (
lastComprehensiveMessage &&
(isInterruptionContent(lastComprehensiveMessage) ||
lastComprehensiveMessage.parts?.some(
(part) => typeof part.text === 'string' && part.text === BENIGN_INTERRUPTION_REPLACEMENT
))
) {| it("should return { next_speaker: 'model' } when last message is an interruption placeholder", async () => { | ||
| (chatInstance.getHistory as Mock).mockImplementation( | ||
| (curated?: boolean) => { | ||
| if (curated) { | ||
| return [ | ||
| { | ||
| role: 'model', | ||
| parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }], | ||
| }, | ||
| ] as Content[]; | ||
| } | ||
| return [ | ||
| { | ||
| role: 'model', | ||
| parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }], | ||
| }, | ||
| ] as Content[]; | ||
| }, | ||
| ); | ||
| const result = await checkNextSpeaker( | ||
| chatInstance, | ||
| mockBaseLlmClient, | ||
| abortSignal, | ||
| promptId, | ||
| ); | ||
| expect(result).toEqual({ | ||
| reasoning: | ||
| 'The last model response was an interruption placeholder, so the model should speak next to provide a real response.', | ||
| next_speaker: 'model', | ||
| }); | ||
| // Should NOT make an LLM call — this is a short-circuit | ||
| expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
Update the test case to verify that the next speaker checker correctly short-circuits and returns { next_speaker: 'model' } for both the raw interruption placeholder and the sanitized interruption placeholder. Note that in tests, we prefer using hardcoded literal values (such as 'Continuing.') instead of importing constants to ensure tests are self-contained and less brittle.
it.each([
['raw placeholder', INTERRUPTED_RESPONSE_PLACEHOLDER],
['sanitized placeholder', 'Continuing.'],
])("should return { next_speaker: 'model' } when last message is %s", async (_, placeholder) => {
(chatInstance.getHistory as Mock).mockImplementation(
(curated?: boolean) => {
const turn = [
{
role: 'model',
parts: [{ text: placeholder }],
},
] as Content[];
return turn;
},
);
const result = await checkNextSpeaker(
chatInstance,
mockBaseLlmClient,
abortSignal,
promptId,
);
expect(result).toEqual({
reasoning:
'The last model response was an interruption placeholder, so the model should speak next to provide a real response.',
next_speaker: 'model',
});
// Should NOT make an LLM call — this is a short-circuit
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});References
- In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.
There was a problem hiding this comment.
Code Review
This pull request addresses session context poisoning (Issue #29264) by introducing an interruptionSanitizer utility that replaces dangerous interruption placeholders with a benign replacement ('Continuing.'). This sanitization is integrated across several core components, including GeminiChat, ChatCompressionService, LocalAgentExecutor, and the history hardening pipeline, alongside updates to the next-speaker checker and new E2E and unit tests. The reviewer feedback recommends adding defensive checks against null or undefined values for parameters and optional properties (such as content, part, turns, history, and parts) within the new sanitizer functions to prevent potential runtime TypeError exceptions.
Note: Security Review did not run due to the size of the PR.
| export function isInterruptionContent(content: Content): boolean { | ||
| if (content.role !== 'model') return false; | ||
| if (!content.parts || content.parts.length === 0) return false; | ||
|
|
||
| return content.parts.some( | ||
| (part) => | ||
| typeof part.text === 'string' && isInterruptionPlaceholder(part.text), | ||
| ); | ||
| } |
There was a problem hiding this comment.
To prevent potential runtime TypeError exceptions, we should defensively guard against content being null/undefined, and ensure that each part in content.parts is also checked for null/undefined before accessing its properties. Since parts is an optional property, we should handle the undefined case using the nullish coalescing operator (??) to code against the interface contract.
export function isInterruptionContent(content: Content): boolean {
if (!content || content.role !== 'model') return false;
const parts = content.parts ?? [];
return parts.some(
(part) =>
part && typeof part.text === 'string' && isInterruptionPlaceholder(part.text),
);
}References
- When consuming an object, if a property is optional in its type definition (interface), callers must handle the
undefinedcase (e.g., by providing a default with??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.
| export function sanitizePart(part: Part): Part { | ||
| if (typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) { | ||
| return part; | ||
| } | ||
| return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT }; | ||
| } |
There was a problem hiding this comment.
Defensively guard against part being null or undefined before attempting to access part.text. This prevents runtime crashes if the parts array contains nullish values.
| export function sanitizePart(part: Part): Part { | |
| if (typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) { | |
| return part; | |
| } | |
| return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT }; | |
| } | |
| export function sanitizePart(part: Part): Part { | |
| if (!part || typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) { | |
| return part; | |
| } | |
| return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT }; | |
| } |
| export function sanitizeContent(content: Content): Content { | ||
| if (!isInterruptionContent(content)) { | ||
| return content; | ||
| } | ||
| return { | ||
| ...content, | ||
| parts: (content.parts || []).map(sanitizePart), | ||
| }; | ||
| } |
There was a problem hiding this comment.
Add a defensive check to ensure content is not null or undefined before processing. Since parts is an optional property, use the nullish coalescing operator (??) to handle the undefined case.
| export function sanitizeContent(content: Content): Content { | |
| if (!isInterruptionContent(content)) { | |
| return content; | |
| } | |
| return { | |
| ...content, | |
| parts: (content.parts || []).map(sanitizePart), | |
| }; | |
| } | |
| export function sanitizeContent(content: Content): Content { | |
| if (!content || !isInterruptionContent(content)) { | |
| return content; | |
| } | |
| return { | |
| ...content, | |
| parts: (content.parts ?? []).map(sanitizePart), | |
| }; | |
| } |
References
- When consuming an object, if a property is optional in its type definition (interface), callers must handle the
undefinedcase (e.g., by providing a default with??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.
| export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] { | ||
| return turns.map((turn) => { | ||
| if (!isInterruptionContent(turn.content)) { | ||
| return turn; | ||
| } | ||
| return { | ||
| ...turn, | ||
| content: sanitizeContent(turn.content), | ||
| }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
Defensively guard against turns being null/undefined, and ensure that each turn and its content property are checked for null/undefined before processing.
| export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] { | |
| return turns.map((turn) => { | |
| if (!isInterruptionContent(turn.content)) { | |
| return turn; | |
| } | |
| return { | |
| ...turn, | |
| content: sanitizeContent(turn.content), | |
| }; | |
| }); | |
| } | |
| export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] { | |
| if (!turns) return []; | |
| return turns.map((turn) => { | |
| if (!turn || !turn.content || !isInterruptionContent(turn.content)) { | |
| return turn; | |
| } | |
| return { | |
| ...turn, | |
| content: sanitizeContent(turn.content), | |
| }; | |
| }); | |
| } |
| export function sanitizeContentHistory(history: Content[]): Content[] { | ||
| return history.map(sanitizeContent); | ||
| } |
There was a problem hiding this comment.
Defensively guard against history being null or undefined before mapping over it.
| export function sanitizeContentHistory(history: Content[]): Content[] { | |
| return history.map(sanitizeContent); | |
| } | |
| export function sanitizeContentHistory(history: Content[]): Content[] { | |
| if (!history) return []; | |
| return history.map(sanitizeContent); | |
| } |
Resolve all 8 high-priority review comments from @gemini-code-assist: 1. interruptionSanitizer.ts: Add defensive null/undefined guards to isInterruptionContent, sanitizePart, sanitizeContent, sanitizeInterruptedTurns, and sanitizeContentHistory. Use nullish coalescing (??) for optional parts arrays. 2. nextSpeakerChecker.ts: Import BENIGN_INTERRUPTION_REPLACEMENT and extend the short-circuit check to detect both raw placeholders and sanitized 'Continuing.' turns. 3. nextSpeakerChecker.test.ts: Replace single test with parameterized it.each covering both raw and sanitized placeholder forms. 4. interruptionSanitizer.test.ts: Add null/undefined defensive tests for all public functions. 282 tests passing across 6 test suites + E2E.
|
Thanks @gemini-code-assist for the thorough review! All 8 high-priority items addressed in the latest push:
282 tests passing across 6 suites + E2E mock API integration. @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request implements session context poisoning prevention (Issue #29264) by introducing an interruption sanitizer that replaces raw interruption placeholders with a benign 'Continuing.' string across history extraction, chat compression, history hardening, and local execution. It also updates the next speaker checker to short-circuit when an interruption is detected. The review identified a critical bug in the next speaker checker where any multi-part model response containing the transition word 'Continuing.' would trigger a false positive short-circuit, bypassing tool execution; restricting the match to single-part messages containing only the placeholder resolves this issue.
| const isSanitizedInterruption = | ||
| lastComprehensiveMessage && | ||
| lastComprehensiveMessage.role === 'model' && | ||
| lastComprehensiveMessage.parts?.some( | ||
| (p) => | ||
| p && typeof p.text === 'string' && p.text === BENIGN_INTERRUPTION_REPLACEMENT, | ||
| ); |
There was a problem hiding this comment.
The current implementation of isSanitizedInterruption uses parts.some() to check if any part of the last model message matches BENIGN_INTERRUPTION_REPLACEMENT ("Continuing."). This creates a critical bug: if the model outputs a multi-part response that contains the word "Continuing." (e.g., as a transition phrase) followed by a tool call, isSanitizedInterruption will evaluate to true. This triggers the short-circuit logic, returning { next_speaker: 'model' } immediately and bypassing the tool execution entirely, which breaks the agentic loop. To prevent this, we should ensure that isSanitizedInterruption only matches when the message consists solely of the synthetic interruption placeholder (i.e., it has exactly one part, and that part is the placeholder text).
| const isSanitizedInterruption = | |
| lastComprehensiveMessage && | |
| lastComprehensiveMessage.role === 'model' && | |
| lastComprehensiveMessage.parts?.some( | |
| (p) => | |
| p && typeof p.text === 'string' && p.text === BENIGN_INTERRUPTION_REPLACEMENT, | |
| ); | |
| const isSanitizedInterruption = | |
| lastComprehensiveMessage && | |
| lastComprehensiveMessage.role === 'model' && | |
| lastComprehensiveMessage.parts?.length === 1 && | |
| lastComprehensiveMessage.parts[0] && | |
| typeof lastComprehensiveMessage.parts[0].text === 'string' && | |
| lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT; |
…n detection Address critical review from @gemini-code-assist: the previous parts.some() check for BENIGN_INTERRUPTION_REPLACEMENT could false-positive on a real model response that happens to contain "Continuing." as a transition phrase alongside a tool call. This would bypass tool execution and break the agentic loop. Fix: require parts.length === 1 so only the synthetic single-part placeholder matches, never a multi-part real response. Added regression test for the exact false-positive scenario: a model turn with [{ text: 'Continuing.' }, { functionCall: ... }] must NOT trigger the short-circuit. 283 tests passing across 6 suites + E2E.
|
Fixed the critical finding from @gemini-code-assist: Problem: Fix: Changed to strict single-part matching ( Regression test added: A model turn 283 tests passing + E2E clean. @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request implements session context poisoning prevention on interrupted turns (Issue #29264) by introducing an interruptionSanitizer utility. This utility replaces the dangerous raw interruption placeholder with a benign 'Continuing.' string across history curation, chat compression, history hardening, next-speaker checking, and local execution. Comprehensive unit and end-to-end tests are also added. The feedback suggests combining consecutive .map calls in historyHardening.ts to avoid intermediate array allocations, and refactoring the assertion logic in e2e-interruption-test.mjs to use a try...finally block to guarantee cleanup of temporary directories on failure.
| const scrubbedParts = nonThoughtParts | ||
| .map((p) => scrubPart(p)) | ||
| .map((p) => sanitizeInterruptionPart(p)); |
There was a problem hiding this comment.
To optimize performance and avoid creating intermediate arrays, combine the .map(scrubPart) and .map(sanitizeInterruptionPart) calls into a single .map pass. This reduces iteration overhead on the chat history turns.
| const scrubbedParts = nonThoughtParts | |
| .map((p) => scrubPart(p)) | |
| .map((p) => sanitizeInterruptionPart(p)); | |
| const scrubbedParts = nonThoughtParts.map((p) => | |
| sanitizeInterruptionPart(scrubPart(p)), | |
| ); |
| const scrubbedParts = nonThoughtParts | ||
| .map((p) => scrubPart(p)) | ||
| .map((p) => sanitizeInterruptionPart(p)); |
There was a problem hiding this comment.
Similarly, combine the .map calls here into a single pass to avoid intermediate array allocation and improve efficiency during history scrubbing.
| const scrubbedParts = nonThoughtParts | |
| .map((p) => scrubPart(p)) | |
| .map((p) => sanitizeInterruptionPart(p)); | |
| const scrubbedParts = nonThoughtParts.map((p) => | |
| sanitizeInterruptionPart(scrubPart(p)), | |
| ); |
| function assert(condition, message) { | ||
| if (!condition) { | ||
| console.error(`\n ✗ ASSERTION FAILED: ${message}`); | ||
| server.close(); | ||
| process.exit(1); | ||
| } | ||
| } |
There was a problem hiding this comment.
If an assertion fails, process.exit(1) is called immediately, which bypasses the cleanup of the temporary directory tmpHome and leaves orphaned directories in the OS temp folder. To prevent resource leaks on test failures, refactor assert to throw an Error instead of exiting the process directly. Then, wrap the test execution in main() with a try...finally block to guarantee that server.close() and fs.rmSync(tmpHome, ...) are always executed.
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}1. e2e-interruption-test.mjs: assert() now throws instead of calling process.exit(1), and main() body is wrapped in try/finally to guarantee tmpHome cleanup on assertion failure. Prevents orphaned temp directories in OS temp folder. 2. historyHardening.ts: Merged two consecutive .map() passes (.map(scrubPart).map(sanitizeInterruptionPart)) into a single .map(p => sanitizeInterruptionPart(scrubPart(p))) in both scrubHistory() and scrubContents(). Eliminates intermediate array allocation. 283 tests + E2E clean.
|
Round 3 addressed, all 3 high-priority items:
283 tests + E2E clean. @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request introduces a session context poisoning prevention mechanism by replacing dangerous raw interruption placeholders with a benign replacement ('Continuing.'). It adds an interruption sanitizer utility, integrates it across history curation, chat compression, and next-speaker checking, and includes comprehensive tests. The review feedback identifies a correctness bug in LocalAgentExecutor where using the benign replacement as a fallback for aborted subagent runs misleads the parent agent. The reviewer recommends reverting to the original explicit termination message, trimming the optional string, and updating the associated tests and imports to use hardcoded literal values.
Note: Security Review did not run due to the size of the PR.
| import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; | ||
| import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js'; |
There was a problem hiding this comment.
The import of BENIGN_INTERRUPTION_REPLACEMENT is no longer needed if we revert the fallback result to the original explicit termination message.
| import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; | |
| import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js'; | |
| import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; |
| return { | ||
| result: | ||
| finalResult || 'Agent execution was terminated before completion.', | ||
| result: finalResult || BENIGN_INTERRUPTION_REPLACEMENT, |
There was a problem hiding this comment.
Using BENIGN_INTERRUPTION_REPLACEMENT (which evaluates to 'Continuing.') as the fallback for an aborted or cancelled subagent run is a correctness bug. When a subagent is executed as a tool by a parent agent, the parent agent receives this result as the tool output. If the subagent was aborted, returning 'Continuing.' will mislead the parent agent into thinking the subagent completed successfully and returned 'Continuing.' as its output, rather than realizing it was aborted/cancelled. The original string 'Agent execution was terminated before completion.' does not contain the poisoned placeholder and therefore does not cause context poisoning. We should revert this fallback to the original explicit termination message. Additionally, when using an optional string with a fallback value, we should trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.
| result: finalResult || BENIGN_INTERRUPTION_REPLACEMENT, | |
| result: finalResult?.trim() || 'Agent execution was terminated before completion.', |
References
- When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.
| } from './types.js'; | ||
| import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js'; |
There was a problem hiding this comment.
The import of BENIGN_INTERRUPTION_REPLACEMENT is no longer needed in the test file once the assertions are updated to use the correct fallback string. In tests, we prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.
| } from './types.js'; | |
| import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js'; | |
| } from './types.js'; |
References
- In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.
| describe('session context poisoning prevention (Issue #29264)', () => { | ||
| it('should use BENIGN_INTERRUPTION_REPLACEMENT as the fallback termination result', () => { | ||
| // Verify the constant is the short benign string, not the poisoning placeholder | ||
| expect(BENIGN_INTERRUPTION_REPLACEMENT).toBe('Continuing.'); | ||
| expect(BENIGN_INTERRUPTION_REPLACEMENT).not.toContain('interrupted'); | ||
| expect(BENIGN_INTERRUPTION_REPLACEMENT).not.toContain('['); | ||
| }); | ||
|
|
||
| it('should not include raw interruption placeholder in default abort result messages', async () => { | ||
| // Read the source code to verify no raw placeholder is used in fallback results | ||
| const fs = await import('node:fs'); | ||
| const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8'); | ||
| // The old string 'Agent execution was terminated before completion.' has been | ||
| // replaced with BENIGN_INTERRUPTION_REPLACEMENT to prevent context poisoning | ||
| // when the executor result is injected back into the parent agent's history | ||
| expect(source).not.toContain( | ||
| "finalResult || 'Agent execution was terminated before completion.'", | ||
| ); | ||
| expect(source).toContain( | ||
| 'finalResult || BENIGN_INTERRUPTION_REPLACEMENT', | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Update the tests to assert the correct fallback string 'Agent execution was terminated before completion.' (with the trimmed optional string check) and verify that the raw interruption placeholder is not leaked, rather than asserting the incorrect 'Continuing.' fallback. Also, prefer using hardcoded literal values instead of importing constants to keep tests self-contained.
describe('session context poisoning prevention (Issue #29264)', () => {
it('should not include raw interruption placeholder in default abort result messages', async () => {
const fs = await import('node:fs');
const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8');
expect(source).not.toContain('INTERRUPTED_RESPONSE_PLACEHOLDER');
expect(source).not.toContain('INTERRUPTED_RESPONSE_TEXT');
expect(source).toContain(
"finalResult?.trim() || 'Agent execution was terminated before completion.'",
);
});
});References
- In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.
…ssage Address round-4 review from @gemini-code-assist (4 high): Using BENIGN_INTERRUPTION_REPLACEMENT ('Continuing.') as the abort fallback was a correctness bug: the parent agent would interpret it as a successful completion rather than a termination. The original string 'Agent execution was terminated before completion.' does NOT contain the poisoning placeholder and therefore never caused context poisoning in the first place. Changes: - local-executor.ts: revert fallback to original message, add .trim() guard against whitespace-only finalResult strings, remove unused BENIGN_INTERRUPTION_REPLACEMENT import - local-executor.test.ts: remove constant import, rewrite test to use hardcoded literals and verify no raw placeholder leaks 282 tests + E2E clean.
|
Round 4 addressed, all 4 high-priority items:
282 tests + E2E clean. @gemini-code-assist please re-review. |
|
@gemini-code-assist All round-4 feedback has been addressed. Could you please do a final review of the latest commit (a4db215)? Summary of current state:
|
There was a problem hiding this comment.
Code Review
This pull request introduces robust prevention against session context poisoning caused by interrupted turns (Issue #29264). It adds a new interruptionSanitizer utility to replace dangerous raw interruption placeholders with a benign replacement ('Continuing.'), integrating this sanitization into history extraction, chat compression, and history scrubbing. It also updates the next-speaker checker to short-circuit on these placeholders and adds comprehensive unit and E2E tests. The review feedback suggests two valuable improvements: ensuring the next-speaker checker's sanitized interruption detection explicitly excludes thought parts to prevent false positives, and simplifying a redundant string equality check in isInterruptionPlaceholder since includes already covers exact matches.
Note: Security Review did not run due to the size of the PR.
| const isSanitizedInterruption = | ||
| lastComprehensiveMessage && | ||
| lastComprehensiveMessage.role === 'model' && | ||
| lastComprehensiveMessage.parts?.length === 1 && | ||
| lastComprehensiveMessage.parts[0] && | ||
| typeof lastComprehensiveMessage.parts[0].text === 'string' && | ||
| lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT; |
There was a problem hiding this comment.
The check for isSanitizedInterruption should explicitly ensure that the single part is not a thought part. If the model generated a thought that happens to contain the benign replacement text, it could be incorrectly classified as a sanitized interruption turn.
const isSanitizedInterruption =
lastComprehensiveMessage &&
lastComprehensiveMessage.role === 'model' &&
lastComprehensiveMessage.parts?.length === 1 &&
lastComprehensiveMessage.parts[0] &&
!lastComprehensiveMessage.parts[0].thought &&
typeof lastComprehensiveMessage.parts[0].text === 'string' &&
lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT;| export function isInterruptionPlaceholder(text: string): boolean { | ||
| return ( | ||
| text === INTERRUPTED_RESPONSE_TEXT || | ||
| text.includes(INTERRUPTED_RESPONSE_TEXT) | ||
| ); | ||
| } |
There was a problem hiding this comment.
The check text === INTERRUPTED_RESPONSE_TEXT is redundant because text.includes(INTERRUPTED_RESPONSE_TEXT) already returns true when the strings are exactly equal. Simplifying this reduces redundant comparisons.
export function isInterruptionPlaceholder(text: string): boolean {
return text.includes(INTERRUPTED_RESPONSE_TEXT);
}There was a problem hiding this comment.
Code Review
This pull request addresses session context poisoning (Issue #29264) caused by interrupted model turns. It introduces a new interruptionSanitizer utility to replace dangerous raw interruption placeholders with a benign replacement ('Continuing.') during history curation, chat compression, history scrubbing, and next speaker checking. Additionally, it includes comprehensive unit tests, a new end-to-end test, and build-time validation to ensure the sanitization logic works correctly. As there are no review comments provided, I have no feedback to offer on the review itself.
Note: Security Review did not run due to the size of the PR.
Round 5 from @gemini-code-assist (2 high): 1. interruptionSanitizer.ts: Remove redundant equality check in isInterruptionPlaceholder - text.includes() already covers the exact-match case. 2. nextSpeakerChecker.ts: Add !thought guard to isSanitizedInterruption so a thought part whose text happens to be 'Continuing.' is not misclassified as a synthetic interruption placeholder. 282 tests + E2E clean.
|
Round 5 done (2 high):
282 tests + E2E clean. @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request implements session context poisoning prevention (Issue #29264) by introducing an interruptionSanitizer utility to sanitize raw interruption placeholders and replace them with a benign 'Continuing.' message. The sanitization is integrated across GeminiChat, ChatCompressionService, historyHardening, and nextSpeakerChecker, and is supported by comprehensive unit and E2E tests. The review feedback highlights that several test files and the E2E script use hardcoded relative paths for file reading and dynamic imports, which makes execution fragile depending on the current working directory. It is recommended to use new URL(..., import.meta.url) to resolve these paths robustly relative to the source files.
Note: Security Review did not run due to the size of the PR.
| describe('session context poisoning prevention (Issue #29264)', () => { | ||
| it('should not include raw interruption placeholder in default abort result messages', async () => { | ||
| const fs = await import('node:fs'); | ||
| const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8'); |
There was a problem hiding this comment.
Using a hardcoded relative path like 'src/agents/local-executor.ts' makes the test execution fragile and dependent on the current working directory (CWD). If the tests are run from the monorepo root instead of the packages/core directory, this test will fail with an ENOENT error.
Using new URL('local-executor.ts', import.meta.url) resolves the path relative to the test file itself, making it robust and independent of where the test runner is invoked.
| const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8'); | |
| const source = fs.readFileSync(new URL('local-executor.ts', import.meta.url), 'utf-8'); |
| const geminiChatModule = await import( | ||
| path.resolve('packages/core/dist/src/core/geminiChat.js') | ||
| ); | ||
| const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule; | ||
|
|
||
| // Import the sanitizer to verify exports work | ||
| const sanitizerModule = await import( | ||
| path.resolve('packages/core/dist/src/utils/interruptionSanitizer.js') | ||
| ); |
There was a problem hiding this comment.
Using path.resolve with a hardcoded relative path like 'packages/core/dist/...' makes the dynamic imports fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail to resolve the modules.
Using new URL(..., import.meta.url).href resolves the paths relative to the script itself, making it robust and independent of where the script is invoked.
| const geminiChatModule = await import( | |
| path.resolve('packages/core/dist/src/core/geminiChat.js') | |
| ); | |
| const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule; | |
| // Import the sanitizer to verify exports work | |
| const sanitizerModule = await import( | |
| path.resolve('packages/core/dist/src/utils/interruptionSanitizer.js') | |
| ); | |
| const geminiChatModule = await import( | |
| new URL('dist/src/core/geminiChat.js', import.meta.url).href | |
| ); | |
| const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule; | |
| // Import the sanitizer to verify exports work | |
| const sanitizerModule = await import( | |
| new URL('dist/src/utils/interruptionSanitizer.js', import.meta.url).href | |
| ); |
| const sourceCode = fs.readFileSync( | ||
| 'packages/core/src/core/geminiChat.ts', | ||
| 'utf-8', | ||
| ); |
There was a problem hiding this comment.
Using a hardcoded relative path like 'packages/core/src/core/geminiChat.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.
Using new URL('src/core/geminiChat.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.
| const sourceCode = fs.readFileSync( | |
| 'packages/core/src/core/geminiChat.ts', | |
| 'utf-8', | |
| ); | |
| const sourceCode = fs.readFileSync( | |
| new URL('src/core/geminiChat.ts', import.meta.url), | |
| 'utf-8', | |
| ); |
| const nextSpeakerSource = fs.readFileSync( | ||
| 'packages/core/src/utils/nextSpeakerChecker.ts', | ||
| 'utf-8', | ||
| ); |
There was a problem hiding this comment.
Using a hardcoded relative path like 'packages/core/src/utils/nextSpeakerChecker.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.
Using new URL('src/utils/nextSpeakerChecker.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.
| const nextSpeakerSource = fs.readFileSync( | |
| 'packages/core/src/utils/nextSpeakerChecker.ts', | |
| 'utf-8', | |
| ); | |
| const nextSpeakerSource = fs.readFileSync( | |
| new URL('src/utils/nextSpeakerChecker.ts', import.meta.url), | |
| 'utf-8', | |
| ); |
| const compressionSource = fs.readFileSync( | ||
| 'packages/core/src/context/chatCompressionService.ts', | ||
| 'utf-8', | ||
| ); |
There was a problem hiding this comment.
Using a hardcoded relative path like 'packages/core/src/context/chatCompressionService.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.
Using new URL('src/context/chatCompressionService.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.
| const compressionSource = fs.readFileSync( | |
| 'packages/core/src/context/chatCompressionService.ts', | |
| 'utf-8', | |
| ); | |
| const compressionSource = fs.readFileSync( | |
| new URL('src/context/chatCompressionService.ts', import.meta.url), | |
| 'utf-8', | |
| ); |
Round 6 from @gemini-code-assist (5 high): All hardcoded relative paths in e2e-interruption-test.mjs and local-executor.test.ts replaced with import.meta.url-based resolution. Scripts now work correctly regardless of which directory they are invoked from (monorepo root, packages/core, or /tmp). Verified from 3 different CWDs: all pass. 282 tests + E2E clean.
|
Round 6 done (5 high, all same category): Replaced all hardcoded relative paths with Verified the E2E test now passes from 3 different CWDs:
282 tests + E2E clean. @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request introduces a robust mechanism to prevent session context poisoning caused by interrupted turns (Issue #29264). It adds an interruptionSanitizer utility to replace raw interruption placeholders with a benign 'Continuing.' response, integrating this sanitization across history curation, chat compression, history scrubbing, and next-speaker checking. Additionally, it updates the local executor's fallback messages and includes comprehensive unit and end-to-end tests. The review feedback highlights a critical improvement opportunity in isInterruptionPlaceholder: replacing the .includes() check with a strict exact match (using .trim()) to avoid false positives and data loss if the model legitimately references the placeholder text, along with a corresponding update to the unit tests.
| export function isInterruptionPlaceholder(text: string): boolean { | ||
| return text.includes(INTERRUPTED_RESPONSE_TEXT); | ||
| } |
There was a problem hiding this comment.
Using .includes() to detect the interruption placeholder can lead to severe false positives and data loss. If the model legitimately discusses, explains, or quotes the interruption message (for example, in response to a user query asking about it), the entire text of that part will be aggressively replaced with "Continuing." and the surrounding text will be completely lost.
To prevent this, we should use a strict exact match (with .trim() to handle leading/trailing whitespace or newlines). Since the system-generated turn is always exactly the placeholder string, this is highly robust and safe.
| export function isInterruptionPlaceholder(text: string): boolean { | |
| return text.includes(INTERRUPTED_RESPONSE_TEXT); | |
| } | |
| export function isInterruptionPlaceholder(text: string): boolean { | |
| return text.trim() === INTERRUPTED_RESPONSE_TEXT; | |
| } |
| it('should match when placeholder is embedded in surrounding text', () => { | ||
| expect( | ||
| isInterruptionPlaceholder( | ||
| `Some prefix ${INTERRUPTED_RESPONSE_TEXT} some suffix`, | ||
| ), | ||
| ).toBe(true); | ||
| }); |
There was a problem hiding this comment.
Update the test to reflect the strict exact matching behavior for the interruption placeholder, ensuring that embedded occurrences are not falsely matched.
| it('should match when placeholder is embedded in surrounding text', () => { | |
| expect( | |
| isInterruptionPlaceholder( | |
| `Some prefix ${INTERRUPTED_RESPONSE_TEXT} some suffix`, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should not match when placeholder is embedded in surrounding text', () => { | |
| expect( | |
| isInterruptionPlaceholder( | |
| 'Some prefix ' + INTERRUPTED_RESPONSE_TEXT + ' some suffix', | |
| ), | |
| ).toBe(false); | |
| }); |
…tion Round 7 from @gemini-code-assist (2 high): 1. interruptionSanitizer.ts: Changed isInterruptionPlaceholder from .includes() to .trim() === exact match. Prevents false positives where the model legitimately discusses or quotes the interruption message -- .includes() would aggressively replace the entire part text and lose surrounding content. 2. interruptionSanitizer.test.ts: Updated the embedded-text test to expect false (not true) since strict matching rejects substrings. 282 tests + E2E from 3 CWDs + gemini CLI binary all clean.
|
Round 7 done (2 high):
Verified: 282 unit tests + E2E from 3 CWDs + real @gemini-code-assist please re-review. |
There was a problem hiding this comment.
Code Review
This pull request implements session context poisoning prevention for interrupted turns (Issue #29264). It introduces a new utility module interruptionSanitizer that replaces dangerous raw interruption placeholders with a benign replacement (Continuing.). This sanitization is integrated across several core services, including GeminiChat history extraction, ChatCompressionService, and the history scrubbing pipeline (historyHardening). Additionally, nextSpeakerChecker is updated to short-circuit and prompt the model to speak next when an interruption is detected. Comprehensive unit and end-to-end tests are added to verify the changes. There are no review comments, so I have no feedback to provide.
Note: Security Review did not run due to the size of the PR.
|
Hi there! Thank you for your interest in contributing to Gemini CLI. To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding. |
|
This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding. |
Summary
When an agentic loop stream is interrupted (via SIGINT, timeout, or aborted tool execution), the CLI appends a synthetic assistant turn containing
[The previous response was interrupted before it completed.]directly into the chat session history. This causes severe in-context session poisoning: on subsequent turns, the Gemini model recognizes this string as the expected completion pattern and parrots it back, breaking the agentic loop and blocking tool executions until the session is reset.Details
New Sanitization Module:
interruptionSanitizer.tsprovides detection (isInterruptionPlaceholder,isInterruptionContent) and replacement (sanitizePart,sanitizeContent, etc.) utilities that map the dangerous placeholder to a benign model turn text ("Continuing.").Source Fix:
closeUnansweredToolResponseTurn()ingeminiChat.tsnow injects the benign replacement directly instead of the raw placeholder, preventing poisoning at the source.Curated History Defense:
extractCuratedHistory()sanitizes model output turns before they enter the curated history that gets sent to the Gemini API. This catches any residual placeholders from older sessions or resumed transcripts.Next-Speaker Short Circuit:
nextSpeakerChecker.tsnow detects interruption placeholder turns and immediately returns{ next_speaker: 'model' }without making an LLM call, preventing the checker from misinterpreting the interrupted turn.Compression Guard:
chatCompressionService.tssanitizes the curated history before feeding it to the summarization model, so compressed snapshots never contain the poisoned text.History Hardening:
scrubHistory()andscrubContents()inhistoryHardening.tsnow run interruption sanitization as part of the scrubbing pipeline, providing defense in depth.E2E Integration Test:
e2e-interruption-test.mjsstarts a mock Gemini HTTP server on localhost, imports the built dist modules, and verifies the full sanitization pipeline end-to-end.AST Call Chain Coverage (5 levels deep)
Related Issues
Resolves #29264
How to Validate
npm test -w @google/gemini-cli-core -- src/utils/interruptionSanitizer.test.ts --runnpm test -w @google/gemini-cli-core -- src/core/geminiChat.test.ts --runnpm test -w @google/gemini-cli-core -- src/utils/interruptionSanitizer.test.ts src/core/geminiChat.test.ts src/utils/nextSpeakerChecker.test.ts src/context/chatCompressionService.test.ts src/utils/historyHardening.test.ts src/agents/local-executor.test.ts --runnpm run build && npm run typecheck -w @google/gemini-cli-corePre-Merge Checklist