File size: 7,274 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | 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<string, string> = {
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
}
|