/** * SSH audit log: dedicated table with pending → completed/failed/denied/aborted lifecycle. * * Design rationale (codex review CRITICAL #6): * Tool operations begin a pending audit row, commit, THEN issue the remote call. * If the orchestrator crashes mid-call, the pending row remains and is reconciled * to 'aborted' on next boot (see ssh-recovery.ts). This gives us "execution * happened, outcome unknown" forensics rather than "no record at all". * * All write operations are inside a transaction (better-sqlite3 default for prepare+run * with .transaction wrappers when needed). * * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1). */ import type Database from 'better-sqlite3'; export type SshAuditOutcome = 'pending' | 'success' | 'failed' | 'denied' | 'aborted'; export interface BeginAuditInput { action: string; entityType?: string; entityId?: string; connectionId?: string; ownerId?: string | null; actingUserId?: string; jobId?: string; pieceName?: string; reason?: string; detail?: Record; /** ISO8601 timestamp; defaults to now. */ startedAt?: string; } export interface SshAuditRow { id: number; action: string; entityType: string | null; entityId: string | null; connectionId: string | null; ownerId: string | null; actingUserId: string | null; jobId: string | null; pieceName: string | null; outcome: SshAuditOutcome; reason: string | null; detail: Record | null; startedAt: string; completedAt: string | null; } interface RawRow { id: number; action: string; entity_type: string | null; entity_id: string | null; connection_id: string | null; owner_id: string | null; acting_user_id: string | null; job_id: string | null; piece_name: string | null; outcome: SshAuditOutcome; reason: string | null; detail: string | null; started_at: string; completed_at: string | null; } function fromRow(r: RawRow): SshAuditRow { return { id: r.id, action: r.action, entityType: r.entity_type, entityId: r.entity_id, connectionId: r.connection_id, ownerId: r.owner_id, actingUserId: r.acting_user_id, jobId: r.job_id, pieceName: r.piece_name, outcome: r.outcome, reason: r.reason, detail: r.detail ? (JSON.parse(r.detail) as Record) : null, startedAt: r.started_at, completedAt: r.completed_at, }; } export interface SshAuditRepo { /** Insert a pending audit row. Commits before returning (synchronous via better-sqlite3). */ begin(input: BeginAuditInput): number; /** * Update an existing pending row to a terminal outcome. Idempotent: if the row * was already completed, this is a no-op (returns false). Returns true if the * row was 'pending' and was updated. */ complete(id: number, outcome: Exclude, detail?: Record): boolean; /** Convenience: begin + complete in one call, for actions with no remote operation. */ beginAndComplete(input: BeginAuditInput, outcome: Exclude): number; listForConnection(connectionId: string, limit?: number): SshAuditRow[]; listForOwner(ownerId: string, limit?: number): SshAuditRow[]; listPending(): SshAuditRow[]; getById(id: number): SshAuditRow | null; } export function createAuditRepo(db: Database.Database): SshAuditRepo { const insertStmt = db.prepare(` INSERT INTO ssh_audit_log ( action, entity_type, entity_id, connection_id, owner_id, acting_user_id, job_id, piece_name, outcome, reason, detail, started_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, NULL) `); const completeStmt = db.prepare(` UPDATE ssh_audit_log SET outcome = ?, detail = ?, completed_at = ? WHERE id = ? AND outcome = 'pending' `); const selectByIdStmt = db.prepare(`SELECT * FROM ssh_audit_log WHERE id = ?`); const selectByConnStmt = db.prepare( `SELECT * FROM ssh_audit_log WHERE connection_id = ? ORDER BY started_at DESC LIMIT ?`, ); const selectByOwnerStmt = db.prepare( `SELECT * FROM ssh_audit_log WHERE owner_id = ? ORDER BY started_at DESC LIMIT ?`, ); const selectPendingStmt = db.prepare( `SELECT * FROM ssh_audit_log WHERE outcome = 'pending' ORDER BY started_at ASC`, ); function begin(input: BeginAuditInput): number { const startedAt = input.startedAt ?? new Date().toISOString(); const detailJson = input.detail ? JSON.stringify(input.detail) : null; const result = insertStmt.run( input.action, input.entityType ?? null, input.entityId ?? null, input.connectionId ?? null, input.ownerId ?? null, input.actingUserId ?? null, input.jobId ?? null, input.pieceName ?? null, input.reason ?? null, detailJson, startedAt, ); return result.lastInsertRowid as number; } function complete( id: number, outcome: Exclude, detail?: Record, ): boolean { // Merge detail with any existing detail (caller-supplied wins for overlapping keys). let mergedDetail: string | null = null; const existing = selectByIdStmt.get(id) as RawRow | undefined; if (existing) { const existingDetail = existing.detail ? (JSON.parse(existing.detail) as Record) : {}; const merged: Record = { ...existingDetail, ...(detail ?? {}) }; mergedDetail = JSON.stringify(merged); } else if (detail) { mergedDetail = JSON.stringify(detail); } const completedAt = new Date().toISOString(); const r = completeStmt.run(outcome, mergedDetail, completedAt, id); return r.changes > 0; } function beginAndComplete( input: BeginAuditInput, outcome: Exclude, ): number { return db.transaction(() => { const id = begin(input); complete(id, outcome); return id; })(); } return { begin, complete, beginAndComplete, listForConnection(connectionId, limit = 50) { return (selectByConnStmt.all(connectionId, limit) as RawRow[]).map(fromRow); }, listForOwner(ownerId, limit = 50) { return (selectByOwnerStmt.all(ownerId, limit) as RawRow[]).map(fromRow); }, listPending() { return (selectPendingStmt.all() as RawRow[]).map(fromRow); }, getById(id) { const r = selectByIdStmt.get(id) as RawRow | undefined; return r ? fromRow(r) : null; }, }; }