176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
/**
|
|
* ComplianceStatusChecker — evaluates AI Act compliance per workspace.
|
|
*
|
|
* Checks:
|
|
* - Art. 12: Automatic event logging (ai_interactions count)
|
|
* - Art. 14: Human oversight (approval/denial actions recorded)
|
|
* - Art. 19: Log retention (oldest log >= 6 months ago)
|
|
* - Art. 26: Deployer monitoring (active monitors)
|
|
* - Art. 50: Model transparency (models disclosed)
|
|
*/
|
|
|
|
import type { InteractionStore } from './interaction-store.js';
|
|
import type { ComplianceStatus, ArticleStatus } from './types.js';
|
|
|
|
const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
|
|
|
|
export class ComplianceStatusChecker {
|
|
private store: InteractionStore;
|
|
|
|
constructor(store: InteractionStore) {
|
|
this.store = store;
|
|
}
|
|
|
|
/** Evaluate full compliance status for a workspace (or all workspaces if null). */
|
|
check(workspaceId?: string): ComplianceStatus {
|
|
const art12 = this.checkArt12(workspaceId);
|
|
const art14 = this.checkArt14(workspaceId);
|
|
const art19 = this.checkArt19();
|
|
const art26 = this.checkArt26();
|
|
const art50 = this.checkArt50(workspaceId);
|
|
|
|
const statuses = [art12.status, art14.status, art19.status, art26.status, art50.status];
|
|
const overall = statuses.includes('non-compliant') ? 'non-compliant'
|
|
: statuses.includes('warning') ? 'warning'
|
|
: 'compliant';
|
|
|
|
return {
|
|
overall,
|
|
art12Logging: art12,
|
|
art14Oversight: art14,
|
|
art19Retention: art19,
|
|
art26Monitoring: art26,
|
|
art50Transparency: art50,
|
|
};
|
|
}
|
|
|
|
/** Art. 12: Automatic recording of events. */
|
|
private checkArt12(workspaceId?: string): ArticleStatus & { totalInteractions: number } {
|
|
const total = this.store.count(workspaceId);
|
|
|
|
if (total === 0) {
|
|
// M11: if the DB has been active for >24h with zero interactions, escalate to non-compliant
|
|
const firstRun = this.store.getFirstRunAt();
|
|
const activeOver24h = firstRun
|
|
? (Date.now() - new Date(firstRun).getTime()) > 24 * 60 * 60 * 1000
|
|
: false;
|
|
|
|
return {
|
|
status: activeOver24h ? 'non-compliant' : 'warning',
|
|
detail: activeOver24h
|
|
? 'No interactions logged despite system being active for over 24 hours. Verify logging pipeline.'
|
|
: 'No interactions logged yet. Logging activates automatically on first AI interaction.',
|
|
totalInteractions: 0,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'compliant',
|
|
detail: `${total} interactions logged with full model, token, cost, and tool tracking.`,
|
|
totalInteractions: total,
|
|
};
|
|
}
|
|
|
|
/** Art. 14: Human oversight capability. */
|
|
private checkArt14(workspaceId?: string): ArticleStatus & { humanActions: number; approvalRate: number } {
|
|
const counts = this.store.getOversightCounts(workspaceId);
|
|
|
|
if (counts.total === 0) {
|
|
return {
|
|
status: 'compliant',
|
|
detail: 'Human oversight capabilities available (approval gates, tool deny lists). No oversight actions recorded yet.',
|
|
humanActions: 0,
|
|
approvalRate: 0,
|
|
};
|
|
}
|
|
|
|
const approvalRate = counts.total > 0
|
|
? Math.round((counts.approved / counts.total) * 100)
|
|
: 0;
|
|
|
|
return {
|
|
status: 'compliant',
|
|
detail: `${counts.total} human oversight actions: ${counts.approved} approved, ${counts.denied} denied, ${counts.modified} modified.`,
|
|
humanActions: counts.total,
|
|
approvalRate,
|
|
};
|
|
}
|
|
|
|
/** Art. 19: Log retention (minimum 6 months). */
|
|
private checkArt19(): ArticleStatus & { oldestLogDate: string | null; retentionDays: number } {
|
|
const oldest = this.store.getOldestTimestamp();
|
|
|
|
if (!oldest) {
|
|
return {
|
|
status: 'compliant',
|
|
detail: 'No logs to retain yet. Retention policy is permanent by default.',
|
|
oldestLogDate: null,
|
|
retentionDays: 0,
|
|
};
|
|
}
|
|
|
|
const oldestDate = new Date(oldest);
|
|
const now = new Date();
|
|
const retentionMs = now.getTime() - oldestDate.getTime();
|
|
const retentionDays = Math.floor(retentionMs / (24 * 60 * 60 * 1000));
|
|
|
|
// Review Critical #2: the previous expression was a tautology
|
|
// (`retentionMs >= SIX_MONTHS_MS || retentionDays < 180`) that covered every
|
|
// non-negative value of retentionDays. A deployment that pruned logs after 30
|
|
// days still reported compliant.
|
|
//
|
|
// Proper fix requires distinguishing 'system is young' from 'logs were pruned'.
|
|
// We track system age via MindDB's `meta.first_run_at` entry (set on schema init,
|
|
// backfilled for pre-existing DBs). If the system has been running for 180+ days
|
|
// but the oldest log is younger than that, something pruned the logs and we
|
|
// correctly report warning.
|
|
const firstRun = this.store.getFirstRunAt();
|
|
const systemAgeMs = firstRun ? now.getTime() - new Date(firstRun).getTime() : retentionMs;
|
|
const hasBeenRunning6Months = systemAgeMs >= SIX_MONTHS_MS;
|
|
const logsOlderThan6Months = retentionMs >= SIX_MONTHS_MS;
|
|
const meetsMinimum = !hasBeenRunning6Months || logsOlderThan6Months;
|
|
|
|
return {
|
|
status: meetsMinimum ? 'compliant' : 'warning',
|
|
detail: meetsMinimum
|
|
? hasBeenRunning6Months
|
|
? `Logs retained since ${oldest.split('T')[0]} (${retentionDays} days). Art. 19 minimum (180 days) met.`
|
|
: `Logs retained since ${oldest.split('T')[0]} (${retentionDays} days). System is still within its first 180 days — retention compliance will be enforceable after 2026-${(new Date(firstRun ?? now).getMonth() + 7).toString().padStart(2, '0')}.`
|
|
: `Oldest log: ${oldest.split('T')[0]} (${retentionDays} days) but system is ${Math.floor(systemAgeMs / (24 * 60 * 60 * 1000))} days old. Logs appear to have been pruned — EU AI Act Art. 19 requires 180-day minimum retention.`,
|
|
oldestLogDate: oldest,
|
|
retentionDays,
|
|
};
|
|
}
|
|
|
|
/** Art. 26: Deployer monitoring obligations. */
|
|
private checkArt26(): ArticleStatus & { activeMonitors: string[] } {
|
|
// Waggle always has these monitors active
|
|
const monitors = [
|
|
'cost_tracking', // CostTracker in packages/agent
|
|
'tool_logging', // Tool calls logged per interaction
|
|
'model_identification', // Model recorded per interaction
|
|
'persona_tracking', // Persona recorded per interaction
|
|
];
|
|
|
|
return {
|
|
status: 'compliant',
|
|
detail: `${monitors.length} active monitors: cost, tools, model ID, persona.`,
|
|
activeMonitors: monitors,
|
|
};
|
|
}
|
|
|
|
/** Art. 50: Transparency — models disclosed. */
|
|
private checkArt50(workspaceId?: string): ArticleStatus & { modelsDisclosed: boolean } {
|
|
const inventory = this.store.getModelInventory(undefined, undefined, workspaceId);
|
|
const modelsDisclosed = inventory.length > 0;
|
|
|
|
return {
|
|
status: 'compliant',
|
|
detail: modelsDisclosed
|
|
? `${inventory.length} model(s) in use, all identified in StatusBar and interaction logs.`
|
|
: 'Model identification active. Models will be disclosed on first interaction.',
|
|
modelsDisclosed,
|
|
};
|
|
}
|
|
}
|