- Removed the 'toolName' and 'matchField' properties from the default trigger configuration. - Updated the default match field options to include a new option for matching against the entire JSON-serialized input.
316 lines
9.5 KiB
TypeScript
316 lines
9.5 KiB
TypeScript
/**
|
|
* TriggerManager - Manages notification triggers.
|
|
*
|
|
* Handles CRUD operations for notification triggers including:
|
|
* - Adding, updating, and removing triggers
|
|
* - Validating trigger configurations (with ReDoS protection)
|
|
* - Managing builtin vs custom triggers
|
|
*/
|
|
|
|
import { validateRegexPattern } from '@main/utils/regexValidation';
|
|
|
|
import type { NotificationTrigger } from './ConfigManager';
|
|
|
|
// ===========================================================================
|
|
// Types
|
|
// ===========================================================================
|
|
|
|
export interface TriggerValidationResult {
|
|
valid: boolean;
|
|
errors: string[];
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Default Triggers
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Default built-in notification triggers.
|
|
*/
|
|
export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|
{
|
|
id: 'builtin-bash-command',
|
|
name: '.env File Access Alert',
|
|
enabled: true,
|
|
contentType: 'tool_use',
|
|
mode: 'content_match',
|
|
matchPattern: '/.env',
|
|
isBuiltin: true,
|
|
color: 'red',
|
|
},
|
|
{
|
|
id: 'builtin-tool-result-error',
|
|
name: 'Tool Result Error',
|
|
enabled: true,
|
|
contentType: 'tool_result',
|
|
mode: 'error_status',
|
|
requireError: true,
|
|
ignorePatterns: [
|
|
"The user doesn't want to proceed with this tool use\\.",
|
|
'\\[Request interrupted by user for tool use\\]',
|
|
],
|
|
isBuiltin: true,
|
|
color: 'orange',
|
|
},
|
|
{
|
|
id: 'builtin-high-token-usage',
|
|
name: 'High Token Usage',
|
|
enabled: true,
|
|
contentType: 'tool_result',
|
|
mode: 'token_threshold',
|
|
tokenThreshold: 8000,
|
|
tokenType: 'total',
|
|
color: 'yellow',
|
|
isBuiltin: true,
|
|
},
|
|
];
|
|
|
|
// ===========================================================================
|
|
// TriggerManager Class
|
|
// ===========================================================================
|
|
|
|
export class TriggerManager {
|
|
private triggers: NotificationTrigger[];
|
|
private readonly onSave: () => void;
|
|
|
|
constructor(triggers: NotificationTrigger[], onSave: () => void) {
|
|
this.triggers = triggers;
|
|
this.onSave = onSave;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// CRUD Operations
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Gets all notification triggers.
|
|
*/
|
|
getAll(): NotificationTrigger[] {
|
|
return this.deepClone(this.triggers);
|
|
}
|
|
|
|
/**
|
|
* Gets enabled notification triggers only.
|
|
*/
|
|
getEnabled(): NotificationTrigger[] {
|
|
return this.deepClone(this.triggers.filter((t) => t.enabled));
|
|
}
|
|
|
|
/**
|
|
* Gets a trigger by ID.
|
|
*/
|
|
getById(triggerId: string): NotificationTrigger | undefined {
|
|
const trigger = this.triggers.find((t) => t.id === triggerId);
|
|
return trigger ? this.deepClone(trigger) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Adds a new notification trigger.
|
|
* @throws Error if trigger with same ID already exists
|
|
*/
|
|
add(trigger: NotificationTrigger): NotificationTrigger[] {
|
|
// Check if trigger with same ID already exists
|
|
if (this.triggers.some((t) => t.id === trigger.id)) {
|
|
throw new Error(`Trigger with ID "${trigger.id}" already exists`);
|
|
}
|
|
|
|
// Validate trigger
|
|
const validation = this.validate(trigger);
|
|
if (!validation.valid) {
|
|
throw new Error(`Invalid trigger: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
this.triggers = [...this.triggers, trigger];
|
|
this.onSave();
|
|
return this.getAll();
|
|
}
|
|
|
|
/**
|
|
* Updates an existing notification trigger.
|
|
* @throws Error if trigger not found
|
|
*/
|
|
update(triggerId: string, updates: Partial<NotificationTrigger>): NotificationTrigger[] {
|
|
const index = this.triggers.findIndex((t) => t.id === triggerId);
|
|
|
|
if (index === -1) {
|
|
throw new Error(`Trigger with ID "${triggerId}" not found`);
|
|
}
|
|
|
|
// Extract allowedUpdates without isBuiltin (which cannot be changed)
|
|
const allowedUpdates = Object.fromEntries(
|
|
Object.entries(updates).filter(([key]) => key !== 'isBuiltin')
|
|
) as Partial<NotificationTrigger>;
|
|
|
|
const updated = { ...this.triggers[index], ...allowedUpdates };
|
|
|
|
// Ensure mode is set (for backward compatibility with old triggers)
|
|
if (!updated.mode) {
|
|
updated.mode = this.inferMode(updated);
|
|
}
|
|
|
|
// Validate updated trigger
|
|
const validation = this.validate(updated);
|
|
if (!validation.valid) {
|
|
throw new Error(`Invalid trigger update: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
this.triggers = this.triggers.map((t, i) => (i === index ? updated : t));
|
|
this.onSave();
|
|
return this.getAll();
|
|
}
|
|
|
|
/**
|
|
* Infers trigger mode from trigger properties for backward compatibility.
|
|
*/
|
|
private inferMode(
|
|
trigger: Partial<NotificationTrigger>
|
|
): 'error_status' | 'content_match' | 'token_threshold' {
|
|
if (trigger.requireError) return 'error_status';
|
|
if (trigger.matchPattern || trigger.matchField) return 'content_match';
|
|
if (trigger.tokenThreshold !== undefined) return 'token_threshold';
|
|
return 'error_status'; // default fallback
|
|
}
|
|
|
|
/**
|
|
* Removes a notification trigger.
|
|
* Built-in triggers cannot be removed.
|
|
* @throws Error if trigger not found or is builtin
|
|
*/
|
|
remove(triggerId: string): NotificationTrigger[] {
|
|
const trigger = this.triggers.find((t) => t.id === triggerId);
|
|
|
|
if (!trigger) {
|
|
throw new Error(`Trigger with ID "${triggerId}" not found`);
|
|
}
|
|
|
|
if (trigger.isBuiltin) {
|
|
throw new Error('Cannot remove built-in triggers. Disable them instead.');
|
|
}
|
|
|
|
this.triggers = this.triggers.filter((t) => t.id !== triggerId);
|
|
this.onSave();
|
|
return this.getAll();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Validation
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Validates a trigger configuration.
|
|
*/
|
|
validate(trigger: NotificationTrigger): TriggerValidationResult {
|
|
const errors: string[] = [];
|
|
|
|
// Required fields
|
|
if (!trigger.id || trigger.id.trim() === '') {
|
|
errors.push('Trigger ID is required');
|
|
}
|
|
|
|
if (!trigger.name || trigger.name.trim() === '') {
|
|
errors.push('Trigger name is required');
|
|
}
|
|
|
|
if (!trigger.contentType) {
|
|
errors.push('Content type is required');
|
|
}
|
|
|
|
if (!trigger.mode) {
|
|
errors.push('Trigger mode is required');
|
|
}
|
|
|
|
// Mode-specific validation
|
|
if (trigger.mode === 'content_match') {
|
|
// matchField is required unless it's tool_use with "Any Tool" (no toolName)
|
|
// In that case, we match against the entire JSON input
|
|
if (!trigger.matchField && !(trigger.contentType === 'tool_use' && !trigger.toolName)) {
|
|
errors.push('Match field is required for content_match mode');
|
|
}
|
|
// Validate regex pattern if provided (with ReDoS protection)
|
|
if (trigger.matchPattern) {
|
|
const validation = validateRegexPattern(trigger.matchPattern);
|
|
if (!validation.valid) {
|
|
errors.push(validation.error ?? 'Invalid regex pattern');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (trigger.mode === 'token_threshold') {
|
|
if (trigger.tokenThreshold === undefined || trigger.tokenThreshold < 0) {
|
|
errors.push('Token threshold must be a non-negative number');
|
|
}
|
|
if (!trigger.tokenType) {
|
|
errors.push('Token type is required for token_threshold mode');
|
|
}
|
|
}
|
|
|
|
// Validate ignore patterns (with ReDoS protection)
|
|
if (trigger.ignorePatterns) {
|
|
for (const pattern of trigger.ignorePatterns) {
|
|
const validation = validateRegexPattern(pattern);
|
|
if (!validation.valid) {
|
|
errors.push(
|
|
`Invalid ignore pattern "${pattern}": ${validation.error ?? 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Trigger Merging
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Merges loaded triggers with default triggers.
|
|
* - Preserves all existing triggers (including user-modified builtin triggers)
|
|
* - Adds any missing builtin triggers from defaults
|
|
* - Removes deprecated builtin triggers that are no longer in defaults
|
|
*/
|
|
static mergeTriggers(
|
|
loaded: NotificationTrigger[],
|
|
defaults: NotificationTrigger[] = DEFAULT_TRIGGERS
|
|
): NotificationTrigger[] {
|
|
// Get IDs of current builtin triggers
|
|
const builtinIds = new Set(defaults.filter((t) => t.isBuiltin).map((t) => t.id));
|
|
|
|
// Filter out deprecated builtin triggers (builtin triggers not in current defaults)
|
|
const filtered = loaded.filter((t) => !t.isBuiltin || builtinIds.has(t.id));
|
|
|
|
// Add any missing builtin triggers from defaults
|
|
for (const defaultTrigger of defaults) {
|
|
if (defaultTrigger.isBuiltin) {
|
|
const existsInFiltered = filtered.some((t) => t.id === defaultTrigger.id);
|
|
if (!existsInFiltered) {
|
|
filtered.push(defaultTrigger);
|
|
}
|
|
}
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Internal Methods
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Updates the internal triggers array.
|
|
* Used by ConfigManager when loading config.
|
|
*/
|
|
setTriggers(triggers: NotificationTrigger[]): void {
|
|
this.triggers = triggers;
|
|
}
|
|
|
|
/**
|
|
* Deep clones an object.
|
|
*/
|
|
private deepClone<T>(obj: T): T {
|
|
return JSON.parse(JSON.stringify(obj)) as T;
|
|
}
|
|
}
|