mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-23 04:21:31 -07:00
feat(cli): add security consent prompts for skill installation (#16549)
This commit is contained in:
@@ -78,4 +78,33 @@ describe('skillUtils', () => {
|
||||
const installedExists = await fs.stat(installedPath).catch(() => null);
|
||||
expect(installedExists?.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should abort installation if consent is rejected', async () => {
|
||||
const mockSkillDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const requestConsent = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
installSkill(
|
||||
mockSkillDir,
|
||||
'workspace',
|
||||
undefined,
|
||||
() => {},
|
||||
requestConsent,
|
||||
),
|
||||
).rejects.toThrow('Skill installation cancelled by user.');
|
||||
|
||||
expect(requestConsent).toHaveBeenCalled();
|
||||
|
||||
// Verify it was NOT copied
|
||||
const installedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const installedExists = await fs.stat(installedPath).catch(() => null);
|
||||
expect(installedExists).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
import { SettingScope } from '../config/settings.js';
|
||||
import type { SkillActionResult } from './skillSettings.js';
|
||||
import { Storage, loadSkillsFromDir } from '@google/gemini-cli-core';
|
||||
import {
|
||||
Storage,
|
||||
loadSkillsFromDir,
|
||||
type SkillDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cloneFromGit } from '../config/extensions/github.js';
|
||||
import extract from 'extract-zip';
|
||||
import * as fs from 'node:fs/promises';
|
||||
@@ -79,6 +83,10 @@ export async function installSkill(
|
||||
scope: 'user' | 'workspace',
|
||||
subpath: string | undefined,
|
||||
onLog: (msg: string) => void,
|
||||
requestConsent: (
|
||||
skills: SkillDefinition[],
|
||||
targetDir: string,
|
||||
) => Promise<boolean> = () => Promise.resolve(true),
|
||||
): Promise<Array<{ name: string; location: string }>> {
|
||||
let sourcePath = source;
|
||||
let tempDirToClean: string | undefined = undefined;
|
||||
@@ -90,85 +98,92 @@ export async function installSkill(
|
||||
|
||||
const isSkillFile = source.toLowerCase().endsWith('.skill');
|
||||
|
||||
if (isGitUrl) {
|
||||
tempDirToClean = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-skill-'));
|
||||
sourcePath = tempDirToClean;
|
||||
try {
|
||||
if (isGitUrl) {
|
||||
tempDirToClean = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-skill-'),
|
||||
);
|
||||
sourcePath = tempDirToClean;
|
||||
|
||||
onLog(`Cloning skill from ${source}...`);
|
||||
// Reuse existing robust git cloning utility from extension manager.
|
||||
await cloneFromGit(
|
||||
{
|
||||
source,
|
||||
type: 'git',
|
||||
},
|
||||
tempDirToClean,
|
||||
);
|
||||
} else if (isSkillFile) {
|
||||
tempDirToClean = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-skill-'));
|
||||
sourcePath = tempDirToClean;
|
||||
onLog(`Cloning skill from ${source}...`);
|
||||
// Reuse existing robust git cloning utility from extension manager.
|
||||
await cloneFromGit(
|
||||
{
|
||||
source,
|
||||
type: 'git',
|
||||
},
|
||||
tempDirToClean,
|
||||
);
|
||||
} else if (isSkillFile) {
|
||||
tempDirToClean = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-skill-'),
|
||||
);
|
||||
sourcePath = tempDirToClean;
|
||||
|
||||
onLog(`Extracting skill from ${source}...`);
|
||||
await extract(path.resolve(source), { dir: tempDirToClean });
|
||||
}
|
||||
onLog(`Extracting skill from ${source}...`);
|
||||
await extract(path.resolve(source), { dir: tempDirToClean });
|
||||
}
|
||||
|
||||
// If a subpath is provided, resolve it against the cloned/local root.
|
||||
if (subpath) {
|
||||
sourcePath = path.join(sourcePath, subpath);
|
||||
}
|
||||
// If a subpath is provided, resolve it against the cloned/local root.
|
||||
if (subpath) {
|
||||
sourcePath = path.join(sourcePath, subpath);
|
||||
}
|
||||
|
||||
sourcePath = path.resolve(sourcePath);
|
||||
sourcePath = path.resolve(sourcePath);
|
||||
|
||||
// Quick security check to prevent directory traversal out of temp dir when cloning
|
||||
if (tempDirToClean && !sourcePath.startsWith(path.resolve(tempDirToClean))) {
|
||||
// Quick security check to prevent directory traversal out of temp dir when cloning
|
||||
if (
|
||||
tempDirToClean &&
|
||||
!sourcePath.startsWith(path.resolve(tempDirToClean))
|
||||
) {
|
||||
throw new Error('Invalid path: Directory traversal not allowed.');
|
||||
}
|
||||
|
||||
onLog(`Searching for skills in ${sourcePath}...`);
|
||||
const skills = await loadSkillsFromDir(sourcePath);
|
||||
|
||||
if (skills.length === 0) {
|
||||
throw new Error(
|
||||
`No valid skills found in ${source}${subpath ? ` at path "${subpath}"` : ''}. Ensure a SKILL.md file exists with valid frontmatter.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const targetDir =
|
||||
scope === 'workspace'
|
||||
? storage.getProjectSkillsDir()
|
||||
: Storage.getUserSkillsDir();
|
||||
|
||||
if (!(await requestConsent(skills, targetDir))) {
|
||||
throw new Error('Skill installation cancelled by user.');
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const installedSkills: Array<{ name: string; location: string }> = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillName = skill.name;
|
||||
const skillDir = path.dirname(skill.location);
|
||||
const destPath = path.join(targetDir, skillName);
|
||||
|
||||
const exists = await fs.stat(destPath).catch(() => null);
|
||||
if (exists) {
|
||||
onLog(`Skill "${skillName}" already exists. Overwriting...`);
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.cp(skillDir, destPath, { recursive: true });
|
||||
installedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
return installedSkills;
|
||||
} finally {
|
||||
if (tempDirToClean) {
|
||||
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
||||
}
|
||||
throw new Error('Invalid path: Directory traversal not allowed.');
|
||||
}
|
||||
|
||||
onLog(`Searching for skills in ${sourcePath}...`);
|
||||
const skills = await loadSkillsFromDir(sourcePath);
|
||||
|
||||
if (skills.length === 0) {
|
||||
if (tempDirToClean) {
|
||||
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
||||
}
|
||||
throw new Error(
|
||||
`No valid skills found in ${source}${subpath ? ` at path "${subpath}"` : ''}. Ensure a SKILL.md file exists with valid frontmatter.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const targetDir =
|
||||
scope === 'workspace'
|
||||
? storage.getProjectSkillsDir()
|
||||
: Storage.getUserSkillsDir();
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const installedSkills: Array<{ name: string; location: string }> = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillName = skill.name;
|
||||
const skillDir = path.dirname(skill.location);
|
||||
const destPath = path.join(targetDir, skillName);
|
||||
|
||||
const exists = await fs.stat(destPath).catch(() => null);
|
||||
if (exists) {
|
||||
onLog(`Skill "${skillName}" already exists. Overwriting...`);
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.cp(skillDir, destPath, { recursive: true });
|
||||
installedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
if (tempDirToClean) {
|
||||
await fs.rm(tempDirToClean, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return installedSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user