chore: Merge branch 'main' into channels

This commit is contained in:
Jack Wotherspoon
2026-03-22 20:23:38 -07:00
364 changed files with 8881 additions and 5980 deletions
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
sanitizeErrorMessage,
sanitizeToolArgs,
sanitizeThoughtContent,
} from './agent-sanitization-utils.js';
describe('agent-sanitization-utils', () => {
describe('sanitizeErrorMessage', () => {
it('should redact standard inline PEM content', () => {
const input =
'Here is my key: -----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA12345\n-----END RSA PRIVATE KEY----- do not share.';
const expected = 'Here is my key: [REDACTED_PEM] do not share.';
expect(sanitizeErrorMessage(input)).toBe(expected);
});
it('should redact non-standard inline PEM content (with punctuation)', () => {
const input =
'-----BEGIN X.509 CERTIFICATE-----\nMIIEowIBAAKCAQEA12345\n-----END X.509 CERTIFICATE-----';
const expected = '[REDACTED_PEM]';
expect(sanitizeErrorMessage(input)).toBe(expected);
});
it('should not hang on ReDoS attack string for PEM redaction', () => {
const start = Date.now();
// A string that starts with -----BEGIN but has no ending, with many spaces
// In the vulnerable regex, this would cause catastrophic backtracking.
const maliciousInput = '-----BEGIN ' + ' '.repeat(50000) + 'A';
const result = sanitizeErrorMessage(maliciousInput);
const duration = Date.now() - start;
// Should process very quickly (e.g. < 50ms)
expect(duration).toBeLessThan(50);
// Since it doesn't match the full PEM block pattern, it should return the input unaltered
expect(result).toBe(maliciousInput);
});
it('should redact key-value pairs with sensitive keys', () => {
const input = 'Error: connection failed. --api-key="secret123"';
const result = sanitizeErrorMessage(input);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('secret123');
});
it('should redact space-separated sensitive keywords', () => {
// The keyword regex requires tokens to be 8+ chars
const input = 'Using password mySuperSecretPassword123';
const result = sanitizeErrorMessage(input);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('mySuperSecretPassword123');
});
});
describe('sanitizeToolArgs', () => {
it('should redact sensitive fields in an object', () => {
const input = {
username: 'admin',
password: 'superSecretPassword',
nested: {
api_key: 'abc123xyz',
normal_field: 'hello',
},
};
const result = sanitizeToolArgs(input);
expect(result).toEqual({
username: 'admin',
password: '[REDACTED]',
nested: {
api_key: '[REDACTED]',
normal_field: 'hello',
},
});
});
it('should handle arrays and strings correctly', () => {
const input = ['normal string', '--api-key="secret123"'];
const result = sanitizeToolArgs(input) as string[];
expect(result[0]).toBe('normal string');
expect(result[1]).toContain('[REDACTED]');
expect(result[1]).not.toContain('secret123');
});
});
describe('sanitizeThoughtContent', () => {
it('should redact sensitive patterns from thought content', () => {
const input = 'I will now authenticate using token 1234567890abcdef.';
const result = sanitizeThoughtContent(input);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('1234567890abcdef');
});
});
});
@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Sensitive key patterns used for redaction.
*/
export const SENSITIVE_KEY_PATTERNS = [
'password',
'pwd',
'apikey',
'api_key',
'api-key',
'token',
'secret',
'credential',
'auth',
'authorization',
'access_token',
'access_key',
'refresh_token',
'session_id',
'cookie',
'passphrase',
'privatekey',
'private_key',
'private-key',
'secret_key',
'client_secret',
'client_id',
];
/**
* Sanitizes tool arguments by recursively redacting sensitive fields.
* Supports nested objects and arrays.
*/
export function sanitizeToolArgs(args: unknown): unknown {
if (typeof args === 'string') {
return sanitizeErrorMessage(args);
}
if (typeof args !== 'object' || args === null) {
return args;
}
if (Array.isArray(args)) {
return args.map(sanitizeToolArgs);
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
// Decode key to handle URL-encoded sensitive keys (e.g., api%5fkey)
let decodedKey = key;
try {
decodedKey = decodeURIComponent(key);
} catch {
// Ignore decoding errors
}
const keyNormalized = decodedKey.toLowerCase().replace(/[-_]/g, '');
const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) =>
keyNormalized.includes(pattern.replace(/[-_]/g, '')),
);
if (isSensitive) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = sanitizeToolArgs(value);
}
}
return sanitized;
}
/**
* Sanitizes error messages by redacting potential sensitive data patterns.
* Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values.
*/
export function sanitizeErrorMessage(message: string): string {
if (!message) return message;
let sanitized = message;
// 1. Redact inline PEM content (Safe iterative approach to avoid ReDoS)
let startIndex = 0;
while ((startIndex = sanitized.indexOf('-----BEGIN', startIndex)) !== -1) {
const endOfBegin = sanitized.indexOf('-----', startIndex + 10);
if (endOfBegin === -1) {
break; // No closing dashes for the BEGIN header
}
// Find the END header
const endHeaderStart = sanitized.indexOf('-----END', endOfBegin + 5);
if (endHeaderStart === -1) {
break; // No END header found
}
const endHeaderEnd = sanitized.indexOf('-----', endHeaderStart + 8);
if (endHeaderEnd === -1) {
break; // No closing dashes for the END header
}
// We found a complete block. Replace it.
const before = sanitized.substring(0, startIndex);
const after = sanitized.substring(endHeaderEnd + 5);
sanitized = before + '[REDACTED_PEM]' + after;
// Resume searching after the redacted block
startIndex = before.length + 14; // length of '[REDACTED_PEM]'
}
const unquotedValue = `[^\\s]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>]+)*`;
const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`;
// 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag)
const urlSafeKeyPatternStr = SENSITIVE_KEY_PATTERNS.map((p) =>
p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'),
).join('|');
const keyWithDelimiter = new RegExp(
`((?:--)?("|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`,
'gi',
);
sanitized = sanitized.replace(keyWithDelimiter, '$1[REDACTED]');
// 3. Handle space-separated sensitive keywords (e.g. "password mypass", "--api-key secret")
const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`;
const spaceKeywords = [
...SENSITIVE_KEY_PATTERNS.map((p) =>
p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'),
),
'bearer',
];
const spaceSeparated = new RegExp(
`\\b((?:--)?(?:${spaceKeywords.join('|')})(?:\\s*:\\s*bearer)?\\s+)(${tokenValuePattern})`,
'gi',
);
sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]');
// 4. Handle file path redaction
sanitized = sanitized.replace(
/((?:[/\\][a-zA-Z0-9_-]+)*[/\\][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi,
'/path/to/[REDACTED].key',
);
return sanitized;
}
/**
* Sanitizes LLM thought content by redacting sensitive data patterns.
*/
export function sanitizeThoughtContent(text: string): string {
return sanitizeErrorMessage(text);
}
+37 -37
View File
@@ -171,7 +171,7 @@ describe('memoryDiscovery', () => {
);
expect(fileCount).toEqual(1);
expect(memoryContent).toContain(path.relative(cwd, filepath).toString());
expect(memoryContent).toContain(filepath);
expect(filePaths).toEqual([filepath]);
});
});
@@ -215,9 +215,9 @@ describe('memoryDiscovery', () => {
memoryContent: flattenMemory(result.memoryContent),
}).toEqual({
memoryContent: `--- Global ---
--- Context from: ${path.relative(cwd, defaultContextFile)} ---
--- Context from: ${defaultContextFile} ---
default context content
--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`,
--- End of Context from: ${defaultContextFile} ---`,
fileCount: 1,
filePaths: [defaultContextFile],
});
@@ -244,9 +244,9 @@ default context content
expect(result).toEqual({
memoryContent: `--- Global ---
--- Context from: ${normMarker(path.relative(cwd, customContextFile))} ---
--- Context from: ${customContextFile} ---
custom context content
--- End of Context from: ${normMarker(path.relative(cwd, customContextFile))} ---`,
--- End of Context from: ${customContextFile} ---`,
fileCount: 1,
filePaths: [customContextFile],
});
@@ -277,13 +277,13 @@ custom context content
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(path.relative(cwd, projectContextFile))} ---
--- Context from: ${projectContextFile} ---
project context content
--- End of Context from: ${normMarker(path.relative(cwd, projectContextFile))} ---
--- End of Context from: ${projectContextFile} ---
--- Context from: ${normMarker(path.relative(cwd, cwdContextFile))} ---
--- Context from: ${cwdContextFile} ---
cwd context content
--- End of Context from: ${normMarker(path.relative(cwd, cwdContextFile))} ---`,
--- End of Context from: ${cwdContextFile} ---`,
fileCount: 2,
filePaths: [projectContextFile, cwdContextFile],
});
@@ -314,13 +314,13 @@ cwd context content
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(customFilename)} ---
--- Context from: ${cwdCustomFile} ---
CWD custom memory
--- End of Context from: ${normMarker(customFilename)} ---
--- End of Context from: ${cwdCustomFile} ---
--- Context from: ${normMarker(path.join('subdir', customFilename))} ---
--- Context from: ${subdirCustomFile} ---
Subdir custom memory
--- End of Context from: ${normMarker(path.join('subdir', customFilename))} ---`,
--- End of Context from: ${subdirCustomFile} ---`,
fileCount: 2,
filePaths: [cwdCustomFile, subdirCustomFile],
});
@@ -348,13 +348,13 @@ Subdir custom memory
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} ---
--- Context from: ${projectRootGeminiFile} ---
Project root memory
--- End of Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} ---
--- End of Context from: ${projectRootGeminiFile} ---
--- Context from: ${normMarker(path.relative(cwd, srcGeminiFile))} ---
--- Context from: ${srcGeminiFile} ---
Src directory memory
--- End of Context from: ${normMarker(path.relative(cwd, srcGeminiFile))} ---`,
--- End of Context from: ${srcGeminiFile} ---`,
fileCount: 2,
filePaths: [projectRootGeminiFile, srcGeminiFile],
});
@@ -382,13 +382,13 @@ Src directory memory
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(DEFAULT_CONTEXT_FILENAME)} ---
--- Context from: ${cwdGeminiFile} ---
CWD memory
--- End of Context from: ${normMarker(DEFAULT_CONTEXT_FILENAME)} ---
--- End of Context from: ${cwdGeminiFile} ---
--- Context from: ${normMarker(path.join('subdir', DEFAULT_CONTEXT_FILENAME))} ---
--- Context from: ${subDirGeminiFile} ---
Subdir memory
--- End of Context from: ${normMarker(path.join('subdir', DEFAULT_CONTEXT_FILENAME))} ---`,
--- End of Context from: ${subDirGeminiFile} ---`,
fileCount: 2,
filePaths: [cwdGeminiFile, subDirGeminiFile],
});
@@ -428,26 +428,26 @@ Subdir memory
expect(result).toEqual({
memoryContent: `--- Global ---
--- Context from: ${normMarker(path.relative(cwd, defaultContextFile))} ---
--- Context from: ${defaultContextFile} ---
default context content
--- End of Context from: ${normMarker(path.relative(cwd, defaultContextFile))} ---
--- End of Context from: ${defaultContextFile} ---
--- Project ---
--- Context from: ${normMarker(path.relative(cwd, rootGeminiFile))} ---
--- Context from: ${rootGeminiFile} ---
Project parent memory
--- End of Context from: ${normMarker(path.relative(cwd, rootGeminiFile))} ---
--- End of Context from: ${rootGeminiFile} ---
--- Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} ---
--- Context from: ${projectRootGeminiFile} ---
Project root memory
--- End of Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} ---
--- End of Context from: ${projectRootGeminiFile} ---
--- Context from: ${normMarker(path.relative(cwd, cwdGeminiFile))} ---
--- Context from: ${cwdGeminiFile} ---
CWD memory
--- End of Context from: ${normMarker(path.relative(cwd, cwdGeminiFile))} ---
--- End of Context from: ${cwdGeminiFile} ---
--- Context from: ${normMarker(path.relative(cwd, subDirGeminiFile))} ---
--- Context from: ${subDirGeminiFile} ---
Subdir memory
--- End of Context from: ${normMarker(path.relative(cwd, subDirGeminiFile))} ---`,
--- End of Context from: ${subDirGeminiFile} ---`,
fileCount: 5,
filePaths: [
defaultContextFile,
@@ -491,9 +491,9 @@ Subdir memory
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(path.relative(cwd, regularSubDirGeminiFile))} ---
--- Context from: ${regularSubDirGeminiFile} ---
My code memory
--- End of Context from: ${normMarker(path.relative(cwd, regularSubDirGeminiFile))} ---`,
--- End of Context from: ${regularSubDirGeminiFile} ---`,
fileCount: 1,
filePaths: [regularSubDirGeminiFile],
});
@@ -565,9 +565,9 @@ My code memory
expect(result).toEqual({
memoryContent: `--- Extension ---
--- Context from: ${normMarker(path.relative(cwd, extensionFilePath))} ---
--- Context from: ${extensionFilePath} ---
Extension memory content
--- End of Context from: ${normMarker(path.relative(cwd, extensionFilePath))} ---`,
--- End of Context from: ${extensionFilePath} ---`,
fileCount: 1,
filePaths: [extensionFilePath],
});
@@ -594,9 +594,9 @@ Extension memory content
expect(result).toEqual({
memoryContent: `--- Project ---
--- Context from: ${normMarker(path.relative(cwd, includedFile))} ---
--- Context from: ${includedFile} ---
included directory memory
--- End of Context from: ${normMarker(path.relative(cwd, includedFile))} ---`,
--- End of Context from: ${includedFile} ---`,
fileCount: 1,
filePaths: [includedFile],
});
+1 -9
View File
@@ -424,8 +424,6 @@ export async function readGeminiMdFiles(
export function concatenateInstructions(
instructionContents: GeminiFileContent[],
// CWD is needed to resolve relative paths for display markers
currentWorkingDirectoryForDisplay: string,
): string {
return instructionContents
.filter((item) => typeof item.content === 'string')
@@ -435,10 +433,7 @@ export function concatenateInstructions(
if (trimmedContent.length === 0) {
return null;
}
const displayPath = path.isAbsolute(item.filePath)
? path.relative(currentWorkingDirectoryForDisplay, item.filePath)
: item.filePath;
return `--- Context from: ${displayPath} ---\n${trimmedContent}\n--- End of Context from: ${displayPath} ---`;
return `--- Context from: ${item.filePath} ---\n${trimmedContent}\n--- End of Context from: ${item.filePath} ---`;
})
.filter((block): block is string => block !== null)
.join('\n\n');
@@ -514,14 +509,12 @@ export async function getEnvironmentMemoryPaths(
export function categorizeAndConcatenate(
paths: { global: string[]; extension: string[]; project: string[] },
contentsMap: Map<string, GeminiFileContent>,
workingDir: string,
): HierarchicalMemory {
const getConcatenated = (pList: string[]) =>
concatenateInstructions(
pList
.map((p) => contentsMap.get(p))
.filter((c): c is GeminiFileContent => !!c),
workingDir,
);
return {
@@ -687,7 +680,6 @@ export async function loadServerHierarchicalMemory(
project: discoveryResult.project,
},
contentsMap,
currentWorkingDirectory,
);
return {
@@ -48,16 +48,16 @@ export interface ProcessImportsResult {
importTree: MemoryFile;
}
// Helper to find the project root (looks for .git directory)
// Helper to find the project root (looks for .git directory or file for worktrees)
async function findProjectRoot(startDir: string): Promise<string> {
let currentDir = path.resolve(startDir);
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
const stats = await fs.lstat(gitPath);
if (stats.isDirectory()) {
return currentDir;
}
// Check for existence only — .git can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(gitPath);
return currentDir;
} catch {
// .git not found, continue to parent
}
+4 -3
View File
@@ -37,9 +37,10 @@ export function determineSurface(): string {
return ide.name;
}
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it.
// This prevents generic terminals from being misidentified as VSCode.
if (process.env['TERM_PROGRAM'] === 'vscode') {
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM or VSCODE_PID confirms it.
// This prevents generic terminals from being misidentified as VSCode, while still detecting
// background processes spawned by the VS Code extension host (like a2a-server).
if (process.env['TERM_PROGRAM'] === 'vscode' || process.env['VSCODE_PID']) {
return ide.name;
}