feat(cli): add security consent prompts for skill installation (#16549)

This commit is contained in:
N. Taylor Mullen
2026-01-14 17:47:02 -08:00
committed by GitHub
parent 5bdfe1a1fa
commit a81500a929
7 changed files with 296 additions and 101 deletions
@@ -7,11 +7,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockInstallSkill = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
vi.mock('../../utils/skillUtils.js', () => ({
installSkill: mockInstallSkill,
}));
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
skillsConsentString: mockSkillsConsentString,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: { log: vi.fn(), error: vi.fn() },
}));
@@ -23,6 +30,8 @@ describe('skill install command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
mockSkillsConsentString.mockResolvedValue('Mock Consent String');
mockRequestConsentNonInteractive.mockResolvedValue(true);
});
describe('installCommand', () => {
@@ -37,9 +46,10 @@ describe('skill install command', () => {
});
it('should call installSkill with correct arguments for user scope', async () => {
mockInstallSkill.mockResolvedValue([
{ name: 'test-skill', location: '/mock/user/skills/test-skill' },
]);
mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => {
await rc([]);
return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }];
});
await handleInstall({
source: 'https://example.com/repo.git',
@@ -51,6 +61,7 @@ describe('skill install command', () => {
'user',
undefined,
expect.any(Function),
expect.any(Function),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Successfully installed skill: test-skill'),
@@ -58,6 +69,47 @@ describe('skill install command', () => {
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('location: /mock/user/skills/test-skill'),
);
expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith(
'Mock Consent String',
);
});
it('should skip prompt and log consent when --consent is provided', async () => {
mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => {
await rc([]);
return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }];
});
await handleInstall({
source: 'https://example.com/repo.git',
consent: true,
});
expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled();
expect(debugLogger.log).toHaveBeenCalledWith(
'You have consented to the following:',
);
expect(debugLogger.log).toHaveBeenCalledWith('Mock Consent String');
expect(mockInstallSkill).toHaveBeenCalled();
});
it('should abort installation if consent is denied', async () => {
mockRequestConsentNonInteractive.mockResolvedValue(false);
mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => {
if (!(await rc([]))) {
throw new Error('Skill installation cancelled by user.');
}
return [];
});
await handleInstall({
source: 'https://example.com/repo.git',
});
expect(debugLogger.error).toHaveBeenCalledWith(
'Skill installation cancelled by user.',
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it('should call installSkill with correct arguments for workspace scope and subpath', async () => {
@@ -76,6 +128,7 @@ describe('skill install command', () => {
'workspace',
'my-skills-dir',
expect.any(Function),
expect.any(Function),
);
});
+29 -2
View File
@@ -5,24 +5,43 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { exitCli } from '../utils.js';
import { installSkill } from '../../utils/skillUtils.js';
import chalk from 'chalk';
import {
requestConsentNonInteractive,
skillsConsentString,
} from '../../config/extensions/consent.js';
interface InstallArgs {
source: string;
scope?: 'user' | 'workspace';
path?: string;
consent?: boolean;
}
export async function handleInstall(args: InstallArgs) {
try {
const { source } = args;
const { source, consent } = args;
const scope = args.scope ?? 'user';
const subpath = args.path;
const requestConsent = async (
skills: SkillDefinition[],
targetDir: string,
) => {
if (consent) {
debugLogger.log('You have consented to the following:');
debugLogger.log(await skillsConsentString(skills, source, targetDir));
return true;
}
return requestConsentNonInteractive(
await skillsConsentString(skills, source, targetDir),
);
};
const installedSkills = await installSkill(
source,
scope,
@@ -30,6 +49,7 @@ export async function handleInstall(args: InstallArgs) {
(msg) => {
debugLogger.log(msg);
},
requestConsent,
);
for (const skill of installedSkills) {
@@ -68,6 +88,12 @@ export const installCommand: CommandModule = {
'Sub-path within the repository to install from (only used for git repository sources).',
type: 'string',
})
.option('consent', {
describe:
'Acknowledge the security risks of installing a skill and skip the confirmation prompt.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.source) {
throw new Error('The source argument must be provided.');
@@ -79,6 +105,7 @@ export const installCommand: CommandModule = {
source: argv['source'] as string,
scope: argv['scope'] as 'user' | 'workspace',
path: argv['path'] as string | undefined,
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
},