
- Add HooksEditor component for managing project hooks - Add ProjectSettings component for project-specific configurations - Create hooksManager utility for hook operations - Add hooks type definitions - Update backend commands to support hooks functionality - Integrate hooks into main app, agent execution, and Claude sessions - Update API and utilities to handle hooks data
250 lines
7.2 KiB
TypeScript
250 lines
7.2 KiB
TypeScript
/**
|
|
* Hooks configuration manager for Claude Code hooks
|
|
*/
|
|
|
|
import {
|
|
HooksConfiguration,
|
|
HookMatcher,
|
|
HookValidationResult,
|
|
HookValidationError,
|
|
HookValidationWarning,
|
|
HookCommand,
|
|
} from '@/types/hooks';
|
|
|
|
export class HooksManager {
|
|
/**
|
|
* Merge hooks configurations with proper priority
|
|
* Priority: local > project > user
|
|
*/
|
|
static mergeConfigs(
|
|
user: HooksConfiguration,
|
|
project: HooksConfiguration,
|
|
local: HooksConfiguration
|
|
): HooksConfiguration {
|
|
const merged: HooksConfiguration = {};
|
|
|
|
// Events with matchers (tool-related)
|
|
const matcherEvents: (keyof HooksConfiguration)[] = ['PreToolUse', 'PostToolUse'];
|
|
|
|
// Events without matchers (non-tool-related)
|
|
const directEvents: (keyof HooksConfiguration)[] = ['Notification', 'Stop', 'SubagentStop'];
|
|
|
|
// Merge events with matchers
|
|
for (const event of matcherEvents) {
|
|
// Start with user hooks
|
|
let matchers = [...((user[event] as HookMatcher[] | undefined) || [])];
|
|
|
|
// Add project hooks (may override by matcher pattern)
|
|
if (project[event]) {
|
|
matchers = this.mergeMatchers(matchers, project[event] as HookMatcher[]);
|
|
}
|
|
|
|
// Add local hooks (highest priority)
|
|
if (local[event]) {
|
|
matchers = this.mergeMatchers(matchers, local[event] as HookMatcher[]);
|
|
}
|
|
|
|
if (matchers.length > 0) {
|
|
(merged as any)[event] = matchers;
|
|
}
|
|
}
|
|
|
|
// Merge events without matchers
|
|
for (const event of directEvents) {
|
|
// Combine all hooks from all levels (local takes precedence)
|
|
const hooks: HookCommand[] = [];
|
|
|
|
// Add user hooks
|
|
if (user[event]) {
|
|
hooks.push(...(user[event] as HookCommand[]));
|
|
}
|
|
|
|
// Add project hooks
|
|
if (project[event]) {
|
|
hooks.push(...(project[event] as HookCommand[]));
|
|
}
|
|
|
|
// Add local hooks (highest priority)
|
|
if (local[event]) {
|
|
hooks.push(...(local[event] as HookCommand[]));
|
|
}
|
|
|
|
if (hooks.length > 0) {
|
|
(merged as any)[event] = hooks;
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Merge matcher arrays, with later items taking precedence
|
|
*/
|
|
private static mergeMatchers(
|
|
base: HookMatcher[],
|
|
override: HookMatcher[]
|
|
): HookMatcher[] {
|
|
const result = [...base];
|
|
|
|
for (const overrideMatcher of override) {
|
|
const existingIndex = result.findIndex(
|
|
m => m.matcher === overrideMatcher.matcher
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
// Replace existing matcher
|
|
result[existingIndex] = overrideMatcher;
|
|
} else {
|
|
// Add new matcher
|
|
result.push(overrideMatcher);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Validate hooks configuration
|
|
*/
|
|
static async validateConfig(hooks: HooksConfiguration): Promise<HookValidationResult> {
|
|
const errors: HookValidationError[] = [];
|
|
const warnings: HookValidationWarning[] = [];
|
|
|
|
// Guard against undefined or null hooks
|
|
if (!hooks) {
|
|
return { valid: true, errors, warnings };
|
|
}
|
|
|
|
// Events with matchers
|
|
const matcherEvents = ['PreToolUse', 'PostToolUse'] as const;
|
|
|
|
// Events without matchers
|
|
const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const;
|
|
|
|
// Validate events with matchers
|
|
for (const event of matcherEvents) {
|
|
const matchers = hooks[event];
|
|
if (!matchers || !Array.isArray(matchers)) continue;
|
|
|
|
for (const matcher of matchers) {
|
|
// Validate regex pattern if provided
|
|
if (matcher.matcher) {
|
|
try {
|
|
new RegExp(matcher.matcher);
|
|
} catch (e) {
|
|
errors.push({
|
|
event,
|
|
matcher: matcher.matcher,
|
|
message: `Invalid regex pattern: ${e instanceof Error ? e.message : 'Unknown error'}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate commands
|
|
if (matcher.hooks && Array.isArray(matcher.hooks)) {
|
|
for (const hook of matcher.hooks) {
|
|
if (!hook.command || !hook.command.trim()) {
|
|
errors.push({
|
|
event,
|
|
matcher: matcher.matcher,
|
|
message: 'Empty command'
|
|
});
|
|
}
|
|
|
|
// Check for dangerous patterns
|
|
const dangers = this.checkDangerousPatterns(hook.command || '');
|
|
warnings.push(...dangers.map(d => ({
|
|
event,
|
|
matcher: matcher.matcher,
|
|
command: hook.command || '',
|
|
message: d
|
|
})));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate events without matchers
|
|
for (const event of directEvents) {
|
|
const directHooks = hooks[event];
|
|
if (!directHooks || !Array.isArray(directHooks)) continue;
|
|
|
|
for (const hook of directHooks) {
|
|
if (!hook.command || !hook.command.trim()) {
|
|
errors.push({
|
|
event,
|
|
message: 'Empty command'
|
|
});
|
|
}
|
|
|
|
// Check for dangerous patterns
|
|
const dangers = this.checkDangerousPatterns(hook.command || '');
|
|
warnings.push(...dangers.map(d => ({
|
|
event,
|
|
command: hook.command || '',
|
|
message: d
|
|
})));
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings };
|
|
}
|
|
|
|
/**
|
|
* Check for potentially dangerous command patterns
|
|
*/
|
|
public static checkDangerousPatterns(command: string): string[] {
|
|
const warnings: string[] = [];
|
|
|
|
// Guard against undefined or null commands
|
|
if (!command || typeof command !== 'string') {
|
|
return warnings;
|
|
}
|
|
|
|
const patterns = [
|
|
{ pattern: /rm\s+-rf\s+\/(?:\s|$)/, message: 'Destructive command on root directory' },
|
|
{ pattern: /rm\s+-rf\s+~/, message: 'Destructive command on home directory' },
|
|
{ pattern: /:\s*\(\s*\)\s*\{.*\}\s*;/, message: 'Fork bomb pattern detected' },
|
|
{ pattern: /curl.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
|
{ pattern: /wget.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
|
{ pattern: />\/dev\/sda/, message: 'Direct disk write operation' },
|
|
{ pattern: /sudo\s+/, message: 'Elevated privileges required' },
|
|
{ pattern: /dd\s+.*of=\/dev\//, message: 'Dangerous disk operation' },
|
|
{ pattern: /mkfs\./, message: 'Filesystem formatting command' },
|
|
{ pattern: /:(){ :|:& };:/, message: 'Fork bomb detected' },
|
|
];
|
|
|
|
for (const { pattern, message } of patterns) {
|
|
if (pattern.test(command)) {
|
|
warnings.push(message);
|
|
}
|
|
}
|
|
|
|
// Check for unescaped variables that could lead to code injection
|
|
if (command.includes('$') && !command.includes('"$')) {
|
|
warnings.push('Unquoted shell variable detected - potential code injection risk');
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/**
|
|
* Escape a command for safe shell execution
|
|
*/
|
|
static escapeCommand(command: string): string {
|
|
// Basic shell escaping - in production, use a proper shell escaping library
|
|
return command
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\$/g, '\\$')
|
|
.replace(/`/g, '\\`');
|
|
}
|
|
|
|
/**
|
|
* Generate a unique ID for hooks/matchers/commands
|
|
*/
|
|
static generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
}
|