mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-23 20:40:41 -07:00
feat(core): add generic forbidden resource service for kernel sandboxing
This commit is contained in:
98
packages/core/src/services/forbiddenResourceService.test.ts
Normal file
98
packages/core/src/services/forbiddenResourceService.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { resolveForbiddenResources } from './forbiddenResourceService.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fdir } from 'fdir';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('fdir', () => ({ fdir: vi.fn() }));
|
||||
|
||||
function mockIgnoreFiles(files: Record<string, string>) {
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
|
||||
const fileName = path.basename(filePath.toString());
|
||||
if (fileName in files) return files[fileName];
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
function mockWorkspaceFiles(entries: Array<{ path: string; isDir: boolean }>) {
|
||||
const mockFdir = {
|
||||
withBasePath: vi.fn().mockReturnThis(),
|
||||
withPathSeparator: vi.fn().mockReturnThis(),
|
||||
withDirs: vi.fn().mockReturnThis(),
|
||||
exclude: vi.fn().mockReturnThis(),
|
||||
filter: vi.fn().mockReturnThis(),
|
||||
crawl: vi.fn().mockReturnValue({
|
||||
withPromise: vi.fn().mockImplementation(async () => {
|
||||
const excludeCb = mockFdir.exclude.mock.calls[0]?.[0];
|
||||
const filterCb = mockFdir.filter.mock.calls[0]?.[0];
|
||||
|
||||
for (const entry of entries) {
|
||||
const isExcluded =
|
||||
entry.isDir &&
|
||||
excludeCb?.(path.basename(entry.path), path.dirname(entry.path));
|
||||
|
||||
if (!isExcluded) {
|
||||
filterCb?.(entry.path, entry.isDir);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(fdir).mockImplementation(() => mockFdir as unknown as fdir);
|
||||
}
|
||||
|
||||
describe('forbiddenResourceService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve forbidden resources correctly from ignore files', async () => {
|
||||
mockIgnoreFiles({
|
||||
'.gitignore': 'node_modules/\n.env\n',
|
||||
'.geminiignore': 'secrets/\n',
|
||||
});
|
||||
|
||||
mockWorkspaceFiles([
|
||||
{ path: '/workspace/node_modules', isDir: true },
|
||||
{ path: '/workspace/src', isDir: true },
|
||||
{ path: '/workspace/src/index.ts', isDir: false },
|
||||
{ path: '/workspace/.env', isDir: false },
|
||||
{ path: '/workspace/secrets', isDir: true },
|
||||
]);
|
||||
|
||||
const resources = await resolveForbiddenResources('/workspace');
|
||||
|
||||
expect(resources).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: '/workspace/node_modules', isDirectory: true },
|
||||
{ path: '/workspace/.env', isDirectory: false },
|
||||
{ path: '/workspace/secrets', isDirectory: true },
|
||||
]),
|
||||
);
|
||||
expect(resources).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle missing ignore files gracefully', async () => {
|
||||
mockIgnoreFiles({});
|
||||
|
||||
mockWorkspaceFiles([
|
||||
{ path: '/workspace/src', isDir: true },
|
||||
{ path: '/workspace/src/index.ts', isDir: false },
|
||||
]);
|
||||
|
||||
const resources = await resolveForbiddenResources('/workspace');
|
||||
|
||||
expect(resources).toEqual([]);
|
||||
});
|
||||
});
|
||||
157
packages/core/src/services/forbiddenResourceService.ts
Normal file
157
packages/core/src/services/forbiddenResourceService.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import ignore from 'ignore';
|
||||
import { fdir } from 'fdir';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
type IgnoreManager = ReturnType<typeof ignore>;
|
||||
|
||||
export interface ForbiddenResource {
|
||||
absolutePath: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
const IGNORE_FILES = ['.gitignore', '.geminiignore'];
|
||||
|
||||
/**
|
||||
* Resolves patterns from ignore files into absolute paths of resources in
|
||||
* the workspace.
|
||||
*/
|
||||
export async function resolveForbiddenResources(
|
||||
workspacePath: string,
|
||||
): Promise<ForbiddenResource[]> {
|
||||
const ignoreManager = await buildIgnoreManager(workspacePath);
|
||||
return findForbiddenResources(workspacePath, ignoreManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all configured ignore files into a single ignore manager instance.
|
||||
*/
|
||||
async function buildIgnoreManager(
|
||||
workspacePath: string,
|
||||
): Promise<IgnoreManager> {
|
||||
const ignoreManager = ignore();
|
||||
for (const fileName of IGNORE_FILES) {
|
||||
const content = await readFile(workspacePath, fileName);
|
||||
if (content) {
|
||||
ignoreManager.add(content);
|
||||
}
|
||||
}
|
||||
return ignoreManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file and returns its content, or null if the file does not exist.
|
||||
*/
|
||||
async function readFile(
|
||||
workspacePath: string,
|
||||
fileName: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const filePath = path.join(workspacePath, fileName);
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses the workspace to find all resources that match the ignore rules.
|
||||
*/
|
||||
async function findForbiddenResources(
|
||||
workspacePath: string,
|
||||
ignoreManager: IgnoreManager,
|
||||
): Promise<ForbiddenResource[]> {
|
||||
const forbiddenResources: ForbiddenResource[] = [];
|
||||
|
||||
const crawler = new fdir()
|
||||
.withBasePath()
|
||||
.withPathSeparator('/')
|
||||
.withDirs()
|
||||
// Exclude directories that match ignore rules. This completely stops fdir
|
||||
// from crawling inside them.
|
||||
.exclude((dirName, dirPath) =>
|
||||
recordIfForbidden(
|
||||
workspacePath,
|
||||
path.join(dirPath, dirName),
|
||||
true, // isDirectory
|
||||
ignoreManager,
|
||||
forbiddenResources,
|
||||
),
|
||||
)
|
||||
// Filter everything else against the ignore rules.
|
||||
.filter((resourcePath, isDirectory) =>
|
||||
recordIfForbidden(
|
||||
workspacePath,
|
||||
resourcePath,
|
||||
isDirectory,
|
||||
ignoreManager,
|
||||
forbiddenResources,
|
||||
),
|
||||
);
|
||||
|
||||
await crawler.crawl(workspacePath).withPromise();
|
||||
|
||||
return forbiddenResources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a resource is forbidden, and if so, records it in the array.
|
||||
* Returns true if the resource was forbidden.
|
||||
*/
|
||||
function recordIfForbidden(
|
||||
workspacePath: string,
|
||||
resourcePath: string,
|
||||
isDirectory: boolean,
|
||||
ignoreManager: IgnoreManager,
|
||||
forbiddenResources: ForbiddenResource[],
|
||||
): boolean {
|
||||
const isForbidden = isResourceForbidden(
|
||||
workspacePath,
|
||||
resourcePath,
|
||||
isDirectory,
|
||||
ignoreManager,
|
||||
);
|
||||
if (isForbidden) {
|
||||
forbiddenResources.push({
|
||||
absolutePath: resourcePath,
|
||||
isDirectory,
|
||||
});
|
||||
}
|
||||
return isForbidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a single resource to see if it's forbidden by the ignore rules.
|
||||
*/
|
||||
function isResourceForbidden(
|
||||
workspacePath: string,
|
||||
resourcePath: string,
|
||||
isDirectory: boolean,
|
||||
ignoreManager: IgnoreManager,
|
||||
): boolean {
|
||||
// The `ignore` package expects paths to be relative to the workspace root.
|
||||
let relativePath = path.relative(workspacePath, resourcePath);
|
||||
|
||||
// Directories must end with a trailing slash to correctly match
|
||||
// directory-only rules.
|
||||
if (isDirectory && !relativePath.endsWith('/')) {
|
||||
relativePath += '/';
|
||||
}
|
||||
|
||||
// The workspace root itself cannot be ignored.
|
||||
if (relativePath === '' || relativePath === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoreManager.ignores(relativePath);
|
||||
}
|
||||
Reference in New Issue
Block a user