mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 04:54:25 -07:00
fix(core): prevent race condition in policy persistence (#18506)
Co-authored-by: Allen Hutchison <adh@google.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from './types.js';
|
||||
import type { PolicyEngine } from './policy-engine.js';
|
||||
import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js';
|
||||
import { buildArgsPatterns } from './utils.js';
|
||||
import { buildArgsPatterns, isSafeRegExp } from './utils.js';
|
||||
import toml from '@iarna/toml';
|
||||
import {
|
||||
MessageBusType,
|
||||
@@ -331,6 +332,9 @@ export function createPolicyUpdater(
|
||||
policyEngine: PolicyEngine,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
// Use a sequential queue for persistence to avoid lost updates from concurrent events.
|
||||
let persistenceQueue = Promise.resolve();
|
||||
|
||||
messageBus.subscribe(
|
||||
MessageBusType.UPDATE_POLICY,
|
||||
async (message: UpdatePolicy) => {
|
||||
@@ -341,6 +345,8 @@ export function createPolicyUpdater(
|
||||
const patterns = buildArgsPatterns(undefined, message.commandPrefix);
|
||||
for (const pattern of patterns) {
|
||||
if (pattern) {
|
||||
// Note: patterns from buildArgsPatterns are derived from escapeRegex,
|
||||
// which is safe and won't contain ReDoS patterns.
|
||||
policyEngine.addRule({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
@@ -354,6 +360,14 @@ export function createPolicyUpdater(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (message.argsPattern && !isSafeRegExp(message.argsPattern)) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Invalid or unsafe regular expression for tool ${toolName}: ${message.argsPattern}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const argsPattern = message.argsPattern
|
||||
? new RegExp(message.argsPattern)
|
||||
: undefined;
|
||||
@@ -371,74 +385,88 @@ export function createPolicyUpdater(
|
||||
}
|
||||
|
||||
if (message.persist) {
|
||||
try {
|
||||
const userPoliciesDir = Storage.getUserPoliciesDir();
|
||||
await fs.mkdir(userPoliciesDir, { recursive: true });
|
||||
const policyFile = path.join(userPoliciesDir, 'auto-saved.toml');
|
||||
|
||||
// Read existing file
|
||||
let existingData: { rule?: TomlRule[] } = {};
|
||||
persistenceQueue = persistenceQueue.then(async () => {
|
||||
try {
|
||||
const fileContent = await fs.readFile(policyFile, 'utf-8');
|
||||
existingData = toml.parse(fileContent) as { rule?: TomlRule[] };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
debugLogger.warn(
|
||||
`Failed to parse ${policyFile}, overwriting with new policy.`,
|
||||
error,
|
||||
);
|
||||
const userPoliciesDir = Storage.getUserPoliciesDir();
|
||||
await fs.mkdir(userPoliciesDir, { recursive: true });
|
||||
const policyFile = path.join(userPoliciesDir, 'auto-saved.toml');
|
||||
|
||||
// Read existing file
|
||||
let existingData: { rule?: TomlRule[] } = {};
|
||||
try {
|
||||
const fileContent = await fs.readFile(policyFile, 'utf-8');
|
||||
existingData = toml.parse(fileContent) as { rule?: TomlRule[] };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
debugLogger.warn(
|
||||
`Failed to parse ${policyFile}, overwriting with new policy.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize rule array if needed
|
||||
if (!existingData.rule) {
|
||||
existingData.rule = [];
|
||||
}
|
||||
|
||||
// Create new rule object
|
||||
const newRule: TomlRule = {};
|
||||
|
||||
if (message.mcpName) {
|
||||
newRule.mcpName = message.mcpName;
|
||||
// Extract simple tool name
|
||||
const simpleToolName = toolName.startsWith(`${message.mcpName}__`)
|
||||
? toolName.slice(message.mcpName.length + 2)
|
||||
: toolName;
|
||||
newRule.toolName = simpleToolName;
|
||||
newRule.decision = 'allow';
|
||||
newRule.priority = 200;
|
||||
} else {
|
||||
newRule.toolName = toolName;
|
||||
newRule.decision = 'allow';
|
||||
newRule.priority = 100;
|
||||
}
|
||||
|
||||
if (message.commandPrefix) {
|
||||
newRule.commandPrefix = message.commandPrefix;
|
||||
} else if (message.argsPattern) {
|
||||
// message.argsPattern was already validated above
|
||||
newRule.argsPattern = message.argsPattern;
|
||||
}
|
||||
|
||||
// Add to rules
|
||||
existingData.rule.push(newRule);
|
||||
|
||||
// Serialize back to TOML
|
||||
// @iarna/toml stringify might not produce beautiful output but it handles escaping correctly
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newContent = toml.stringify(existingData as toml.JsonMap);
|
||||
|
||||
// Atomic write: write to a unique tmp file then rename to the target file.
|
||||
// Using a unique suffix avoids race conditions where concurrent processes
|
||||
// overwrite each other's temporary files, leading to ENOENT errors on rename.
|
||||
const tmpSuffix = crypto.randomBytes(8).toString('hex');
|
||||
const tmpFile = `${policyFile}.${tmpSuffix}.tmp`;
|
||||
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// Use 'wx' to create the file exclusively (fails if exists) for security.
|
||||
handle = await fs.open(tmpFile, 'wx');
|
||||
await handle.writeFile(newContent, 'utf-8');
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
await fs.rename(tmpFile, policyFile);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to persist policy for ${toolName}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize rule array if needed
|
||||
if (!existingData.rule) {
|
||||
existingData.rule = [];
|
||||
}
|
||||
|
||||
// Create new rule object
|
||||
const newRule: TomlRule = {};
|
||||
|
||||
if (message.mcpName) {
|
||||
newRule.mcpName = message.mcpName;
|
||||
// Extract simple tool name
|
||||
const simpleToolName = toolName.startsWith(`${message.mcpName}__`)
|
||||
? toolName.slice(message.mcpName.length + 2)
|
||||
: toolName;
|
||||
newRule.toolName = simpleToolName;
|
||||
newRule.decision = 'allow';
|
||||
newRule.priority = 200;
|
||||
} else {
|
||||
newRule.toolName = toolName;
|
||||
newRule.decision = 'allow';
|
||||
newRule.priority = 100;
|
||||
}
|
||||
|
||||
if (message.commandPrefix) {
|
||||
newRule.commandPrefix = message.commandPrefix;
|
||||
} else if (message.argsPattern) {
|
||||
newRule.argsPattern = message.argsPattern;
|
||||
}
|
||||
|
||||
// Add to rules
|
||||
existingData.rule.push(newRule);
|
||||
|
||||
// Serialize back to TOML
|
||||
// @iarna/toml stringify might not produce beautiful output but it handles escaping correctly
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newContent = toml.stringify(existingData as toml.JsonMap);
|
||||
|
||||
// Atomic write: write to tmp then rename
|
||||
const tmpFile = `${policyFile}.tmp`;
|
||||
await fs.writeFile(tmpFile, newContent, 'utf-8');
|
||||
await fs.rename(tmpFile, policyFile);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to persist policy for ${toolName}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user