moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
/**
* Install Audit Store — persistent audit trail for capability install events.
*
* Records every install-relevant action (proposed, approved, installed, rejected,
* failed) so there is a verifiable history of what was installed, when, why,
* and by whom.
*
* Follows the same pattern as ImprovementSignalStore — operates on the .mind DB.
*/
import type { MindDB } from '@waggle/hive-mind-core';
import {
sqlInList, RISK_LEVELS, APPROVAL_CLASSES, AUDIT_ACTIONS,
AUDIT_CAPABILITY_TYPES, AUDIT_INITIATORS, TRUST_SOURCES,
} from '@waggle/shared';
// ── Types ──────────────────────────────────────────────────────────────
// P7/D15 A2b: the audit vocabulary is now canonical in @waggle/shared. The
// Audit*-prefixed names are kept as aliases (re-exported) so every downstream
// `import { AuditAction, ... } from '@waggle/core'` keeps working unchanged.
// All six sets were already byte-identical to the shared ones (incl. P5/D4's
// 'uninstalled'), so this is a pure structural re-point — no value change.
export type {
AuditAction, AuditCapabilityType, AuditInitiator,
RiskLevel as AuditRiskLevel, ApprovalClass as AuditApprovalClass, TrustSource as AuditTrustSource,
} from '@waggle/shared';
import type {
AuditAction, AuditCapabilityType, AuditInitiator,
RiskLevel as AuditRiskLevel, ApprovalClass as AuditApprovalClass, TrustSource as AuditTrustSource,
} from '@waggle/shared';
export interface InstallAuditEntry {
id: number;
timestamp: string;
capability_name: string;
capability_type: AuditCapabilityType;
source: string;
version: string | null;
risk_level: AuditRiskLevel;
trust_source: AuditTrustSource;
approval_class: AuditApprovalClass;
action: AuditAction;
initiator: AuditInitiator;
detail: string;
}
export interface RecordAuditInput {
capabilityName: string;
capabilityType: AuditCapabilityType;
source: string;
version?: string | null;
riskLevel: AuditRiskLevel;
trustSource: AuditTrustSource;
approvalClass: AuditApprovalClass;
action: AuditAction;
initiator: AuditInitiator;
detail?: string;
}
// ── Table DDL ──────────────────────────────────────────────────────────
// P7/D15 A3: the CHECK lists are generated from the canonical @waggle/shared
// arrays via sqlInList, so the SQLite constraint and the TS union can no longer
// drift (divergence #14 — the old comment admitted "drift silently crashes
// record()"). The mirror DDL in hive-mind-core/src/mind/schema.ts stays a
// standalone literal (it's the OSS substrate, §7.5) but is locked to these same
// canonical lists by the parity test in install-audit-check-parity.test.ts.
// P7/D15 #15: trust_source now also carries a CHECK (was unconstrained at the DB
// while the TS type claimed a closed set). Every historical value came from the
// typed AuditTrustSource (the pre-security-gate 6-set ⊂ the current 7-set), so
// the rebuild migration's row copy can never violate it.
export const INSTALL_AUDIT_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS install_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
capability_name TEXT NOT NULL,
capability_type TEXT NOT NULL CHECK (capability_type IN (${sqlInList(AUDIT_CAPABILITY_TYPES)})),
source TEXT NOT NULL,
version TEXT,
risk_level TEXT NOT NULL CHECK (risk_level IN (${sqlInList(RISK_LEVELS)})),
trust_source TEXT NOT NULL CHECK (trust_source IN (${sqlInList(TRUST_SOURCES)})),
approval_class TEXT NOT NULL CHECK (approval_class IN (${sqlInList(APPROVAL_CLASSES)})),
action TEXT NOT NULL CHECK (action IN (${sqlInList(AUDIT_ACTIONS)})),
initiator TEXT NOT NULL CHECK (initiator IN (${sqlInList(AUDIT_INITIATORS)})),
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_capability ON install_audit (capability_name, action);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON install_audit (timestamp DESC);
`;
// ── Store ──────────────────────────────────────────────────────────────
export class InstallAuditStore {
private db: MindDB;
constructor(db: MindDB) {
this.db = db;
this.ensureTable();
}
private ensureTable(): void {
const raw = this.db.getDatabase();
const existing = raw.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='install_audit'",
).get() as { sql: string } | undefined;
if (!existing) {
raw.exec(INSTALL_AUDIT_TABLE_SQL);
return;
}
// P5/D4 migration: pre-'uninstalled' installs carry a narrower action CHECK
// baked into the table DDL. SQLite can't ALTER a CHECK, so rebuild the table
// when the stored DDL lacks the new value. Idempotent — a no-op once migrated.
// #15: also rebuild when the stored DDL has no trust_source CHECK (the column
// was previously unconstrained). "CHECK (trust_source IN" is a safe sentinel —
// it appears nowhere else in this DDL.
const needsActionWiden = !existing.sql.includes("'uninstalled'");
const needsTrustSourceCheck = !existing.sql.includes('CHECK (trust_source IN');
if (needsActionWiden || needsTrustSourceCheck) {
this.rebuildForWidenedActionCheck(raw);
}
}
/**
* Rebuild install_audit with the widened `action` CHECK, preserving all rows.
* Classic SQLite 12-step table redefinition, wrapped in a transaction so a
* crash mid-rebuild leaves the original table intact.
*/
private rebuildForWidenedActionCheck(raw: ReturnType<MindDB['getDatabase']>): void {
const migrate = raw.transaction(() => {
raw.exec('ALTER TABLE install_audit RENAME TO install_audit_legacy');
// SQLite carries indexes along with RENAME (still named idx_audit_*), so
// INSTALL_AUDIT_TABLE_SQL's CREATE INDEX IF NOT EXISTS would no-op and the
// DROP below would take the indexes with the legacy table. Drop them first
// (mirrors hive-mind-core db.ts) so they get recreated on the new table.
raw.exec('DROP INDEX IF EXISTS idx_audit_capability');
raw.exec('DROP INDEX IF EXISTS idx_audit_timestamp');
raw.exec(INSTALL_AUDIT_TABLE_SQL);
raw.exec(`
INSERT INTO install_audit (
id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail
)
SELECT id, timestamp, capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail
FROM install_audit_legacy
`);
raw.exec('DROP TABLE install_audit_legacy');
});
migrate();
}
/** Record an install audit event. */
record(input: RecordAuditInput): InstallAuditEntry {
const raw = this.db.getDatabase();
raw.prepare(`
INSERT INTO install_audit (
capability_name, capability_type, source, version,
risk_level, trust_source, approval_class, action, initiator, detail
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.capabilityName,
input.capabilityType,
input.source,
input.version ?? null,
input.riskLevel,
input.trustSource,
input.approvalClass,
input.action,
input.initiator,
input.detail ?? '',
);
// Return the inserted row
return raw.prepare(
'SELECT * FROM install_audit ORDER BY id DESC LIMIT 1',
).get() as InstallAuditEntry;
}
/** Get audit history for a specific capability. */
getByCapability(name: string): InstallAuditEntry[] {
return this.db.getDatabase().prepare(
'SELECT * FROM install_audit WHERE capability_name = ? ORDER BY id DESC',
).all(name) as InstallAuditEntry[];
}
/** Get audit history filtered by action type. */
getByAction(action: AuditAction): InstallAuditEntry[] {
return this.db.getDatabase().prepare(
'SELECT * FROM install_audit WHERE action = ? ORDER BY id DESC',
).all(action) as InstallAuditEntry[];
}
/** Get recent audit entries (most recent first). */
getRecent(limit: number = 20): InstallAuditEntry[] {
return this.db.getDatabase().prepare(
'SELECT * FROM install_audit ORDER BY id DESC LIMIT ?',
).all(limit) as InstallAuditEntry[];
}
/** Get recent entries for one capability type (most recent first) — backs
* the shared Extend-layer audit read (GET /api/extend/audit?type=, C18). */
getRecentByType(type: AuditCapabilityType, limit: number = 20): InstallAuditEntry[] {
return this.db.getDatabase().prepare(
'SELECT * FROM install_audit WHERE capability_type = ? ORDER BY id DESC LIMIT ?',
).all(type, limit) as InstallAuditEntry[];
}
/** Get all entries (for testing). */
getAll(): InstallAuditEntry[] {
return this.db.getDatabase().prepare(
'SELECT * FROM install_audit ORDER BY id ASC',
).all() as InstallAuditEntry[];
}
/** Clear all entries (for testing). */
clear(): void {
this.db.getDatabase().prepare('DELETE FROM install_audit').run();
}
}