feat: security observation types + Telegram notifier

Adds two severity-axis security observation types (security_alert, security_note)
to the code mode and a fire-and-forget Telegram notifier that posts when a saved
observation matches configured type or concept triggers. Default trigger fires on
security_alert only; notifier is disabled until BOT_TOKEN and CHAT_ID are set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-22 12:37:19 -07:00
parent 49ab404c08
commit 965ec9c055
11 changed files with 366 additions and 230 deletions

View File

@@ -44,6 +44,20 @@
"description": "Architectural/design choice with rationale",
"emoji": "⚖️",
"work_emoji": "⚖️"
},
{
"id": "security_alert",
"label": "Security Alert",
"description": "A security issue that needs attention before continuing.",
"emoji": "🚨",
"work_emoji": "🚨"
},
{
"id": "security_note",
"label": "Security Note",
"description": "A security-relevant observation worth recording, but not urgent.",
"emoji": "🔐",
"work_emoji": "🔐"
}
],
"observation_concepts": [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,100 @@
/**
* TelegramNotifier
*
* Fire-and-forget Telegram notification module. Fires one message per observation
* whose type or concepts match user-configured triggers. Never throws; all errors
* are caught per-observation and logged as warnings. Bot token is never logged.
*/
import { ParsedObservation } from '../../sdk/parser.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { logger } from '../../utils/logger.js';
export interface TelegramNotifyInput {
observations: ParsedObservation[];
observationIds: number[];
project: string;
memorySessionId: string;
}
const MARKDOWN_V2_RESERVED = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
function escapeMarkdownV2(value: string): string {
return value.replace(MARKDOWN_V2_RESERVED, '\\$&');
}
function splitCsv(value: string): string[] {
return value
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
}
function formatMessage(
obs: ParsedObservation,
project: string,
memorySessionId: string,
observationId: number,
): string {
const type = escapeMarkdownV2(obs.type);
const title = escapeMarkdownV2(obs.title ?? '');
const subtitle = escapeMarkdownV2(obs.subtitle ?? '');
const projectEscaped = escapeMarkdownV2(project);
const idEscaped = escapeMarkdownV2(String(observationId));
return `🚨 *${type}* — ${title}\n${subtitle}\nProject: \`${projectEscaped}\` · obs \\#${idEscaped}`;
}
async function postOne(botToken: string, chatId: string, text: string): Promise<void> {
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'MarkdownV2',
}),
});
if (!response.ok) {
const status = response.status;
const statusText = response.statusText;
throw new Error(`Telegram API responded ${status} ${statusText}`);
}
}
export async function notifyTelegram(input: TelegramNotifyInput): Promise<void> {
const botToken = SettingsDefaultsManager.get('CLAUDE_MEM_TELEGRAM_BOT_TOKEN');
const chatId = SettingsDefaultsManager.get('CLAUDE_MEM_TELEGRAM_CHAT_ID');
if (!botToken || !chatId) {
return;
}
const triggerTypes = splitCsv(SettingsDefaultsManager.get('CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES'));
const triggerConcepts = splitCsv(SettingsDefaultsManager.get('CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS'));
if (triggerTypes.length === 0 && triggerConcepts.length === 0) {
return;
}
const { observations, observationIds, project, memorySessionId } = input;
for (let i = 0; i < observations.length; i++) {
const obs = observations[i];
const matchesType = triggerTypes.includes(obs.type);
const matchesConcept = obs.concepts.some(c => triggerConcepts.includes(c));
if (!matchesType && !matchesConcept) {
continue;
}
const observationId = observationIds[i];
try {
const text = formatMessage(obs, project, memorySessionId, observationId);
await postOne(botToken, chatId, text);
} catch (error) {
logger.warn('TELEGRAM', 'Failed to send Telegram notification', {
observationId,
project,
memorySessionId,
type: obs.type,
}, error as Error);
}
}
}

View File

@@ -15,6 +15,7 @@ import { logger } from '../../../utils/logger.js';
import { parseObservations, parseSummary, type ParsedObservation, type ParsedSummary } from '../../../sdk/parser.js';
import { SUMMARY_MODE_MARKER, MAX_CONSECUTIVE_SUMMARY_FAILURES } from '../../../sdk/prompts.js';
import { updateCursorContextForProject } from '../../integrations/CursorHooksInstaller.js';
import { notifyTelegram } from '../../integrations/TelegramNotifier.js';
import { updateFolderClaudeMdFiles } from '../../../utils/claude-md-utils.js';
import { getWorkerPort } from '../../../shared/worker-utils.js';
import { SettingsDefaultsManager } from '../../../shared/SettingsDefaultsManager.js';
@@ -213,6 +214,13 @@ export async function processAgentResponse(
// Clear the tracking array after confirmation
session.processingMessageIds = [];
void notifyTelegram({
observations: labeledObservations,
observationIds: result.observationIds,
project: session.project,
memorySessionId: session.memorySessionId,
});
// AFTER transaction commits - async operations (can fail safely without data loss)
await syncAndBroadcastObservations(
observations,

View File

@@ -13,7 +13,7 @@ import { CorpusBuilder } from '../../knowledge/CorpusBuilder.js';
import { KnowledgeAgent } from '../../knowledge/KnowledgeAgent.js';
import type { CorpusFilter } from '../../knowledge/types.js';
const ALLOWED_CORPUS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
const ALLOWED_CORPUS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change', 'security_alert', 'security_note']);
export class CorpusRoutes extends BaseRouteHandler {
constructor(

View File

@@ -5,7 +5,7 @@
*/
import { logger } from '../../../../utils/logger.js';
type ObservationType = 'decision' | 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'change';
type ObservationType = 'decision' | 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'change' | 'security_alert' | 'security_note';
/**
* Valid observation types
@@ -16,7 +16,9 @@ export const OBSERVATION_TYPES: ObservationType[] = [
'feature',
'refactor',
'discovery',
'change'
'change',
'security_alert',
'security_note'
];
/**

View File

@@ -76,6 +76,11 @@ export interface SettingsDefaults {
CLAUDE_MEM_CHROMA_API_KEY: string;
CLAUDE_MEM_CHROMA_TENANT: string;
CLAUDE_MEM_CHROMA_DATABASE: string;
// Telegram Notifier
CLAUDE_MEM_TELEGRAM_BOT_TOKEN: string;
CLAUDE_MEM_TELEGRAM_CHAT_ID: string;
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: string;
CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS: string;
}
export class SettingsDefaultsManager {
@@ -147,6 +152,11 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_CHROMA_API_KEY: '',
CLAUDE_MEM_CHROMA_TENANT: 'default_tenant',
CLAUDE_MEM_CHROMA_DATABASE: 'default_database',
// Telegram Notifier
CLAUDE_MEM_TELEGRAM_BOT_TOKEN: '',
CLAUDE_MEM_TELEGRAM_CHAT_ID: '',
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert',
CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS: '',
};
/**

View File

@@ -15,7 +15,7 @@ export enum LogLevel {
SILENT = 4
}
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM' | 'HTTP' | 'SESSION' | 'CHROMA' | 'CHROMA_MCP' | 'CHROMA_SYNC' | 'FOLDER_INDEX' | 'CLAUDE_MD' | 'QUEUE';
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM' | 'HTTP' | 'SESSION' | 'CHROMA' | 'CHROMA_MCP' | 'CHROMA_SYNC' | 'FOLDER_INDEX' | 'CLAUDE_MD' | 'QUEUE' | 'TELEGRAM';
interface LogContext {
sessionId?: number;