/ src / utils / permissions / permissionRuleParser.ts
permissionRuleParser.ts
  1  import { feature } from 'bun:bundle'
  2  import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'
  3  import { TASK_OUTPUT_TOOL_NAME } from '../../tools/TaskOutputTool/constants.js'
  4  import { TASK_STOP_TOOL_NAME } from '../../tools/TaskStopTool/prompt.js'
  5  import type { PermissionRuleValue } from './PermissionRule.js'
  6  
  7  // Dead code elimination: ant-only tool names are conditionally required so
  8  // their strings don't leak into external builds. Static imports always bundle.
  9  /* eslint-disable @typescript-eslint/no-require-imports */
 10  const BRIEF_TOOL_NAME: string | null =
 11    feature('KAIROS') || feature('KAIROS_BRIEF')
 12      ? (
 13          require('../../tools/BriefTool/prompt.js') as typeof import('../../tools/BriefTool/prompt.js')
 14        ).BRIEF_TOOL_NAME
 15      : null
 16  /* eslint-enable @typescript-eslint/no-require-imports */
 17  
 18  // Maps legacy tool names to their current canonical names.
 19  // When a tool is renamed, add old → new here so permission rules,
 20  // hooks, and persisted wire names resolve to the canonical name.
 21  const LEGACY_TOOL_NAME_ALIASES: Record<string, string> = {
 22    Task: AGENT_TOOL_NAME,
 23    KillShell: TASK_STOP_TOOL_NAME,
 24    AgentOutputTool: TASK_OUTPUT_TOOL_NAME,
 25    BashOutputTool: TASK_OUTPUT_TOOL_NAME,
 26    ...((feature('KAIROS') || feature('KAIROS_BRIEF')) && BRIEF_TOOL_NAME
 27      ? { Brief: BRIEF_TOOL_NAME }
 28      : {}),
 29  }
 30  
 31  export function normalizeLegacyToolName(name: string): string {
 32    return LEGACY_TOOL_NAME_ALIASES[name] ?? name
 33  }
 34  
 35  export function getLegacyToolNames(canonicalName: string): string[] {
 36    const result: string[] = []
 37    for (const [legacy, canonical] of Object.entries(LEGACY_TOOL_NAME_ALIASES)) {
 38      if (canonical === canonicalName) result.push(legacy)
 39    }
 40    return result
 41  }
 42  
 43  /**
 44   * Escapes special characters in rule content for safe storage in permission rules.
 45   * Permission rules use the format "Tool(content)", so parentheses in content must be escaped.
 46   *
 47   * Escaping order matters:
 48   * 1. Escape existing backslashes first (\ -> \\)
 49   * 2. Then escape parentheses (( -> \(, ) -> \))
 50   *
 51   * @example
 52   * escapeRuleContent('psycopg2.connect()') // => 'psycopg2.connect\\(\\)'
 53   * escapeRuleContent('echo "test\\nvalue"') // => 'echo "test\\\\nvalue"'
 54   */
 55  export function escapeRuleContent(content: string): string {
 56    return content
 57      .replace(/\\/g, '\\\\') // Escape backslashes first
 58      .replace(/\(/g, '\\(') // Escape opening parentheses
 59      .replace(/\)/g, '\\)') // Escape closing parentheses
 60  }
 61  
 62  /**
 63   * Unescapes special characters in rule content after parsing from permission rules.
 64   * This reverses the escaping done by escapeRuleContent.
 65   *
 66   * Unescaping order matters (reverse of escaping):
 67   * 1. Unescape parentheses first (\( -> (, \) -> ))
 68   * 2. Then unescape backslashes (\\ -> \)
 69   *
 70   * @example
 71   * unescapeRuleContent('psycopg2.connect\\(\\)') // => 'psycopg2.connect()'
 72   * unescapeRuleContent('echo "test\\\\nvalue"') // => 'echo "test\\nvalue"'
 73   */
 74  export function unescapeRuleContent(content: string): string {
 75    return content
 76      .replace(/\\\(/g, '(') // Unescape opening parentheses
 77      .replace(/\\\)/g, ')') // Unescape closing parentheses
 78      .replace(/\\\\/g, '\\') // Unescape backslashes last
 79  }
 80  
 81  /**
 82   * Parses a permission rule string into its components.
 83   * Handles escaped parentheses in the content portion.
 84   *
 85   * Format: "ToolName" or "ToolName(content)"
 86   * Content may contain escaped parentheses: \( and \)
 87   *
 88   * @example
 89   * permissionRuleValueFromString('Bash') // => { toolName: 'Bash' }
 90   * permissionRuleValueFromString('Bash(npm install)') // => { toolName: 'Bash', ruleContent: 'npm install' }
 91   * permissionRuleValueFromString('Bash(python -c "print\\(1\\)")') // => { toolName: 'Bash', ruleContent: 'python -c "print(1)"' }
 92   */
 93  export function permissionRuleValueFromString(
 94    ruleString: string,
 95  ): PermissionRuleValue {
 96    // Find the first unescaped opening parenthesis
 97    const openParenIndex = findFirstUnescapedChar(ruleString, '(')
 98    if (openParenIndex === -1) {
 99      // No parenthesis found - this is just a tool name
100      return { toolName: normalizeLegacyToolName(ruleString) }
101    }
102  
103    // Find the last unescaped closing parenthesis
104    const closeParenIndex = findLastUnescapedChar(ruleString, ')')
105    if (closeParenIndex === -1 || closeParenIndex <= openParenIndex) {
106      // No matching closing paren or malformed - treat as tool name
107      return { toolName: normalizeLegacyToolName(ruleString) }
108    }
109  
110    // Ensure the closing paren is at the end
111    if (closeParenIndex !== ruleString.length - 1) {
112      // Content after closing paren - treat as tool name
113      return { toolName: normalizeLegacyToolName(ruleString) }
114    }
115  
116    const toolName = ruleString.substring(0, openParenIndex)
117    const rawContent = ruleString.substring(openParenIndex + 1, closeParenIndex)
118  
119    // Missing toolName (e.g., "(foo)") is malformed - treat whole string as tool name
120    if (!toolName) {
121      return { toolName: normalizeLegacyToolName(ruleString) }
122    }
123  
124    // Empty content (e.g., "Bash()") or standalone wildcard (e.g., "Bash(*)")
125    // should be treated as just the tool name (tool-wide rule)
126    if (rawContent === '' || rawContent === '*') {
127      return { toolName: normalizeLegacyToolName(toolName) }
128    }
129  
130    // Unescape the content
131    const ruleContent = unescapeRuleContent(rawContent)
132    return { toolName: normalizeLegacyToolName(toolName), ruleContent }
133  }
134  
135  /**
136   * Converts a permission rule value to its string representation.
137   * Escapes parentheses in the content to prevent parsing issues.
138   *
139   * @example
140   * permissionRuleValueToString({ toolName: 'Bash' }) // => 'Bash'
141   * permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'npm install' }) // => 'Bash(npm install)'
142   * permissionRuleValueToString({ toolName: 'Bash', ruleContent: 'python -c "print(1)"' }) // => 'Bash(python -c "print\\(1\\)")'
143   */
144  export function permissionRuleValueToString(
145    ruleValue: PermissionRuleValue,
146  ): string {
147    if (!ruleValue.ruleContent) {
148      return ruleValue.toolName
149    }
150    const escapedContent = escapeRuleContent(ruleValue.ruleContent)
151    return `${ruleValue.toolName}(${escapedContent})`
152  }
153  
154  /**
155   * Find the index of the first unescaped occurrence of a character.
156   * A character is escaped if preceded by an odd number of backslashes.
157   */
158  function findFirstUnescapedChar(str: string, char: string): number {
159    for (let i = 0; i < str.length; i++) {
160      if (str[i] === char) {
161        // Count preceding backslashes
162        let backslashCount = 0
163        let j = i - 1
164        while (j >= 0 && str[j] === '\\') {
165          backslashCount++
166          j--
167        }
168        // If even number of backslashes, the char is unescaped
169        if (backslashCount % 2 === 0) {
170          return i
171        }
172      }
173    }
174    return -1
175  }
176  
177  /**
178   * Find the index of the last unescaped occurrence of a character.
179   * A character is escaped if preceded by an odd number of backslashes.
180   */
181  function findLastUnescapedChar(str: string, char: string): number {
182    for (let i = str.length - 1; i >= 0; i--) {
183      if (str[i] === char) {
184        // Count preceding backslashes
185        let backslashCount = 0
186        let j = i - 1
187        while (j >= 0 && str[j] === '\\') {
188          backslashCount++
189          j--
190        }
191        // If even number of backslashes, the char is unescaped
192        if (backslashCount % 2 === 0) {
193          return i
194        }
195      }
196    }
197    return -1
198  }