agent-ecosystem/src/main/services/infrastructure/TriggerManager.ts
matt 8a671bc53f Refactor NotificationTrigger settings for improved match field options
- 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.
2026-02-12 00:27:46 +09:00

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;
}
}