/** * @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[]; agents: 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 { const results: FolderDiscoveryResults = { commands: [], mcps: [], hooks: [], skills: [], agents: [], 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.discoverAgents(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 discoverAgents( geminiDir: string, results: FolderDiscoveryResults, ) { const agentsDir = path.join(geminiDir, 'agents'); if (await this.exists(agentsDir)) { try { const entries = await fs.readdir(agentsDir, { withFileTypes: true }); for (const entry of entries) { if ( entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('_') ) { results.agents.push(path.basename(entry.name, '.md')); } } if (results.agents.length > 0) { results.securityWarnings.push('This project contains custom agents.'); } } catch (e) { results.discoveryErrors.push( `Failed to discover agents: ${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.push(...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(); for (const event of Object.values(hooksConfig)) { if (!Array.isArray(event)) continue; for (const hook of event) { // eslint-disable-next-line no-restricted-syntax 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[] { const warnings: string[] = []; const tools = this.isRecord(settings['tools']) ? settings['tools'] : 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: 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 { return !!val && typeof val === 'object' && !Array.isArray(val); } private static async exists(filePath: string): Promise { try { await fs.stat(filePath); return true; } catch (e) { if (isNodeError(e) && e.code === 'ENOENT') { return false; } throw e; } } }