import { feature } from 'bun:bundle' import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' import { TASK_OUTPUT_TOOL_NAME } from '../../tools/TaskOutputTool/constants.js' import { TASK_STOP_TOOL_NAME } from '../../tools/TaskStopTool/prompt.js' import type { PermissionRuleValue } from './PermissionRule.js' // Dead code elimination: ant-only tool names are conditionally required so // their strings don't leak into external builds. Static imports always bundle. /* eslint-disable @typescript-eslint/no-require-imports */ const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('../../tools/BriefTool/prompt.js') as typeof import('../../tools/BriefTool/prompt.js') ).BRIEF_TOOL_NAME : null /* eslint-enable @typescript-eslint/no-require-imports */ // Maps legacy tool names to their current canonical names. // When a tool is renamed, add old → new here so permission rules, // hooks, and persisted wire names resolve to the canonical name. const LEGACY_TOOL_NAME_ALIASES: Record = { Task: AGENT_TOOL_NAME, KillShell: TASK_STOP_TOOL_NAME, AgentOutputTool: TASK_OUTPUT_TOOL_NAME, BashOutputTool: TASK_OUTPUT_TOOL_NAME, ...((feature('KAIROS') || feature('KAIROS_BRIEF')) && BRIEF_TOOL_NAME ? { Brief: BRIEF_TOOL_NAME } : {}), } export function normalizeLegacyToolName(name: string): string { return LEGACY_TOOL_NAME_ALIASES[name] ?? name } export function getLegacyToolNames(canonicalName: string): string[] { const result: string[] = [] for (const [legacy, canonical] of Object.entries(LEGACY_TOOL_NAME_ALIASES)) { if (canonical === canonicalName) result.push(legacy) } return result } /** * Escapes special characters in rule content for safe storage in permission rules. * Permission rules use the format "Tool(content)", so parentheses in content must be escaped. * * Escaping order matters: * 1. Escape existing backslashes first (\ -> \\) * 2. Then escape parentheses (( -> \(, ) -> \)) * * @example * escapeRuleContent('psycopg2.connect()') // => 'psycopg2.connect\\(\\)' * escapeRuleContent('echo "test\\nvalue"') // => 'echo "test\\\\nvalue"' */ export function escapeRuleContent(content: string): string { return content .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\(/g, '\\(') // Escape opening parentheses .replace(/\)/g, '\\)') // Escape closing parentheses } /** * Unescapes special characters in rule content after parsing from permission rules. * This reverses the escaping done by escapeRuleContent. * * Unescaping order matters (reverse of escaping): * 1. Unescape parentheses first (\( -> (, \) -> )) * 2. Then unescape backslashes (\\ -> \) * * @example * unescapeRuleContent('psycopg2.connect\\(\\)') // => 'psycopg2.connect()' * unescapeRuleContent('echo "test\\\\nvalue"') // => 'echo "test\\nvalue"' */ export function unescapeRuleContent(content: string): string { return content .replace(/\\\(/g, '(') // Unescape opening parentheses .replace(/\\\)/g, ')') // Unescape closing parentheses .replace(/\\\\/g, '\\') // Unescape backslashes last } /** * Parses a permission rule string into its components. * Handles escaped parentheses in the content portion. * * Format: "ToolName" or "ToolName(content)" * Content may contain escaped parentheses: \( and \) * * @example * permissionRuleValueFromString('Bash') // => { toolName: 'Bash' } * permissionRuleValueFromString('Bash(npm install)') // => { toolName: 'Bash', ruleContent: 'npm install' } * permissionRuleValueFromString('Bash(python -c "print\\(1\\)")') // => { toolName: 'Bash', ruleContent: 'python -c "print(1)"' } */ export function permissionRuleValueFromString( ruleString: string, ): PermissionRuleValue { // Find the first unescaped opening parenthesis const openParenIndex = findFirstUnescapedChar(ruleString, '(') if (openParenIndex === -1) { // No parenthesis found - this is just a tool name return { toolName: normalizeLegacyToolName(ruleString) } } // Find the last unescaped closing parenthesis const closeParenIndex = findLastUnescapedChar(ruleString, ')') if (closeParenIndex === -1 || closeParenIndex <= openParenIndex) { // No matching closing paren or malformed - treat as tool name return { toolName: normalizeLegacyToolName(ruleString) } } // Ensure the closing paren is at the end if (closeParenIndex !== ruleString.length - 1) { // Content after closing paren - treat as tool name return { toolName: normalizeLegacyToolName(ruleString) } } const toolName = ruleString.substring(0, openParenIndex) const rawContent = ruleString.substring(openParenIndex + 1, closeParenIndex) // Missing toolName (e.g., "(foo)") is malformed - treat whole string as tool name if (!toolName) { return { toolName: normalizeLegacyToolName(ruleString) } } // Empty content (e.g., "Bash()") or standalone wildcard (e.g., "Bash(*)") // should be treated as just the tool name (tool-wide rule) if (rawContent === '' || rawContent === '*') { return { toolName: normalizeLegacyToolName(toolName) } } // Unescape the content const ruleContent = unescapeRuleContent(rawContent) return { toolName: normalizeLegacyToolName(toolName), ruleContent } } /** * Converts a permission rule value to its string representation. * Escapes parentheses in the content to prevent parsing issues. * * @example * permissionRuleValueToString({ toolName: 'Bash' }) // => 'Bash' * permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'npm install' }) // => 'Bash(npm install)' * permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'python -c "print(1)"' }) // => 'Bash(python -c "print\\(1\\)")' */ export function permissionRuleValueToString( ruleValue: PermissionRuleValue, ): string { if (!ruleValue.ruleContent) { return ruleValue.toolName } const escapedContent = escapeRuleContent(ruleValue.ruleContent) return `${ruleValue.toolName}(${escapedContent})` } /** * Find the index of the first unescaped occurrence of a character. * A character is escaped if preceded by an odd number of backslashes. */ function findFirstUnescapedChar(str: string, char: string): number { for (let i = 0; i < str.length; i++) { if (str[i] === char) { // Count preceding backslashes let backslashCount = 0 let j = i - 1 while (j >= 0 && str[j] === '\\') { backslashCount++ j-- } // If even number of backslashes, the char is unescaped if (backslashCount % 2 === 0) { return i } } } return -1 } /** * Find the index of the last unescaped occurrence of a character. * A character is escaped if preceded by an odd number of backslashes. */ function findLastUnescapedChar(str: string, char: string): number { for (let i = str.length - 1; i >= 0; i--) { if (str[i] === char) { // Count preceding backslashes let backslashCount = 0 let j = i - 1 while (j >= 0 && str[j] === '\\') { backslashCount++ j-- } // If even number of backslashes, the char is unescaped if (backslashCount % 2 === 0) { return i } } } return -1 }