Agent Skills: Extension Support & Security Disclosure (#15834)

This commit is contained in:
N. Taylor Mullen
2026-01-04 14:45:07 -08:00
committed by GitHub
parent 12c7c9cc42
commit bdb349e7f6
9 changed files with 476 additions and 31 deletions

View File

@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { loadSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
import { coreEvents } from '@google/gemini-cli-core';
const mockHomedir = vi.hoisted(() => vi.fn());
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: mockHomedir,
};
});
describe('ExtensionManager skills validation', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(userExtensionsDir, { recursive: true });
mockHomedir.mockReturnValue(tempHomeDir);
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn().mockResolvedValue(''),
settings: loadSettings(tempWorkspaceDir).merged,
});
vi.spyOn(coreEvents, 'emitFeedback');
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(sourceExtDir, 'skills');
fs.mkdirSync(skillsDir);
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
await extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(extension.name).toBe('skills-ext');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
});
it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'load-skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(extDir, 'skills');
fs.mkdirSync(skillsDir);
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
await extensionManager.loadExtensions();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The directory is not empty but no valid skills were discovered',
),
);
});
it('should succeed if skills are correctly loaded', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'good-skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(sourceExtDir, 'skills');
const skillSubdir = path.join(skillsDir, 'test-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test desc\n---\nbody',
);
await extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(extension.skills).toHaveLength(1);
expect(extension.skills![0].name).toBe('test-skill');
// It might be called for other reasons during startup, but shouldn't be called for our skills loading success
// Actually, it shouldn't be called with our warning message
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
});
});

View File

@@ -38,6 +38,7 @@ import {
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
loadSkillsFromDir,
type ExtensionEvents,
type MCPServerConfig,
type ExtensionInstallMetadata,
@@ -262,10 +263,17 @@ Would you like to attempt to install via "git clone" instead?`,
const newHasHooks = fs.existsSync(
path.join(localSourcePath, 'hooks', 'hooks.json'),
);
let previousHasHooks = false;
if (isUpdate && previous && previous.hooks) {
previousHasHooks = Object.keys(previous.hooks).length > 0;
}
const previousHasHooks = !!(
isUpdate &&
previous &&
previous.hooks &&
Object.keys(previous.hooks).length > 0
);
const newSkills = await loadSkillsFromDir(
path.join(localSourcePath, 'skills'),
);
const previousSkills = previous?.skills ?? [];
await maybeRequestConsentOrFail(
newExtensionConfig,
@@ -273,6 +281,8 @@ Would you like to attempt to install via "git clone" instead?`,
newHasHooks,
previousExtensionConfig,
previousHasHooks,
newSkills,
previousSkills,
);
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
const destinationPath = new ExtensionStorage(
@@ -551,6 +561,10 @@ Would you like to attempt to install via "git clone" instead?`,
});
}
const skills = await loadSkillsFromDir(
path.join(effectiveExtensionPath, 'skills'),
);
const extension: GeminiCLIExtension = {
name: config.name,
version: config.version,
@@ -567,6 +581,7 @@ Would you like to attempt to install via "git clone" instead?`,
id: getExtensionId(config, installMetadata),
settings: config.settings,
resolvedSettings,
skills,
};
this.loadedExtensions = [...this.loadedExtensions, extension];
@@ -721,6 +736,12 @@ Would you like to attempt to install via "git clone" instead?`,
output += `\n ${tool}`;
});
}
if (extension.skills && extension.skills.length > 0) {
output += `\n Agent skills:`;
extension.skills.forEach((skill) => {
output += `\n ${skill.name}: ${skill.description}`;
});
}
const resolvedSettings = extension.resolvedSettings;
if (resolvedSettings && resolvedSettings.length > 0) {
output += `\n Settings:`;

View File

@@ -5,15 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import chalk from 'chalk';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
INSTALL_WARNING_MESSAGE,
SKILLS_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
const mockReadline = vi.hoisted(() => ({
createInterface: vi.fn().mockReturnValue({
@@ -40,11 +45,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
describe('consent', () => {
beforeEach(() => {
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-'));
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
describe('requestConsentNonInteractive', () => {
@@ -250,6 +262,102 @@ describe('consent', () => {
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if skills change', async () => {
const skill1Dir = path.join(tempDir, 'skill1');
const skill2Dir = path.join(tempDir, 'skill2');
await fs.mkdir(skill1Dir, { recursive: true });
await fs.mkdir(skill2Dir, { recursive: true });
await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra');
await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2');
const skill1: SkillDefinition = {
name: 'skill1',
description: 'desc1',
location: path.join(skill1Dir, 'SKILL.md'),
body: 'body1',
};
const skill2: SkillDefinition = {
name: 'skill2',
description: 'desc2',
location: path.join(skill2Dir, 'SKILL.md'),
body: 'body2',
};
const config: ExtensionConfig = {
...baseConfig,
mcpServers: {
server1: { command: 'npm', args: ['start'] },
server2: { httpUrl: 'https://remote.com' },
},
contextFileName: 'my-context.md',
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
config,
requestConsent,
false,
undefined,
false,
[skill1, skill2],
);
const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will run the following MCP servers:',
' * server1 (local): npm start',
' * server2 (remote): https://remote.com',
'This extension will append info to your gemini.md context using my-context.md',
'This extension will exclude the following core tools: tool1,tool2',
'',
chalk.bold('Agent Skills:'),
SKILLS_WARNING_MESSAGE,
'This extension will install the following agent skills:',
` * ${chalk.bold('skill1')}: desc1`,
` (Location: ${skill1.location}) (2 items in directory)`,
` * ${chalk.bold('skill2')}: desc2`,
` (Location: ${skill2.location}) (1 items in directory)`,
'',
].join('\n');
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
it('should show a warning if the skill directory cannot be read', async () => {
const lockedDir = path.join(tempDir, 'locked');
await fs.mkdir(lockedDir, { recursive: true, mode: 0o000 });
const skill: SkillDefinition = {
name: 'locked-skill',
description: 'A skill in a locked dir',
location: path.join(lockedDir, 'SKILL.md'),
body: 'body',
};
const requestConsent = vi.fn().mockResolvedValue(true);
try {
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
undefined,
false,
[skill],
);
expect(requestConsent).toHaveBeenCalledWith(
expect.stringContaining(
` (Location: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`,
),
);
} finally {
// Restore permissions so cleanup works
await fs.chmod(lockedDir, 0o700);
}
});
});
});
});

View File

@@ -4,14 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
import chalk from 'chalk';
import type { ConfirmationRequest } from '../../ui/types.js';
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
import type { ExtensionConfig } from '../extension.js';
export const INSTALL_WARNING_MESSAGE =
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
export const INSTALL_WARNING_MESSAGE = chalk.yellow(
'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.',
);
export const SKILLS_WARNING_MESSAGE = chalk.yellow(
"Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.",
);
/**
* Requests consent from the user to perform an action, by reading a Y/n
@@ -38,7 +46,7 @@ export async function requestConsentNonInteractive(
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
@@ -82,7 +90,7 @@ async function promptForConsentNonInteractive(
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
@@ -103,10 +111,11 @@ async function promptForConsentInteractive(
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(
async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
): string {
skills: SkillDefinition[] = [],
): Promise<string> {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
@@ -138,6 +147,24 @@ function extensionConsentString(
'⚠️ This extension contains Hooks which can automatically execute commands.',
);
}
if (skills.length > 0) {
output.push(`\n${chalk.bold('Agent Skills:')}`);
output.push(SKILLS_WARNING_MESSAGE);
output.push('This extension will install the following agent skills:');
for (const skill of skills) {
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
const skillDir = path.dirname(skill.location);
let fileCountStr = '';
try {
const skillDirItems = await fs.readdir(skillDir);
fileCountStr = ` (${skillDirItems.length} items in directory)`;
} catch {
fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`;
}
output.push(` (Location: ${skill.location})${fileCountStr}`);
}
output.push('');
}
return output.join('\n');
}
@@ -156,12 +183,19 @@ export async function maybeRequestConsentOrFail(
hasHooks: boolean,
previousExtensionConfig?: ExtensionConfig,
previousHasHooks?: boolean,
skills: SkillDefinition[] = [],
previousSkills: SkillDefinition[] = [],
) {
const extensionConsent = extensionConsentString(extensionConfig, hasHooks);
const extensionConsent = await extensionConsentString(
extensionConfig,
hasHooks,
skills,
);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
const previousExtensionConsent = await extensionConsentString(
previousExtensionConfig,
previousHasHooks ?? false,
previousSkills,
);
if (previousExtensionConsent === extensionConsent) {
return;