mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 12:04:56 -07:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user