init claude-code
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Shared permission rule matching utilities for shell tools.
|
||||
*
|
||||
* Extracts common logic for:
|
||||
* - Parsing permission rules (exact, prefix, wildcard)
|
||||
* - Matching commands against rules
|
||||
* - Generating permission suggestions
|
||||
*/
|
||||
|
||||
import type { PermissionUpdate } from './PermissionUpdateSchema.js'
|
||||
|
||||
// Null-byte sentinel placeholders for wildcard pattern escaping — module-level
|
||||
// so the RegExp objects are compiled once instead of per permission check.
|
||||
const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
|
||||
const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'
|
||||
const ESCAPED_STAR_PLACEHOLDER_RE = new RegExp(ESCAPED_STAR_PLACEHOLDER, 'g')
|
||||
const ESCAPED_BACKSLASH_PLACEHOLDER_RE = new RegExp(
|
||||
ESCAPED_BACKSLASH_PLACEHOLDER,
|
||||
'g',
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed permission rule discriminated union.
|
||||
*/
|
||||
export type ShellPermissionRule =
|
||||
| {
|
||||
type: 'exact'
|
||||
command: string
|
||||
}
|
||||
| {
|
||||
type: 'prefix'
|
||||
prefix: string
|
||||
}
|
||||
| {
|
||||
type: 'wildcard'
|
||||
pattern: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm")
|
||||
* This is maintained for backwards compatibility.
|
||||
*/
|
||||
export function permissionRuleExtractPrefix(
|
||||
permissionRule: string,
|
||||
): string | null {
|
||||
const match = permissionRule.match(/^(.+):\*$/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern contains unescaped wildcards (not legacy :* syntax).
|
||||
* Returns true if the pattern contains * that are not escaped with \ or part of :* at the end.
|
||||
*/
|
||||
export function hasWildcards(pattern: string): boolean {
|
||||
// If it ends with :*, it's legacy prefix syntax, not wildcard
|
||||
if (pattern.endsWith(':*')) {
|
||||
return false
|
||||
}
|
||||
// Check for unescaped * anywhere in the pattern
|
||||
// An asterisk is unescaped if it's not preceded by a backslash,
|
||||
// or if it's preceded by an even number of backslashes (escaped backslashes)
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
if (pattern[i] === '*') {
|
||||
// Count backslashes before this asterisk
|
||||
let backslashCount = 0
|
||||
let j = i - 1
|
||||
while (j >= 0 && pattern[j] === '\\') {
|
||||
backslashCount++
|
||||
j--
|
||||
}
|
||||
// If even number of backslashes (including 0), the asterisk is unescaped
|
||||
if (backslashCount % 2 === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a command against a wildcard pattern.
|
||||
* Wildcards (*) match any sequence of characters.
|
||||
* Use \* to match a literal asterisk character.
|
||||
* Use \\ to match a literal backslash.
|
||||
*
|
||||
* @param pattern - The permission rule pattern with wildcards
|
||||
* @param command - The command to match against
|
||||
* @returns true if the command matches the pattern
|
||||
*/
|
||||
export function matchWildcardPattern(
|
||||
pattern: string,
|
||||
command: string,
|
||||
caseInsensitive = false,
|
||||
): boolean {
|
||||
// Trim leading/trailing whitespace from pattern
|
||||
const trimmedPattern = pattern.trim()
|
||||
|
||||
// Process the pattern to handle escape sequences: \* and \\
|
||||
let processed = ''
|
||||
let i = 0
|
||||
|
||||
while (i < trimmedPattern.length) {
|
||||
const char = trimmedPattern[i]
|
||||
|
||||
// Handle escape sequences
|
||||
if (char === '\\' && i + 1 < trimmedPattern.length) {
|
||||
const nextChar = trimmedPattern[i + 1]
|
||||
if (nextChar === '*') {
|
||||
// \* -> literal asterisk placeholder
|
||||
processed += ESCAPED_STAR_PLACEHOLDER
|
||||
i += 2
|
||||
continue
|
||||
} else if (nextChar === '\\') {
|
||||
// \\ -> literal backslash placeholder
|
||||
processed += ESCAPED_BACKSLASH_PLACEHOLDER
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
processed += char
|
||||
i++
|
||||
}
|
||||
|
||||
// Escape regex special characters except *
|
||||
const escaped = processed.replace(/[.+?^${}()|[\]\\'"]/g, '\\$&')
|
||||
|
||||
// Convert unescaped * to .* for wildcard matching
|
||||
const withWildcards = escaped.replace(/\*/g, '.*')
|
||||
|
||||
// Convert placeholders back to escaped regex literals
|
||||
let regexPattern = withWildcards
|
||||
.replace(ESCAPED_STAR_PLACEHOLDER_RE, '\\*')
|
||||
.replace(ESCAPED_BACKSLASH_PLACEHOLDER_RE, '\\\\')
|
||||
|
||||
// When a pattern ends with ' *' (space + unescaped wildcard) AND the trailing
|
||||
// wildcard is the ONLY unescaped wildcard, make the trailing space-and-args
|
||||
// optional so 'git *' matches both 'git add' and bare 'git'.
|
||||
// This aligns wildcard matching with prefix rule semantics (git:*).
|
||||
// Multi-wildcard patterns like '* run *' are excluded — making the last
|
||||
// wildcard optional would incorrectly match 'npm run' (no trailing arg).
|
||||
const unescapedStarCount = (processed.match(/\*/g) || []).length
|
||||
if (regexPattern.endsWith(' .*') && unescapedStarCount === 1) {
|
||||
regexPattern = regexPattern.slice(0, -3) + '( .*)?'
|
||||
}
|
||||
|
||||
// Create regex that matches the entire string.
|
||||
// The 's' (dotAll) flag makes '.' match newlines, so wildcards match
|
||||
// commands containing embedded newlines (e.g. heredoc content after splitCommand_DEPRECATED).
|
||||
const flags = 's' + (caseInsensitive ? 'i' : '')
|
||||
const regex = new RegExp(`^${regexPattern}$`, flags)
|
||||
|
||||
return regex.test(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a permission rule string into a structured rule object.
|
||||
*/
|
||||
export function parsePermissionRule(
|
||||
permissionRule: string,
|
||||
): ShellPermissionRule {
|
||||
// Check for legacy :* prefix syntax first (backwards compatibility)
|
||||
const prefix = permissionRuleExtractPrefix(permissionRule)
|
||||
if (prefix !== null) {
|
||||
return {
|
||||
type: 'prefix',
|
||||
prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new wildcard syntax (contains * but not :* at end)
|
||||
if (hasWildcards(permissionRule)) {
|
||||
return {
|
||||
type: 'wildcard',
|
||||
pattern: permissionRule,
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, it's an exact match
|
||||
return {
|
||||
type: 'exact',
|
||||
command: permissionRule,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate permission update suggestion for an exact command match.
|
||||
*/
|
||||
export function suggestionForExactCommand(
|
||||
toolName: string,
|
||||
command: string,
|
||||
): PermissionUpdate[] {
|
||||
return [
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [
|
||||
{
|
||||
toolName,
|
||||
ruleContent: command,
|
||||
},
|
||||
],
|
||||
behavior: 'allow',
|
||||
destination: 'localSettings',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate permission update suggestion for a prefix match.
|
||||
*/
|
||||
export function suggestionForPrefix(
|
||||
toolName: string,
|
||||
prefix: string,
|
||||
): PermissionUpdate[] {
|
||||
return [
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [
|
||||
{
|
||||
toolName,
|
||||
ruleContent: `${prefix}:*`,
|
||||
},
|
||||
],
|
||||
behavior: 'allow',
|
||||
destination: 'localSettings',
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user