From 04892d4fba9ade5f6069f46e271c32d0b087cbbe Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 18 Mar 2026 17:19:31 -0400 Subject: [PATCH] feat(core): add generic forbidden resource service for kernel sandboxing --- .../services/forbiddenResourceService.test.ts | 98 +++++++++++ .../src/services/forbiddenResourceService.ts | 157 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 packages/core/src/services/forbiddenResourceService.test.ts create mode 100644 packages/core/src/services/forbiddenResourceService.ts diff --git a/packages/core/src/services/forbiddenResourceService.test.ts b/packages/core/src/services/forbiddenResourceService.test.ts new file mode 100644 index 0000000000..235995b285 --- /dev/null +++ b/packages/core/src/services/forbiddenResourceService.test.ts @@ -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) { + 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([]); + }); +}); diff --git a/packages/core/src/services/forbiddenResourceService.ts b/packages/core/src/services/forbiddenResourceService.ts new file mode 100644 index 0000000000..6a26d1c800 --- /dev/null +++ b/packages/core/src/services/forbiddenResourceService.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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); +}