forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpermissionRuleParser.ts
More file actions
198 lines (182 loc) · 7.1 KB
/
Copy pathpermissionRuleParser.ts
File metadata and controls
198 lines (182 loc) · 7.1 KB
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
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
}