mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-22 20:14:58 -07:00
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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 {
|
|
type PolicyEngineConfig,
|
|
PolicyDecision,
|
|
type PolicyRule,
|
|
ApprovalMode,
|
|
type PolicySettings,
|
|
type SafetyCheckerRule,
|
|
} from './types.js';
|
|
import type { PolicyEngine } from './policy-engine.js';
|
|
import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js';
|
|
import { buildArgsPatterns, isSafeRegExp } from './utils.js';
|
|
import toml from '@iarna/toml';
|
|
import {
|
|
MessageBusType,
|
|
type UpdatePolicy,
|
|
} from '../confirmation-bus/types.js';
|
|
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
|
import { coreEvents } from '../utils/events.js';
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
|
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
|
|
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
|
import { isNodeError } from '../utils/errors.js';
|
|
|
|
import { isDirectorySecure } from '../utils/security.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
|
|
|
|
// Policy tier constants for priority calculation
|
|
export const DEFAULT_POLICY_TIER = 1;
|
|
export const EXTENSION_POLICY_TIER = 2;
|
|
export const WORKSPACE_POLICY_TIER = 3;
|
|
export const USER_POLICY_TIER = 4;
|
|
export const ADMIN_POLICY_TIER = 5;
|
|
|
|
// Specific priority offsets and derived priorities for dynamic/settings rules.
|
|
// These are added to the tier base (e.g., USER_POLICY_TIER).
|
|
|
|
// Workspace tier (3) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY
|
|
// This ensures user "always allow" selections are high priority
|
|
// within the workspace tier but still lose to user/admin policies.
|
|
export const ALWAYS_ALLOW_PRIORITY = WORKSPACE_POLICY_TIER + 0.95;
|
|
|
|
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
|
|
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
|
|
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
|
|
export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2;
|
|
export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
|
|
|
|
/**
|
|
* Gets the list of directories to search for policy files, in order of increasing priority
|
|
* (Default -> Extension -> Workspace -> User -> Admin).
|
|
*
|
|
* Note: Extension policies are loaded separately by the extension manager.
|
|
*
|
|
* @param defaultPoliciesDir Optional path to a directory containing default policies.
|
|
* @param policyPaths Optional user-provided policy paths (from --policy flag).
|
|
* When provided, these replace the default user policies directory.
|
|
* @param workspacePoliciesDir Optional path to a directory containing workspace policies.
|
|
*/
|
|
export function getPolicyDirectories(
|
|
defaultPoliciesDir?: string,
|
|
policyPaths?: string[],
|
|
workspacePoliciesDir?: string,
|
|
): string[] {
|
|
const dirs = [];
|
|
|
|
// Admin tier (highest priority)
|
|
dirs.push(Storage.getSystemPoliciesDir());
|
|
|
|
// User tier (second highest priority)
|
|
if (policyPaths && policyPaths.length > 0) {
|
|
dirs.push(...policyPaths);
|
|
} else {
|
|
dirs.push(Storage.getUserPoliciesDir());
|
|
}
|
|
|
|
// Workspace Tier (third highest)
|
|
if (workspacePoliciesDir) {
|
|
dirs.push(workspacePoliciesDir);
|
|
}
|
|
|
|
// Default tier (lowest priority)
|
|
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
|
|
|
|
return dirs;
|
|
}
|
|
|
|
/**
|
|
* Determines the policy tier (1=default, 2=extension, 3=workspace, 4=user, 5=admin) for a given directory.
|
|
* This is used by the TOML loader to assign priority bands.
|
|
*/
|
|
export function getPolicyTier(
|
|
dir: string,
|
|
defaultPoliciesDir?: string,
|
|
workspacePoliciesDir?: string,
|
|
): number {
|
|
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
|
|
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
|
|
|
|
const normalizedDir = path.resolve(dir);
|
|
const normalizedUser = path.resolve(USER_POLICIES_DIR);
|
|
const normalizedAdmin = path.resolve(ADMIN_POLICIES_DIR);
|
|
|
|
if (
|
|
defaultPoliciesDir &&
|
|
normalizedDir === path.resolve(defaultPoliciesDir)
|
|
) {
|
|
return DEFAULT_POLICY_TIER;
|
|
}
|
|
if (normalizedDir === path.resolve(DEFAULT_CORE_POLICIES_DIR)) {
|
|
return DEFAULT_POLICY_TIER;
|
|
}
|
|
if (normalizedDir === normalizedUser) {
|
|
return USER_POLICY_TIER;
|
|
}
|
|
if (
|
|
workspacePoliciesDir &&
|
|
normalizedDir === path.resolve(workspacePoliciesDir)
|
|
) {
|
|
return WORKSPACE_POLICY_TIER;
|
|
}
|
|
if (normalizedDir === normalizedAdmin) {
|
|
return ADMIN_POLICY_TIER;
|
|
}
|
|
|
|
return DEFAULT_POLICY_TIER;
|
|
}
|
|
|
|
/**
|
|
* Formats a policy file error for console logging.
|
|
*/
|
|
export function formatPolicyError(error: PolicyFileError): string {
|
|
const tierLabel = error.tier.toUpperCase();
|
|
let message = `[${tierLabel}] Policy file error in ${error.fileName}:\n`;
|
|
message += ` ${error.message}`;
|
|
if (error.details) {
|
|
message += `\n${error.details}`;
|
|
}
|
|
if (error.suggestion) {
|
|
message += `\n Suggestion: ${error.suggestion}`;
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Filters out insecure policy directories (specifically the system policy directory).
|
|
* Emits warnings if insecure directories are found.
|
|
*/
|
|
async function filterSecurePolicyDirectories(
|
|
dirs: string[],
|
|
): Promise<string[]> {
|
|
const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir());
|
|
|
|
const results = await Promise.all(
|
|
dirs.map(async (dir) => {
|
|
// Only check security for system policies
|
|
if (path.resolve(dir) === systemPoliciesDir) {
|
|
const { secure, reason } = await isDirectorySecure(dir);
|
|
if (!secure) {
|
|
const msg = `Security Warning: Skipping system policies from ${dir}: ${reason}`;
|
|
coreEvents.emitFeedback('warning', msg);
|
|
return null;
|
|
}
|
|
}
|
|
return dir;
|
|
}),
|
|
);
|
|
|
|
return results.filter((dir): dir is string => dir !== null);
|
|
}
|
|
|
|
/**
|
|
* Loads and sanitizes policies from an extension's policies directory.
|
|
* Security: Filters out 'ALLOW' rules and YOLO mode configurations.
|
|
*/
|
|
export async function loadExtensionPolicies(
|
|
extensionName: string,
|
|
policyDir: string,
|
|
): Promise<{
|
|
rules: PolicyRule[];
|
|
checkers: SafetyCheckerRule[];
|
|
errors: PolicyFileError[];
|
|
}> {
|
|
const result = await loadPoliciesFromToml(
|
|
[policyDir],
|
|
() => EXTENSION_POLICY_TIER,
|
|
);
|
|
|
|
const rules = result.rules.filter((rule) => {
|
|
// Security: Extensions are not allowed to automatically approve tool calls.
|
|
if (rule.decision === PolicyDecision.ALLOW) {
|
|
debugLogger.warn(
|
|
`[PolicyConfig] Extension "${extensionName}" attempted to contribute an ALLOW rule for tool "${rule.toolName}". Ignoring this rule for security.`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Security: Extensions are not allowed to contribute YOLO mode rules.
|
|
if (rule.modes?.includes(ApprovalMode.YOLO)) {
|
|
debugLogger.warn(
|
|
`[PolicyConfig] Extension "${extensionName}" attempted to contribute a rule for YOLO mode. Ignoring this rule for security.`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Prefix source with extension name to avoid collisions and double prefixing.
|
|
// toml-loader.ts adds "Extension: file.toml", we transform it to "Extension (name): file.toml".
|
|
rule.source = rule.source?.replace(
|
|
/^Extension: /,
|
|
`Extension (${extensionName}): `,
|
|
);
|
|
return true;
|
|
});
|
|
|
|
const checkers = result.checkers.filter((checker) => {
|
|
// Security: Extensions are not allowed to contribute YOLO mode checkers.
|
|
if (checker.modes?.includes(ApprovalMode.YOLO)) {
|
|
debugLogger.warn(
|
|
`[PolicyConfig] Extension "${extensionName}" attempted to contribute a safety checker for YOLO mode. Ignoring this checker for security.`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Prefix source with extension name.
|
|
checker.source = checker.source?.replace(
|
|
/^Extension: /,
|
|
`Extension (${extensionName}): `,
|
|
);
|
|
return true;
|
|
});
|
|
|
|
return { rules, checkers, errors: result.errors };
|
|
}
|
|
|
|
export async function createPolicyEngineConfig(
|
|
settings: PolicySettings,
|
|
approvalMode: ApprovalMode,
|
|
defaultPoliciesDir?: string,
|
|
): Promise<PolicyEngineConfig> {
|
|
const policyDirs = getPolicyDirectories(
|
|
defaultPoliciesDir,
|
|
settings.policyPaths,
|
|
settings.workspacePoliciesDir,
|
|
);
|
|
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
|
|
|
|
const normalizedAdminPoliciesDir = path.resolve(
|
|
Storage.getSystemPoliciesDir(),
|
|
);
|
|
|
|
// Load policies from TOML files
|
|
const {
|
|
rules: tomlRules,
|
|
checkers: tomlCheckers,
|
|
errors,
|
|
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
|
|
const tier = getPolicyTier(
|
|
p,
|
|
defaultPoliciesDir,
|
|
settings.workspacePoliciesDir,
|
|
);
|
|
|
|
// If it's a user-provided path that isn't already categorized as ADMIN,
|
|
// treat it as USER tier.
|
|
if (
|
|
settings.policyPaths?.some(
|
|
(userPath) => path.resolve(userPath) === path.resolve(p),
|
|
)
|
|
) {
|
|
const normalizedPath = path.resolve(p);
|
|
if (normalizedPath !== normalizedAdminPoliciesDir) {
|
|
return USER_POLICY_TIER;
|
|
}
|
|
}
|
|
|
|
return tier;
|
|
});
|
|
|
|
// Emit any errors encountered during TOML loading to the UI
|
|
// coreEvents has a buffer that will display these once the UI is ready
|
|
if (errors.length > 0) {
|
|
for (const error of errors) {
|
|
coreEvents.emitFeedback('error', formatPolicyError(error));
|
|
}
|
|
}
|
|
|
|
const rules: PolicyRule[] = [...tomlRules];
|
|
const checkers = [...tomlCheckers];
|
|
|
|
// Priority system for policy rules:
|
|
|
|
// - Higher priority numbers win over lower priority numbers
|
|
// - When multiple rules match, the highest priority rule is applied
|
|
// - Rules are evaluated in order of priority (highest first)
|
|
//
|
|
// Priority bands (tiers):
|
|
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
|
|
// - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
|
|
// - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
|
|
// - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
|
|
// - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100)
|
|
//
|
|
// This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved,
|
|
// while allowing user-specified priorities to work within each tier.
|
|
//
|
|
// Settings-based and dynamic rules (mixed tiers):
|
|
// MCP_EXCLUDED_PRIORITY: MCP servers excluded list (security: persistent server blocks)
|
|
// EXCLUDE_TOOLS_FLAG_PRIORITY: Command line flag --exclude-tools (explicit temporary blocks)
|
|
// ALLOWED_TOOLS_FLAG_PRIORITY: Command line flag --allowed-tools (explicit temporary allows)
|
|
// TRUSTED_MCP_SERVER_PRIORITY: MCP servers with trust=true (persistent trusted servers)
|
|
// ALLOWED_MCP_SERVER_PRIORITY: MCP servers allowed list (persistent general server allows)
|
|
// ALWAYS_ALLOW_PRIORITY: Tools that the user has selected as "Always Allow" in the interactive UI
|
|
// (Workspace tier 3.x - scoped to the project)
|
|
//
|
|
// TOML policy priorities (before transformation):
|
|
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
|
|
// 15: Auto-edit tool override (becomes 1.015 in default tier)
|
|
// 50: Read-only tools (becomes 1.050 in default tier)
|
|
// 60: Plan mode catch-all DENY override (becomes 1.060 in default tier)
|
|
// 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier)
|
|
// 999: YOLO mode allow-all (becomes 1.999 in default tier)
|
|
|
|
// MCP servers that are explicitly excluded in settings.mcp.excluded
|
|
// Priority: MCP_EXCLUDED_PRIORITY (highest in user tier for security - persistent server blocks)
|
|
if (settings.mcp?.excluded) {
|
|
for (const serverName of settings.mcp.excluded) {
|
|
rules.push({
|
|
toolName: `${serverName}__*`,
|
|
decision: PolicyDecision.DENY,
|
|
priority: MCP_EXCLUDED_PRIORITY,
|
|
source: 'Settings (MCP Excluded)',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Tools that are explicitly excluded in the settings.
|
|
// Priority: EXCLUDE_TOOLS_FLAG_PRIORITY (user tier - explicit temporary blocks)
|
|
if (settings.tools?.exclude) {
|
|
for (const tool of settings.tools.exclude) {
|
|
rules.push({
|
|
toolName: tool,
|
|
decision: PolicyDecision.DENY,
|
|
priority: EXCLUDE_TOOLS_FLAG_PRIORITY,
|
|
source: 'Settings (Tools Excluded)',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Tools that are explicitly allowed in the settings.
|
|
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
|
|
if (settings.tools?.allowed) {
|
|
for (const tool of settings.tools.allowed) {
|
|
// Check for legacy format: toolName(args)
|
|
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
|
|
if (match) {
|
|
const [, rawToolName, args] = match;
|
|
// Normalize shell tool aliases
|
|
const toolName = SHELL_TOOL_NAMES.includes(rawToolName)
|
|
? SHELL_TOOL_NAME
|
|
: rawToolName;
|
|
|
|
// Treat args as a command prefix for shell tool
|
|
if (toolName === SHELL_TOOL_NAME) {
|
|
const patterns = buildArgsPatterns(undefined, args);
|
|
for (const pattern of patterns) {
|
|
if (pattern) {
|
|
rules.push({
|
|
toolName,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
|
argsPattern: new RegExp(pattern),
|
|
source: 'Settings (Tools Allowed)',
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// For non-shell tools, we allow the tool itself but ignore args
|
|
// as args matching was only supported for shell tools historically.
|
|
rules.push({
|
|
toolName,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
|
source: 'Settings (Tools Allowed)',
|
|
});
|
|
}
|
|
} else {
|
|
// Standard tool name
|
|
const toolName = SHELL_TOOL_NAMES.includes(tool)
|
|
? SHELL_TOOL_NAME
|
|
: tool;
|
|
rules.push({
|
|
toolName,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
|
|
source: 'Settings (Tools Allowed)',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// MCP servers that are trusted in the settings.
|
|
// Priority: TRUSTED_MCP_SERVER_PRIORITY (user tier - persistent trusted servers)
|
|
if (settings.mcpServers) {
|
|
for (const [serverName, serverConfig] of Object.entries(
|
|
settings.mcpServers,
|
|
)) {
|
|
if (serverConfig.trust) {
|
|
// Trust all tools from this MCP server
|
|
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName"
|
|
rules.push({
|
|
toolName: `${serverName}__*`,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: TRUSTED_MCP_SERVER_PRIORITY,
|
|
source: 'Settings (MCP Trusted)',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// MCP servers that are explicitly allowed in settings.mcp.allowed
|
|
// Priority: ALLOWED_MCP_SERVER_PRIORITY (user tier - persistent general server allows)
|
|
if (settings.mcp?.allowed) {
|
|
for (const serverName of settings.mcp.allowed) {
|
|
rules.push({
|
|
toolName: `${serverName}__*`,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: ALLOWED_MCP_SERVER_PRIORITY,
|
|
source: 'Settings (MCP Allowed)',
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
rules,
|
|
checkers,
|
|
defaultDecision: PolicyDecision.ASK_USER,
|
|
approvalMode,
|
|
};
|
|
}
|
|
|
|
interface TomlRule {
|
|
toolName?: string;
|
|
mcpName?: string;
|
|
decision?: string;
|
|
priority?: number;
|
|
commandPrefix?: string | string[];
|
|
argsPattern?: string;
|
|
// Index signature to satisfy Record type if needed for toml.stringify
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export function createPolicyUpdater(
|
|
policyEngine: PolicyEngine,
|
|
messageBus: MessageBus,
|
|
storage: Storage,
|
|
) {
|
|
// 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) => {
|
|
const toolName = message.toolName;
|
|
|
|
if (message.commandPrefix) {
|
|
// Convert commandPrefix(es) to argsPatterns for in-memory rules
|
|
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,
|
|
priority: ALWAYS_ALLOW_PRIORITY,
|
|
argsPattern: new RegExp(pattern),
|
|
source: 'Dynamic (Confirmed)',
|
|
});
|
|
}
|
|
}
|
|
} 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;
|
|
|
|
policyEngine.addRule({
|
|
toolName,
|
|
decision: PolicyDecision.ALLOW,
|
|
priority: ALWAYS_ALLOW_PRIORITY,
|
|
argsPattern,
|
|
source: 'Dynamic (Confirmed)',
|
|
});
|
|
}
|
|
|
|
if (message.persist) {
|
|
persistenceQueue = persistenceQueue.then(async () => {
|
|
try {
|
|
const workspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
|
await fs.mkdir(workspacePoliciesDir, { recursive: true });
|
|
const policyFile = storage.getAutoSavedPolicyPath();
|
|
|
|
// Read existing file
|
|
let existingData: { rule?: TomlRule[] } = {};
|
|
try {
|
|
const fileContent = await fs.readFile(policyFile, 'utf-8');
|
|
const parsed = toml.parse(fileContent);
|
|
if (
|
|
typeof parsed === 'object' &&
|
|
parsed !== null &&
|
|
(!('rule' in parsed) || Array.isArray(parsed['rule']))
|
|
) {
|
|
existingData = parsed as { rule?: TomlRule[] };
|
|
}
|
|
} catch (error) {
|
|
if (!isNodeError(error) || error.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,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|