feat(cli): enhance folder trust with configuration discovery and security warnings (#19492)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-02-20 10:21:03 -08:00
committed by GitHub
parent d54702185b
commit d24f10b087
14 changed files with 994 additions and 49 deletions
+1
View File
@@ -111,6 +111,7 @@ export * from './utils/constants.js';
// Export services
export * from './services/fileDiscoveryService.js';
export * from './services/gitService.js';
export * from './services/FolderTrustDiscoveryService.js';
export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sessionSummaryUtils.js';
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js';
import { GEMINI_DIR } from '../utils/paths.js';
describe('FolderTrustDiscoveryService', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-discovery-test-'),
);
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should discover commands, skills, mcps, and hooks', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
// Mock commands
const commandsDir = path.join(geminiDir, 'commands');
await fs.mkdir(commandsDir);
await fs.writeFile(
path.join(commandsDir, 'test-cmd.toml'),
'prompt = "test"',
);
// Mock skills
const skillsDir = path.join(geminiDir, 'skills');
await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body');
// Mock settings (MCPs, Hooks, and general settings)
const settings = {
mcpServers: {
'test-mcp': { command: 'node', args: ['test.js'] },
},
hooks: {
BeforeTool: [{ command: 'test-hook' }],
},
general: { vimMode: true },
ui: { theme: 'Dark' },
};
await fs.writeFile(
path.join(geminiDir, 'settings.json'),
JSON.stringify(settings),
);
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.commands).toContain('test-cmd');
expect(results.skills).toContain('test-skill');
expect(results.mcps).toContain('test-mcp');
expect(results.hooks).toContain('test-hook');
expect(results.settings).toContain('general');
expect(results.settings).toContain('ui');
expect(results.settings).not.toContain('mcpServers');
expect(results.settings).not.toContain('hooks');
});
it('should flag security warnings for sensitive settings', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
const settings = {
tools: {
allowed: ['git'],
sandbox: false,
},
experimental: {
enableAgents: true,
},
security: {
folderTrust: {
enabled: false,
},
},
};
await fs.writeFile(
path.join(geminiDir, 'settings.json'),
JSON.stringify(settings),
);
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.securityWarnings).toContain(
'This project auto-approves certain tools (tools.allowed).',
);
expect(results.securityWarnings).toContain(
'This project enables autonomous agents (enableAgents).',
);
expect(results.securityWarnings).toContain(
'This project attempts to disable folder trust (security.folderTrust.enabled).',
);
expect(results.securityWarnings).toContain(
'This project disables the security sandbox (tools.sandbox).',
);
});
it('should handle missing .gemini directory', async () => {
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.commands).toHaveLength(0);
expect(results.skills).toHaveLength(0);
expect(results.mcps).toHaveLength(0);
expect(results.hooks).toHaveLength(0);
expect(results.settings).toHaveLength(0);
});
it('should handle malformed settings.json', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json');
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.discoveryErrors[0]).toContain(
'Failed to discover settings: Unexpected token',
);
});
it('should handle null settings.json', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
await fs.writeFile(path.join(geminiDir, 'settings.json'), 'null');
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.discoveryErrors).toHaveLength(0);
expect(results.settings).toHaveLength(0);
});
it('should handle array settings.json', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
await fs.writeFile(path.join(geminiDir, 'settings.json'), '[]');
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.discoveryErrors).toHaveLength(0);
expect(results.settings).toHaveLength(0);
});
it('should handle string settings.json', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
await fs.writeFile(path.join(geminiDir, 'settings.json'), '"string"');
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.discoveryErrors).toHaveLength(0);
expect(results.settings).toHaveLength(0);
});
});
@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import stripJsonComments from 'strip-json-comments';
import { GEMINI_DIR } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';
export interface FolderDiscoveryResults {
commands: string[];
mcps: string[];
hooks: string[];
skills: string[];
settings: string[];
securityWarnings: string[];
discoveryErrors: string[];
}
/**
* A safe, read-only service to discover local configurations in a folder
* before it is trusted.
*/
export class FolderTrustDiscoveryService {
/**
* Discovers configurations in the given workspace directory.
* @param workspaceDir The directory to scan.
* @returns A summary of discovered configurations.
*/
static async discover(workspaceDir: string): Promise<FolderDiscoveryResults> {
const results: FolderDiscoveryResults = {
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
securityWarnings: [],
discoveryErrors: [],
};
const geminiDir = path.join(workspaceDir, GEMINI_DIR);
if (!(await this.exists(geminiDir))) {
return results;
}
await Promise.all([
this.discoverCommands(geminiDir, results),
this.discoverSkills(geminiDir, results),
this.discoverSettings(geminiDir, results),
]);
return results;
}
private static async discoverCommands(
geminiDir: string,
results: FolderDiscoveryResults,
) {
const commandsDir = path.join(geminiDir, 'commands');
if (await this.exists(commandsDir)) {
try {
const files = await fs.readdir(commandsDir, { recursive: true });
results.commands = files
.filter((f) => f.endsWith('.toml'))
.map((f) => path.basename(f, '.toml'));
} catch (e) {
results.discoveryErrors.push(
`Failed to discover commands: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
private static async discoverSkills(
geminiDir: string,
results: FolderDiscoveryResults,
) {
const skillsDir = path.join(geminiDir, 'skills');
if (await this.exists(skillsDir)) {
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
if (await this.exists(skillMdPath)) {
results.skills.push(entry.name);
}
}
}
} catch (e) {
results.discoveryErrors.push(
`Failed to discover skills: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
private static async discoverSettings(
geminiDir: string,
results: FolderDiscoveryResults,
) {
const settingsPath = path.join(geminiDir, 'settings.json');
if (!(await this.exists(settingsPath))) return;
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(stripJsonComments(content)) as unknown;
if (!this.isRecord(settings)) {
debugLogger.debug('Settings must be a JSON object');
return;
}
results.settings = Object.keys(settings).filter(
(key) => !['mcpServers', 'hooks', '$schema'].includes(key),
);
results.securityWarnings = this.collectSecurityWarnings(settings);
const mcpServers = settings['mcpServers'];
if (this.isRecord(mcpServers)) {
results.mcps = Object.keys(mcpServers);
}
const hooksConfig = settings['hooks'];
if (this.isRecord(hooksConfig)) {
const hooks = new Set<string>();
for (const event of Object.values(hooksConfig)) {
if (!Array.isArray(event)) continue;
for (const hook of event) {
if (this.isRecord(hook) && typeof hook['command'] === 'string') {
hooks.add(hook['command']);
}
}
}
results.hooks = Array.from(hooks);
}
} catch (e) {
results.discoveryErrors.push(
`Failed to discover settings: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
private static collectSecurityWarnings(
settings: Record<string, unknown>,
): string[] {
const warnings: string[] = [];
const tools = this.isRecord(settings['tools'])
? settings['tools']
: undefined;
const experimental = this.isRecord(settings['experimental'])
? settings['experimental']
: undefined;
const security = this.isRecord(settings['security'])
? settings['security']
: undefined;
const folderTrust =
security && this.isRecord(security['folderTrust'])
? security['folderTrust']
: undefined;
const allowedTools = tools?.['allowed'];
const checks = [
{
condition: Array.isArray(allowedTools) && allowedTools.length > 0,
message: 'This project auto-approves certain tools (tools.allowed).',
},
{
condition: experimental?.['enableAgents'] === true,
message: 'This project enables autonomous agents (enableAgents).',
},
{
condition: folderTrust?.['enabled'] === false,
message:
'This project attempts to disable folder trust (security.folderTrust.enabled).',
},
{
condition: tools?.['sandbox'] === false,
message: 'This project disables the security sandbox (tools.sandbox).',
},
];
for (const check of checks) {
if (check.condition) warnings.push(check.message);
}
return warnings;
}
private static isRecord(val: unknown): val is Record<string, unknown> {
return !!val && typeof val === 'object' && !Array.isArray(val);
}
private static async exists(filePath: string): Promise<boolean> {
try {
await fs.stat(filePath);
return true;
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
return false;
}
throw e;
}
}
}