feat: add privacy-aware security manager onboarding
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 52s

This commit is contained in:
2026-07-05 21:39:55 +02:00
parent d13652a70b
commit 0c7ddc53b4
14 changed files with 767 additions and 8 deletions

View File

@@ -0,0 +1,46 @@
export function evaluateSecurityAlertPolicy(result, profile, now = new Date()) {
if (!result?.notify) return { send: false, reason: 'agent_declined' };
if (!profile) return { send: true, reason: 'no_profile' };
const priority = ['routine', 'priority', 'flash'].includes(result.priority) ? result.priority : 'routine';
const preference = profile.alertPreference || 'important';
if (preference === 'critical_only' && priority !== 'flash') {
return { send: false, reason: 'critical_only' };
}
if (preference === 'important' && priority === 'routine') {
return { send: false, reason: 'routine_suppressed' };
}
if (priority !== 'flash' && isWithinQuietHours(profile.quietHours, profile.timezone, now)) {
return { send: false, reason: 'quiet_hours' };
}
return { send: true, reason: 'allowed' };
}
export function isWithinQuietHours(value, timezone, now = new Date()) {
const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/);
if (!match) return false;
const localMinutes = minutesInTimezone(now, timezone);
if (localMinutes == null) return false;
const start = Number(match[1]) * 60 + Number(match[2]);
const end = Number(match[3]) * 60 + Number(match[4]);
if (start === end) return true;
return start < end
? localMinutes >= start && localMinutes < end
: localMinutes >= start || localMinutes < end;
}
function minutesInTimezone(date, timezone) {
try {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone || 'UTC',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const hour = Number(parts.find(part => part.type === 'hour')?.value);
const minute = Number(parts.find(part => part.type === 'minute')?.value);
return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,256 @@
const CALLBACK_PREFIX = 'security_';
export class SecurityOnboarding {
constructor({ store, alerter, chatId } = {}) {
this.store = store;
this.alerter = alerter;
this.chatId = String(chatId || '');
this.sessions = new Map();
}
isActive(chatId) {
return this.sessions.has(String(chatId));
}
async ensureStarted() {
if (!this.alerter?.isConfigured || this.store?.exists) return false;
if (!this.store?.available) {
await this.alerter.sendMessage('Security Manager setup is unavailable until SECURITY_PROFILE_ENCRYPTION_KEY is configured.', { parseMode: null });
return false;
}
await this.start(this.chatId);
return true;
}
async start(chatId, { languageOnly = false } = {}) {
if (!this.store?.available) {
return this.alerter.sendMessage('Security Manager setup is unavailable until SECURITY_PROFILE_ENCRYPTION_KEY is configured correctly.', { chatId, parseMode: null });
}
const key = String(chatId);
const existing = this.store?.getProfile();
this.sessions.set(key, { step: 'language', mode: languageOnly ? 'language_only' : 'full', draft: existing || emptyDraft() });
return this.alerter.sendMessage('Choose your language / Sprache auswählen', {
chatId,
parseMode: null,
replyMarkup: languageKeyboard(),
});
}
async handleCallback(data, query) {
const value = String(data || '');
if (!value.startsWith(CALLBACK_PREFIX)) return { handled: false };
const chatId = String(query.message?.chat?.id || this.chatId);
const session = this.sessions.get(chatId);
if (value.startsWith('security_language:')) {
const language = value.split(':')[1];
if (!['de', 'en'].includes(language)) return { handled: true, response: plain('Unsupported language.') };
const active = session || { step: 'language', mode: 'full', draft: this.store?.getProfile() || emptyDraft() };
active.draft.language = language;
if (active.mode === 'language_only' && this.store?.exists) {
this.store.save(active.draft);
this.sessions.delete(chatId);
return { handled: true, response: plain(t(language, 'languageSaved')) };
}
active.step = 'consent';
this.sessions.set(chatId, active);
return { handled: true, response: { text: t(language, 'consent'), parseMode: null, replyMarkup: consentKeyboard(language) } };
}
const storedLanguage = this.store?.getProfile()?.language || 'en';
if (value === 'security_delete:confirm') {
this.store.delete();
this.sessions.delete(chatId);
return { handled: true, response: plain(t(storedLanguage, 'deleted')) };
}
if (value === 'security_delete:cancel') return { handled: true, response: plain(t(storedLanguage, 'deleteCancelled')) };
if (!session) return { handled: true, response: plain('Setup session expired. Use /onboarding to restart.') };
const language = session.draft.language || 'en';
if (value.startsWith('security_consent:')) {
const choice = value.split(':')[1];
if (choice === 'cancel') {
this.sessions.delete(chatId);
return { handled: true, response: plain(t(language, 'cancelled')) };
}
session.mode = choice === 'minimal' ? 'minimal' : 'full';
session.draft.consentedAt = new Date().toISOString();
session.step = session.mode === 'minimal' ? 'country' : 'preferredName';
return { handled: true, response: plain(promptFor(session.step, language)) };
}
if (value === 'security_review:save') {
this.store.save(session.draft);
this.sessions.delete(chatId);
return { handled: true, response: plain(t(language, 'saved')) };
}
if (value === 'security_review:restart') {
this.sessions.delete(chatId);
await this.start(chatId);
return { handled: true, response: null };
}
return { handled: true, response: plain(t(language, 'unknownAction')) };
}
handleMessage(text, msg) {
const chatId = String(msg.chat?.id || this.chatId);
const session = this.sessions.get(chatId);
if (!session) return { handled: false };
if (session.step === 'language' || session.step === 'consent' || session.step === 'review') {
const language = session.draft.language || 'en';
return {
handled: true,
response: plain(language === 'de'
? 'Bitte benutze die Schaltflaechen der letzten Nachricht oder starte mit /onboarding neu.'
: 'Use the buttons on the previous message, or restart with /onboarding.'),
};
}
const value = String(text || '').trim();
const skipped = /^\/?skip$/i.test(value);
applyAnswer(session, skipped ? '' : value);
session.step = nextStep(session.step, session.mode);
if (session.step === 'review') {
return {
handled: true,
response: {
text: `${t(session.draft.language, 'review')}\n\n${formatProfile(session.draft, session.draft.language)}`,
parseMode: null,
replyMarkup: reviewKeyboard(session.draft.language),
},
};
}
return { handled: true, response: plain(promptFor(session.step, session.draft.language)) };
}
profileText() {
const profile = this.store?.getProfile();
return profile ? formatProfile(profile, profile.language) : 'No Security Manager profile exists. Use /onboarding to begin.';
}
deletePrompt() {
const language = this.store?.getProfile()?.language || 'en';
return { text: t(language, 'deletePrompt'), parseMode: null, replyMarkup: deleteKeyboard(language) };
}
}
function applyAnswer(session, value) {
const draft = session.draft;
switch (session.step) {
case 'preferredName': draft.preferredName = clean(value, 80); break;
case 'country': draft.location.country = clean(value, 80); break;
case 'region': draft.location.region = clean(value, 100); break;
case 'city': draft.location.city = clean(value, 100); break;
case 'timezone': draft.timezone = clean(value, 80); break;
case 'household': {
const [adults, children, pets] = value.split(/[;,\s]+/).map(Number);
draft.household = { adults: bounded(adults, 0, 20, 1), children: bounded(children, 0, 20, 0), pets: bounded(pets, 0, 20, 0) };
break;
}
case 'mobility': draft.mobility = list(value, 8); break;
case 'travelPattern': draft.travelPattern = clean(value, 120); break;
case 'riskPriorities': draft.riskPriorities = list(value, 8); break;
case 'criticalDependencies': draft.criticalDependencies = list(value, 8); break;
case 'alertPreference': draft.alertPreference = normalizeAlert(value); break;
case 'quietHours': draft.quietHours = clean(value, 40); break;
}
}
function nextStep(step, mode) {
const full = ['preferredName', 'country', 'region', 'city', 'timezone', 'household', 'mobility', 'travelPattern', 'riskPriorities', 'criticalDependencies', 'alertPreference', 'quietHours', 'review'];
const minimal = ['country', 'region', 'city', 'timezone', 'riskPriorities', 'alertPreference', 'quietHours', 'review'];
const steps = mode === 'minimal' ? minimal : full;
return steps[steps.indexOf(step) + 1] || 'review';
}
function promptFor(step, language) {
const prompts = {
de: {
preferredName: 'Wie soll ich dich ansprechen? Optional: /skip',
country: 'In welchem Land soll ich Gefahren priorisieren? Bitte keine genaue Adresse.',
region: 'Welche Region oder welches Bundesland? Optional: /skip',
city: 'Welche Stadt oder nächstgelegene größere Stadt? Optional: /skip',
timezone: 'Welche Zeitzone nutzt du? Beispiel: Europe/Berlin. Optional: /skip',
household: 'Haushalt als Erwachsene,Kinder,Haustiere. Beispiel: 2,1,1. Optional: /skip',
mobility: 'Verkehrsmittel, kommagetrennt: auto,bahn,fahrrad,zu_fuss. Optional: /skip',
travelPattern: 'Wie häufig und wohin reist du typischerweise? Keine Buchungsdaten. Optional: /skip',
riskPriorities: 'Prioritäten, kommagetrennt: weather,conflict,infrastructure,cyber,travel,health,crime,economic',
criticalDependencies: 'Kritische Abhängigkeiten, kommagetrennt: electricity,internet,mobile_network,public_transport,private_vehicle,medical_power,mobility_support. Optional: /skip',
alertPreference: 'Alarmstufe: critical_only, important oder all',
quietHours: 'Ruhezeit im Format 22:00-07:00 oder /skip. Kritische Warnungen dürfen sie übersteuern.',
},
en: {
preferredName: 'How should I address you? Optional: /skip',
country: 'Which country should I prioritize for threats? Do not provide an exact address.',
region: 'Which region or state? Optional: /skip',
city: 'Which city or nearest major city? Optional: /skip',
timezone: 'Which timezone do you use? Example: Europe/Berlin. Optional: /skip',
household: 'Household as adults,children,pets. Example: 2,1,1. Optional: /skip',
mobility: 'Transport modes, comma-separated: car,rail,bicycle,walking. Optional: /skip',
travelPattern: 'How often and where do you typically travel? No booking details. Optional: /skip',
riskPriorities: 'Priorities, comma-separated: weather,conflict,infrastructure,cyber,travel,health,crime,economic',
criticalDependencies: 'Critical dependencies, comma-separated: electricity,internet,mobile_network,public_transport,private_vehicle,medical_power,mobility_support. Optional: /skip',
alertPreference: 'Alert level: critical_only, important, or all',
quietHours: 'Quiet hours as 22:00-07:00 or /skip. Critical warnings may override them.',
},
};
return prompts[language]?.[step] || prompts.en[step] || 'Continue.';
}
function formatProfile(profile, language) {
const labels = language === 'de'
? ['Sprache', 'Name', 'Standort', 'Zeitzone', 'Haushalt', 'Mobilität', 'Reisen', 'Risiken', 'Abhängigkeiten', 'Alarme', 'Ruhezeit']
: ['Language', 'Name', 'Location', 'Timezone', 'Household', 'Mobility', 'Travel', 'Risks', 'Dependencies', 'Alerts', 'Quiet hours'];
const location = [profile.location?.city, profile.location?.region, profile.location?.country].filter(Boolean).join(', ') || '-';
const household = `${profile.household?.adults ?? 1}/${profile.household?.children ?? 0}/${profile.household?.pets ?? 0}`;
return [
`${labels[0]}: ${profile.language}`,
`${labels[1]}: ${profile.preferredName || '-'}`,
`${labels[2]}: ${location}`,
`${labels[3]}: ${profile.timezone || '-'}`,
`${labels[4]}: ${household}`,
`${labels[5]}: ${(profile.mobility || []).join(', ') || '-'}`,
`${labels[6]}: ${profile.travelPattern || '-'}`,
`${labels[7]}: ${(profile.riskPriorities || []).join(', ') || '-'}`,
`${labels[8]}: ${(profile.criticalDependencies || []).join(', ') || '-'}`,
`${labels[9]}: ${profile.alertPreference || 'important'}`,
`${labels[10]}: ${profile.quietHours || '-'}`,
].join('\n');
}
function emptyDraft() {
return { language: 'en', preferredName: null, location: {}, timezone: null, household: { adults: 1, children: 0, pets: 0 }, mobility: [], travelPattern: null, riskPriorities: [], criticalDependencies: [], alertPreference: 'important', quietHours: null, consentedAt: null };
}
function languageKeyboard() {
return { inline_keyboard: [[{ text: 'Deutsch', callback_data: 'security_language:de' }, { text: 'English', callback_data: 'security_language:en' }]] };
}
function consentKeyboard(language) {
return { inline_keyboard: [[{ text: language === 'de' ? 'Vollständig' : 'Full setup', callback_data: 'security_consent:full' }, { text: language === 'de' ? 'Minimal' : 'Minimal', callback_data: 'security_consent:minimal' }], [{ text: language === 'de' ? 'Abbrechen' : 'Cancel', callback_data: 'security_consent:cancel' }]] };
}
function reviewKeyboard(language) {
return { inline_keyboard: [[{ text: language === 'de' ? 'Speichern' : 'Save', callback_data: 'security_review:save' }, { text: language === 'de' ? 'Neu starten' : 'Restart', callback_data: 'security_review:restart' }]] };
}
function deleteKeyboard(language) {
return { inline_keyboard: [[{ text: language === 'de' ? 'Profil löschen' : 'Delete profile', callback_data: 'security_delete:confirm' }, { text: language === 'de' ? 'Abbrechen' : 'Cancel', callback_data: 'security_delete:cancel' }]] };
}
function t(language, key) {
const text = {
de: {
consent: 'Bevor wir beginnen: Das Profil wird lokal verschlüsselt gespeichert und nur deinem konfigurierten LLM für Sicherheitsbewertungen bereitgestellt. Keine genaue Adresse, Ausweisdaten, Passwörter, Konten oder Diagnosen eingeben. Alle Felder außer Land sind optional; /skip überspringt. Du kannst das Profil jederzeit anzeigen oder löschen. Wähle den Umfang.',
languageSaved: 'Sprache wurde gespeichert.', cancelled: 'Einrichtung abgebrochen. Mit /onboarding kannst du später starten.', review: 'Bitte prüfe das Profil. Erst Speichern schreibt den verschlüsselten Datensatz.', saved: 'Security-Manager-Profil verschlüsselt gespeichert.', deleted: 'Security-Manager-Profil wurde vollständig gelöscht.', deletePrompt: 'Soll das verschlüsselte Security-Manager-Profil wirklich gelöscht werden?', deleteCancelled: 'Löschen abgebrochen.', unknownAction: 'Unbekannte Aktion.',
},
en: {
consent: 'Before we begin: the profile is stored locally with encryption and shared only with your configured LLM for security assessments. Do not enter an exact address, identity documents, passwords, accounts, or diagnoses. Every field except country is optional; /skip skips it. You can view or delete the profile at any time. Choose setup scope.',
languageSaved: 'Language saved.', cancelled: 'Setup cancelled. Use /onboarding to begin later.', review: 'Review the profile. Nothing is persisted until you select Save.', saved: 'Security Manager profile saved with encryption.', deleted: 'Security Manager profile was deleted completely.', deletePrompt: 'Delete the encrypted Security Manager profile?', deleteCancelled: 'Deletion cancelled.', unknownAction: 'Unknown action.',
},
};
return text[language]?.[key] || text.en[key] || key;
}
function plain(text) { return { text, parseMode: null }; }
function clean(value, maxLength) { return String(value || '').replace(/[\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, maxLength); }
function list(value, max) { return [...new Set(String(value || '').split(',').map(item => clean(item, 40).toLowerCase()).filter(Boolean))].slice(0, max); }
function bounded(value, min, max, fallback) { return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback; }
function normalizeAlert(value) { const normalized = clean(value, 40).toLowerCase(); return ['critical_only', 'important', 'all'].includes(normalized) ? normalized : 'important'; }

View File

@@ -0,0 +1,171 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
const PROFILE_VERSION = 1;
const ALLOWED_RISKS = new Set(['weather', 'conflict', 'infrastructure', 'cyber', 'travel', 'health', 'crime', 'economic']);
const ALLOWED_DEPENDENCIES = new Set(['electricity', 'internet', 'mobile_network', 'public_transport', 'private_vehicle', 'medical_power', 'mobility_support']);
export class SecurityProfileStore {
constructor(filePath, encryptionSecret) {
this.filePath = filePath;
this.encryptionSecret = String(encryptionSecret || '');
this.profile = null;
this.configured = this.encryptionSecret.length >= 32;
this.available = this.configured;
this.reason = this.available ? null : 'SECURITY_PROFILE_ENCRYPTION_KEY must contain at least 32 characters';
}
init() {
mkdirSync(dirname(this.filePath), { recursive: true });
if (!this.available || !existsSync(this.filePath)) return this;
try {
this.profile = decryptEnvelope(readFileSync(this.filePath, 'utf8'), this.encryptionSecret);
this.profile = sanitizeSecurityProfile(this.profile);
} catch (error) {
this.profile = null;
this.available = false;
this.reason = `Encrypted profile could not be read: ${error.message}`;
}
return this;
}
get exists() {
return Boolean(this.profile?.completedAt);
}
getProfile() {
return this.profile ? structuredClone(this.profile) : null;
}
getAgentProfile() {
const profile = this.getProfile();
if (!profile) return null;
return {
language: profile.language,
preferredName: profile.preferredName || null,
location: profile.location,
timezone: profile.timezone || null,
household: profile.household,
mobility: profile.mobility,
travelPattern: profile.travelPattern || null,
riskPriorities: profile.riskPriorities,
criticalDependencies: profile.criticalDependencies,
alertPreference: profile.alertPreference,
quietHours: profile.quietHours || null,
consentedAt: profile.consentedAt,
updatedAt: profile.updatedAt,
};
}
save(input) {
if (!this.available) throw new Error(this.reason);
const now = new Date().toISOString();
const profile = sanitizeSecurityProfile({
...input,
version: PROFILE_VERSION,
completedAt: input.completedAt || now,
updatedAt: now,
});
const envelope = encryptEnvelope(profile, this.encryptionSecret);
const temporaryPath = `${this.filePath}.tmp`;
writeFileSync(temporaryPath, envelope, { encoding: 'utf8', mode: 0o600 });
renameSync(temporaryPath, this.filePath);
this.profile = profile;
this.reason = null;
return this.getProfile();
}
delete() {
if (existsSync(this.filePath)) unlinkSync(this.filePath);
this.profile = null;
this.available = this.configured;
this.reason = this.available ? null : 'SECURITY_PROFILE_ENCRYPTION_KEY must contain at least 32 characters';
}
status() {
return {
available: this.available,
configured: this.configured,
exists: this.exists,
encrypted: true,
reason: this.reason,
updatedAt: this.profile?.updatedAt || null,
};
}
}
export function sanitizeSecurityProfile(input = {}) {
return {
version: PROFILE_VERSION,
language: ['de', 'en'].includes(input.language) ? input.language : 'en',
preferredName: clean(input.preferredName, 80) || null,
location: {
country: clean(input.location?.country, 80) || null,
region: clean(input.location?.region, 100) || null,
city: clean(input.location?.city, 100) || null,
},
timezone: clean(input.timezone, 80) || null,
household: {
adults: boundedInt(input.household?.adults, 0, 20, 1),
children: boundedInt(input.household?.children, 0, 20, 0),
pets: boundedInt(input.household?.pets, 0, 20, 0),
},
mobility: cleanList(input.mobility, 8, 40),
travelPattern: clean(input.travelPattern, 120) || null,
riskPriorities: cleanList(input.riskPriorities, 8, 40).filter(item => ALLOWED_RISKS.has(item)),
criticalDependencies: cleanList(input.criticalDependencies, 8, 40).filter(item => ALLOWED_DEPENDENCIES.has(item)),
alertPreference: ['critical_only', 'important', 'all'].includes(input.alertPreference) ? input.alertPreference : 'important',
quietHours: clean(input.quietHours, 40) || null,
consentedAt: validIso(input.consentedAt),
completedAt: validIso(input.completedAt),
updatedAt: validIso(input.updatedAt),
};
}
function encryptEnvelope(profile, secret) {
const iv = randomBytes(12);
const key = deriveKey(secret);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(profile), 'utf8'), cipher.final()]);
return JSON.stringify({
version: PROFILE_VERSION,
algorithm: 'aes-256-gcm',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
});
}
function decryptEnvelope(raw, secret) {
const envelope = JSON.parse(raw);
if (envelope.algorithm !== 'aes-256-gcm') throw new Error('unsupported encryption envelope');
const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret), Buffer.from(envelope.iv, 'base64'));
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
return JSON.parse(plaintext.toString('utf8'));
}
function deriveKey(secret) {
return createHash('sha256').update(`intelligence-terminal-security-profile\0${secret}`).digest();
}
function clean(value, maxLength) {
return String(value || '').replace(/[\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function cleanList(value, maxItems, maxLength) {
const list = Array.isArray(value) ? value : String(value || '').split(',');
return [...new Set(list.map(item => clean(item, maxLength).toLowerCase()).filter(Boolean))].slice(0, maxItems);
}
function boundedInt(value, min, max, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
function validIso(value) {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}