Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/core/src/policy/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,13 @@ describe('createPolicyEngineConfig', () => {

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir },
default: {
...actualFs,
readdir: mockReaddir,
realpath: vi.fn(async (p) => p),
},
readdir: mockReaddir,
realpath: vi.fn(async (p) => p),
}));

// Mock Storage to avoid actual filesystem access for policy dirs during tests if needed,
Expand Down Expand Up @@ -486,10 +491,12 @@ describe('createPolicyEngineConfig', () => {
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: vi.fn(async (p) => p),
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: vi.fn(async (p) => p),
}));
vi.resetModules();
const { createPolicyEngineConfig: createConfig } = await import(
Expand Down Expand Up @@ -705,10 +712,12 @@ priority = 150
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
}));

vi.resetModules();
Expand Down Expand Up @@ -834,10 +843,12 @@ required_context = ["environment"]
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
}));

vi.resetModules();
Expand Down Expand Up @@ -956,10 +967,12 @@ name = "invalid-name"
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
}));

vi.resetModules();
Expand Down Expand Up @@ -1021,8 +1034,13 @@ name = "invalid-name"
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir },
default: {
...actualFs,
readdir: mockReaddir,
realpath: vi.fn(async (p) => p),
},
readdir: mockReaddir,
realpath: vi.fn(async (p) => p),
}));

const { createPolicyEngineConfig } = await import('./config.js');
Expand Down Expand Up @@ -1122,10 +1140,12 @@ modes = ["plan"]
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
realpath: vi.fn(async (p) => p),
}));

vi.resetModules();
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/policy/integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class PolicyIntegrityManager {
policyDir: string,
): Promise<IntegrityResult> {
const { hash: currentHash, fileCount } =
await PolicyIntegrityManager.calculateIntegrityHash(policyDir);
await PolicyIntegrityManager.calculateIntegrityHash(policyDir, scope);
const storedData = await this.loadIntegrityData();
const key = this.getIntegrityKey(scope, identifier);
const storedHash = storedData[key];
Expand Down Expand Up @@ -86,9 +86,28 @@ export class PolicyIntegrityManager {
*/
private static async calculateIntegrityHash(
policyDir: string,
scope: string,
): Promise<{ hash: string; fileCount: number }> {
try {
const files = await readPolicyFiles(policyDir);
// Map scope to tierName for readPolicyFiles
const tierName =
scope === 'user' || scope === 'admin' || scope === 'default'
? scope
: 'workspace';

const { files, errors: readErrors } = await readPolicyFiles(
policyDir,
tierName,
);

if (readErrors.length > 0) {
const errorDetails = readErrors
.map((e) => ` - ${e.filePath}: ${e.details}`)
.join('\n');
debugLogger.error(
`[PolicyIntegrity] Unreadable policy files found in ${policyDir}, they will be excluded from the integrity hash:\n${errorDetails}`,
);
}

// Sort files by path to ensure deterministic hashing
files.sort((a, b) => a.path.localeCompare(b.path));
Expand Down
57 changes: 50 additions & 7 deletions packages/core/src/policy/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import path from 'node:path';
import toml from '@iarna/toml';
import { z, type ZodError } from 'zod';
import { isNodeError } from '../utils/errors.js';
import { isWithinRoot } from '../utils/fileUtils.js';
import { MCP_TOOL_PREFIX, formatMcpToolName } from '../tools/mcp-tool.js';

/**
Expand Down Expand Up @@ -155,7 +156,8 @@ export interface PolicyFile {
*/
export async function readPolicyFiles(
policyPath: string,
): Promise<PolicyFile[]> {
tierName: 'default' | 'extension' | 'user' | 'workspace' | 'admin',
): Promise<{ files: PolicyFile[]; errors: PolicyFileError[] }> {
let filesToLoad: string[] = [];
let baseDir = '';

Expand All @@ -165,26 +167,65 @@ export async function readPolicyFiles(
baseDir = policyPath;
const dirEntries = await fs.readdir(policyPath, { withFileTypes: true });
filesToLoad = dirEntries
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
.filter(
(entry) =>
(entry.isFile() || entry.isSymbolicLink()) &&
entry.name.endsWith('.toml'),
)
.map((entry) => entry.name);
} else if (stats.isFile() && policyPath.endsWith('.toml')) {
baseDir = path.dirname(policyPath);
filesToLoad = [path.basename(policyPath)];
}
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
return [];
return { files: [], errors: [] };
}
throw e;
}

const results: PolicyFile[] = [];
const errors: PolicyFileError[] = [];
for (const file of filesToLoad) {
const filePath = path.join(baseDir, file);
const content = await fs.readFile(filePath, 'utf-8');
results.push({ path: filePath, content });
try {
const realFilePath = await fs.realpath(filePath);

// For workspace policies, ensure the symlink doesn't "escape" the policy directory
if (tierName === 'workspace') {
const realBaseDir = await fs.realpath(baseDir);
if (!isWithinRoot(realFilePath, realBaseDir)) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'file_read',
message:
'Security violation: Symbolic link points outside of the workspace policy directory.',
details: `Symlink ${file} resolves to a path outside of the policy folder. Workspace policies are restricted for security.`,
suggestion:
'To use global policies, place them in your user directory (~/.gemini/policies/) instead.',
});
continue;
}
}

const content = await fs.readFile(realFilePath, 'utf-8');
results.push({ path: filePath, content });
} catch (e) {
errors.push({
filePath,
fileName: file,
tier: tierName,
errorType: 'file_read',
message: 'Failed to read policy file',
details: isNodeError(e) ? e.message : String(e),
suggestion:
'Check if the file is a broken symbolic link or an unreadable directory.',
});
}
}
return results;
return { files: results, errors };
}

/**
Expand Down Expand Up @@ -332,7 +373,9 @@ export async function loadPoliciesFromToml(
let policyFiles: PolicyFile[] = [];

try {
policyFiles = await readPolicyFiles(p);
const readResult = await readPolicyFiles(p, tierName);
policyFiles = readResult.files;
errors.push(...readResult.errors);
} catch (e) {
errors.push({
filePath: p,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/policy/workspace-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,21 @@ priority = 10
return '';
});

const mockRealpath = vi.fn(async (p) => p);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
}));

const { createPolicyEngineConfig } = await import('./config.js');
Expand Down Expand Up @@ -197,17 +201,21 @@ decision="allow"
priority=10`,
);

const mockRealpath = vi.fn(async (p) => p);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
}));

const { createPolicyEngineConfig } = await import('./config.js');
Expand Down Expand Up @@ -262,17 +270,21 @@ decision="allow"
priority=500`,
);

const mockRealpath = vi.fn(async (p) => p);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
realpath: mockRealpath,
}));

const { createPolicyEngineConfig } = await import('./config.js');
Expand Down