feat: implement unified audit system v3.0 with crash-safety and self-healing

## Unified Audit System (v3.0)
- Implemented crash-safe, append-only logging to audit-logs/{hostname}_{sessionId}/
- Added session.json with comprehensive metrics (timing, cost, attempts)
- Agent execution logs with turn-by-turn detail
- Prompt snapshots saved to audit-logs/.../prompts/{agent}.md
- SessionMutex prevents race conditions during parallel execution
- Self-healing reconciliation before every CLI command

## Session Metadata Standardization
- Fixed critical bug: standardized on 'id' field (not 'sessionId') throughout codebase
- Updated: shannon.mjs (recon, report), src/phases/pre-recon.js
- Added validation in AuditSession to fail fast on incorrect field usage
- JavaScript shorthand syntax was causing wrong field names

## Schema Improvements
- session.json: Added cost_usd per phase, removed redundant final_cost_usd
- Renamed 'percentage' -> 'duration_percentage' for clarity
- Simplified agent metrics to single total_cost_usd field
- Removed unused validation object from schema

## Legacy System Removal
- Removed savePromptSnapshot() - prompts now only saved by audit system
- Removed target repo pollution (prompt-snapshots/ no longer created)
- Single source of truth: audit-logs/{hostname}_{sessionId}/prompts/

## Export Script Simplification
- Removed JSON export mode (session.json already exists)
- CSV-only export with clean columns: agent, phase, status, attempts, duration_ms, cost_usd
- Tested on real session data

## Documentation
- Updated CLAUDE.md with audit system architecture
- Added .gitignore entry for audit-logs/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ajmallesh
2025-10-22 16:09:08 -07:00
parent a9e00ca19f
commit 27334a4dd6
18 changed files with 1871 additions and 206 deletions
+313
View File
@@ -0,0 +1,313 @@
/**
* Audit Session - Main Facade
*
* Coordinates logger, metrics tracker, and concurrency control for comprehensive
* crash-safe audit logging.
*/
import { AgentLogger } from './logger.js';
import { MetricsTracker } from './metrics-tracker.js';
import { initializeAuditStructure, formatTimestamp } from './utils.js';
/**
* SessionMutex for concurrency control
* (Identical to session-manager.js implementation)
*/
class SessionMutex {
constructor() {
this.locks = new Map();
}
async lock(sessionId) {
if (this.locks.has(sessionId)) {
// Wait for existing lock to be released
await this.locks.get(sessionId);
}
let resolve;
const promise = new Promise(r => resolve = r);
this.locks.set(sessionId, promise);
return () => {
this.locks.delete(sessionId);
resolve();
};
}
}
// Global mutex instance
const sessionMutex = new SessionMutex();
/**
* AuditSession - Main audit system facade
*/
export class AuditSession {
/**
* @param {Object} sessionMetadata - Session metadata from Shannon store
* @param {string} sessionMetadata.id - Session UUID
* @param {string} sessionMetadata.webUrl - Target web URL
* @param {string} [sessionMetadata.repoPath] - Target repository path
*/
constructor(sessionMetadata) {
this.sessionMetadata = sessionMetadata;
this.sessionId = sessionMetadata.id;
// Validate required fields
if (!this.sessionId) {
throw new Error('sessionMetadata.id is required');
}
if (!this.sessionMetadata.webUrl) {
throw new Error('sessionMetadata.webUrl is required');
}
// Components
this.metricsTracker = new MetricsTracker(sessionMetadata);
// Active logger (one at a time per agent attempt)
this.currentLogger = null;
// Initialization flag
this.initialized = false;
}
/**
* Initialize audit session (creates directories, session.json)
* Idempotent and race-safe
* @returns {Promise<void>}
*/
async initialize() {
if (this.initialized) {
return; // Already initialized
}
// Create directory structure
await initializeAuditStructure(this.sessionMetadata);
// Initialize metrics tracker (loads or creates session.json)
await this.metricsTracker.initialize();
this.initialized = true;
}
/**
* Ensure initialized (helper for lazy initialization)
* @private
* @returns {Promise<void>}
*/
async ensureInitialized() {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Log session-level failure (pre-agent failures)
* @param {Error} error - Error object
* @param {Object} context - Additional context
* @returns {Promise<void>}
*/
async logSessionFailure(error, context = {}) {
await this.ensureInitialized();
// Update session status
await this.metricsTracker.updateSessionStatus('failed');
// Create a special failure logger
const failureLogger = new AgentLogger(this.sessionMetadata, 'session-failure', 1);
await failureLogger.initialize();
await failureLogger.logError(error, {
...context,
timestamp: formatTimestamp(),
sessionId: this.sessionId
});
await failureLogger.close();
}
/**
* Start agent execution
* @param {string} agentName - Agent name
* @param {string} promptContent - Full prompt content
* @param {number} [attemptNumber=1] - Attempt number
* @returns {Promise<void>}
*/
async startAgent(agentName, promptContent, attemptNumber = 1) {
await this.ensureInitialized();
// Save prompt snapshot (only on first attempt)
if (attemptNumber === 1) {
await AgentLogger.savePrompt(this.sessionMetadata, agentName, promptContent);
}
// Create and initialize logger for this attempt
this.currentLogger = new AgentLogger(this.sessionMetadata, agentName, attemptNumber);
await this.currentLogger.initialize();
// Start metrics tracking
this.metricsTracker.startAgent(agentName, attemptNumber);
// Log start event
await this.currentLogger.logEvent('agent_start', {
agentName,
attemptNumber,
timestamp: formatTimestamp()
});
}
/**
* Log event during agent execution
* @param {string} eventType - Event type (tool_start, tool_end, llm_response, etc.)
* @param {Object} eventData - Event data
* @returns {Promise<void>}
*/
async logEvent(eventType, eventData) {
if (!this.currentLogger) {
throw new Error('No active logger. Call startAgent() first.');
}
await this.currentLogger.logEvent(eventType, eventData);
}
/**
* Log a text message (for compatibility)
* @param {string} message - Message to log
* @returns {Promise<void>}
*/
async logMessage(message) {
if (!this.currentLogger) {
throw new Error('No active logger. Call startAgent() first.');
}
await this.currentLogger.logMessage(message);
}
/**
* End agent execution (mutex-protected)
* @param {string} agentName - Agent name
* @param {Object} result - Execution result
* @param {number} result.attemptNumber - Attempt number
* @param {number} result.duration_ms - Duration in milliseconds
* @param {number} result.cost_usd - Cost in USD
* @param {boolean} result.success - Whether attempt succeeded
* @param {string} [result.error] - Error message (if failed)
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
* @param {boolean} [result.isFinalAttempt=false] - Whether this is the final attempt
* @returns {Promise<void>}
*/
async endAgent(agentName, result) {
// Log end event
if (this.currentLogger) {
await this.currentLogger.logEvent('agent_end', {
agentName,
success: result.success,
duration_ms: result.duration_ms,
cost_usd: result.cost_usd,
timestamp: formatTimestamp()
});
// Close logger
await this.currentLogger.close();
this.currentLogger = null;
}
// Mutex-protected update to session.json
const unlock = await sessionMutex.lock(this.sessionId);
try {
// Reload metrics (in case of parallel updates)
await this.metricsTracker.reload();
// Update metrics
await this.metricsTracker.endAgent(agentName, result);
} finally {
unlock();
}
}
/**
* Update validation results
* @param {string} agentName - Agent name
* @param {Object} validationData - Validation data
* @returns {Promise<void>}
*/
async updateValidation(agentName, validationData) {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
try {
await this.metricsTracker.reload();
await this.metricsTracker.updateValidation(agentName, validationData);
} finally {
unlock();
}
}
/**
* Mark agent as rolled back
* @param {string} agentName - Agent name
* @returns {Promise<void>}
*/
async markRolledBack(agentName) {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
try {
await this.metricsTracker.reload();
await this.metricsTracker.markRolledBack(agentName);
} finally {
unlock();
}
}
/**
* Mark multiple agents as rolled back
* @param {string[]} agentNames - Array of agent names
* @returns {Promise<void>}
*/
async markMultipleRolledBack(agentNames) {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
try {
await this.metricsTracker.reload();
await this.metricsTracker.markMultipleRolledBack(agentNames);
} finally {
unlock();
}
}
/**
* Update session status
* @param {string} status - New status (in-progress, completed, failed)
* @returns {Promise<void>}
*/
async updateSessionStatus(status) {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
try {
await this.metricsTracker.reload();
await this.metricsTracker.updateSessionStatus(status);
} finally {
unlock();
}
}
/**
* Get current metrics (read-only)
* @returns {Promise<Object>} Current metrics
*/
async getMetrics() {
await this.ensureInitialized();
return this.metricsTracker.getMetrics();
}
/**
* Get validation results (read-only)
* @returns {Promise<Object>} Validation results
*/
async getValidation() {
await this.ensureInitialized();
return this.metricsTracker.getValidation();
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Unified Audit & Metrics System
*
* Public API for the audit system. Provides crash-safe, append-only logging
* and comprehensive metrics tracking for Shannon penetration testing sessions.
*
* IMPORTANT: Session objects must have an 'id' field (NOT 'sessionId')
* Example: { id: "uuid", webUrl: "...", repoPath: "..." }
*
* @module audit
*/
export { AuditSession } from './audit-session.js';
export { AgentLogger } from './logger.js';
export { MetricsTracker } from './metrics-tracker.js';
export * as AuditUtils from './utils.js';
+247
View File
@@ -0,0 +1,247 @@
/**
* Append-Only Agent Logger
*
* Provides crash-safe, append-only logging for agent execution.
* Uses file streams with immediate flush to prevent data loss.
*/
import fs from 'fs';
import { generateLogPath, generatePromptPath, atomicWrite, formatTimestamp } from './utils.js';
/**
* AgentLogger - Manages append-only logging for a single agent execution
*/
export class AgentLogger {
/**
* @param {Object} sessionMetadata - Session metadata
* @param {string} agentName - Name of the agent
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
*/
constructor(sessionMetadata, agentName, attemptNumber) {
this.sessionMetadata = sessionMetadata;
this.agentName = agentName;
this.attemptNumber = attemptNumber;
this.timestamp = Date.now();
// Generate log file path
this.logPath = generateLogPath(sessionMetadata, agentName, this.timestamp, attemptNumber);
// Create write stream (append mode)
this.stream = null;
this.isOpen = false;
}
/**
* Initialize the log stream (creates file and opens stream)
* @returns {Promise<void>}
*/
async initialize() {
if (this.isOpen) {
return; // Already initialized
}
// Create write stream with append mode and auto-flush
this.stream = fs.createWriteStream(this.logPath, {
flags: 'a', // Append mode
encoding: 'utf8',
autoClose: true
});
this.isOpen = true;
// Write header
await this.writeHeader();
}
/**
* Write header to log file
* @private
* @returns {Promise<void>}
*/
async writeHeader() {
const header = [
`========================================`,
`Agent: ${this.agentName}`,
`Attempt: ${this.attemptNumber}`,
`Started: ${formatTimestamp(this.timestamp)}`,
`Session: ${this.sessionMetadata.id}`,
`Web URL: ${this.sessionMetadata.webUrl}`,
`========================================\n`
].join('\n');
return this.writeRaw(header);
}
/**
* Write raw text to log file with immediate flush
* @private
* @param {string} text - Text to write
* @returns {Promise<void>}
*/
writeRaw(text) {
return new Promise((resolve, reject) => {
if (!this.isOpen || !this.stream) {
reject(new Error('Logger not initialized'));
return;
}
// Write and flush immediately (crash-safe)
const needsDrain = !this.stream.write(text, 'utf8', (error) => {
if (error) {
reject(error);
}
});
if (needsDrain) {
// Buffer is full, wait for drain
const drainHandler = () => {
this.stream.removeListener('drain', drainHandler);
resolve();
};
this.stream.once('drain', drainHandler);
} else {
// Buffer has space, resolve immediately
resolve();
}
});
}
/**
* Log an event (tool_start, tool_end, llm_response, etc.)
* Events are logged as JSON for parseability
* @param {string} eventType - Type of event
* @param {Object} eventData - Event data
* @returns {Promise<void>}
*/
async logEvent(eventType, eventData) {
const event = {
type: eventType,
timestamp: formatTimestamp(),
data: eventData
};
const eventLine = `${JSON.stringify(event)}\n`;
return this.writeRaw(eventLine);
}
/**
* Log a text message (for compatibility with existing logging)
* @param {string} message - Message to log
* @returns {Promise<void>}
*/
async logMessage(message) {
const timestamp = formatTimestamp();
const line = `[${timestamp}] ${message}\n`;
return this.writeRaw(line);
}
/**
* Log tool start event
* @param {string} toolName - Name of the tool
* @param {Object} [parameters] - Tool parameters
* @returns {Promise<void>}
*/
async logToolStart(toolName, parameters = {}) {
return this.logEvent('tool_start', { toolName, parameters });
}
/**
* Log tool end event
* @param {string} toolName - Name of the tool
* @param {Object} result - Tool result
* @returns {Promise<void>}
*/
async logToolEnd(toolName, result) {
return this.logEvent('tool_end', { toolName, result });
}
/**
* Log LLM response event
* @param {string} content - Response content
* @param {Object} [metadata] - Additional metadata
* @returns {Promise<void>}
*/
async logLLMResponse(content, metadata = {}) {
return this.logEvent('llm_response', { content, ...metadata });
}
/**
* Log validation start event
* @param {string} validationType - Type of validation
* @returns {Promise<void>}
*/
async logValidationStart(validationType) {
return this.logEvent('validation_start', { validationType });
}
/**
* Log validation end event
* @param {string} validationType - Type of validation
* @param {boolean} success - Whether validation passed
* @param {Object} [details] - Validation details
* @returns {Promise<void>}
*/
async logValidationEnd(validationType, success, details = {}) {
return this.logEvent('validation_end', { validationType, success, ...details });
}
/**
* Log error event
* @param {Error} error - Error object
* @param {Object} [context] - Additional context
* @returns {Promise<void>}
*/
async logError(error, context = {}) {
return this.logEvent('error', {
message: error.message,
stack: error.stack,
...context
});
}
/**
* Close the log stream
* @returns {Promise<void>}
*/
async close() {
if (!this.isOpen || !this.stream) {
return;
}
return new Promise((resolve) => {
this.stream.end(() => {
this.isOpen = false;
resolve();
});
});
}
/**
* Save prompt snapshot to prompts directory
* Static method - doesn't require logger instance
* @param {Object} sessionMetadata - Session metadata
* @param {string} agentName - Agent name
* @param {string} promptContent - Full prompt content
* @returns {Promise<void>}
*/
static async savePrompt(sessionMetadata, agentName, promptContent) {
const promptPath = generatePromptPath(sessionMetadata, agentName);
// Create header with metadata
const header = [
`# Prompt Snapshot: ${agentName}`,
``,
`**Session:** ${sessionMetadata.id}`,
`**Web URL:** ${sessionMetadata.webUrl}`,
`**Saved:** ${formatTimestamp()}`,
``,
`---`,
``
].join('\n');
const fullContent = header + promptContent;
// Use atomic write for safety
await atomicWrite(promptPath, fullContent);
}
}
+331
View File
@@ -0,0 +1,331 @@
/**
* Metrics Tracker
*
* Manages session.json with comprehensive timing, cost, and validation metrics.
* Tracks attempt-level data for complete forensic trail.
*/
import {
generateSessionJsonPath,
atomicWrite,
readJson,
fileExists,
formatTimestamp,
calculatePercentage
} from './utils.js';
/**
* MetricsTracker - Manages metrics for a session
*/
export class MetricsTracker {
/**
* @param {Object} sessionMetadata - Session metadata from Shannon store
*/
constructor(sessionMetadata) {
this.sessionMetadata = sessionMetadata;
this.sessionJsonPath = generateSessionJsonPath(sessionMetadata);
// In-memory state (loaded from/synced to session.json)
this.data = null;
// Active timers (agent name -> start time)
this.activeTimers = new Map();
}
/**
* Initialize session.json (idempotent)
* @returns {Promise<void>}
*/
async initialize() {
// Check if session.json already exists
const exists = await fileExists(this.sessionJsonPath);
if (exists) {
// Load existing data
this.data = await readJson(this.sessionJsonPath);
} else {
// Create new session.json
this.data = this.createInitialData();
await this.save();
}
}
/**
* Create initial session.json structure
* @private
* @returns {Object} Initial session data
*/
createInitialData() {
return {
session: {
id: this.sessionMetadata.id,
webUrl: this.sessionMetadata.webUrl,
repoPath: this.sessionMetadata.repoPath,
status: 'in-progress',
createdAt: formatTimestamp()
},
metrics: {
total_duration_ms: 0,
total_cost_usd: 0,
phases: {}, // Phase-level aggregations: { duration_ms, duration_percentage, cost_usd, agent_count }
agents: {} // Agent-level metrics: { status, attempts[], final_duration_ms, total_cost_usd, checkpoint }
}
};
}
/**
* Start tracking an agent execution
* @param {string} agentName - Agent name
* @param {number} attemptNumber - Attempt number
* @returns {void}
*/
startAgent(agentName, attemptNumber) {
this.activeTimers.set(agentName, {
startTime: Date.now(),
attemptNumber
});
}
/**
* End agent execution and update metrics
* @param {string} agentName - Agent name
* @param {Object} result - Agent execution result
* @param {number} result.attemptNumber - Attempt number
* @param {number} result.duration_ms - Duration in milliseconds
* @param {number} result.cost_usd - Cost in USD
* @param {boolean} result.success - Whether attempt succeeded
* @param {string} [result.error] - Error message (if failed)
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
* @returns {Promise<void>}
*/
async endAgent(agentName, result) {
// Initialize agent metrics if not exists
if (!this.data.metrics.agents[agentName]) {
this.data.metrics.agents[agentName] = {
status: 'in-progress',
attempts: [],
final_duration_ms: 0,
total_cost_usd: 0 // Total cost across all attempts (including retries)
};
}
const agent = this.data.metrics.agents[agentName];
// Add attempt to array
const attempt = {
attempt_number: result.attemptNumber,
duration_ms: result.duration_ms,
cost_usd: result.cost_usd,
success: result.success,
timestamp: formatTimestamp()
};
if (result.error) {
attempt.error = result.error;
}
agent.attempts.push(attempt);
// Update total cost (includes failed attempts)
agent.total_cost_usd = agent.attempts.reduce((sum, a) => sum + a.cost_usd, 0);
// If successful, update final metrics and status
if (result.success) {
agent.status = 'success';
agent.final_duration_ms = result.duration_ms;
if (result.checkpoint) {
agent.checkpoint = result.checkpoint;
}
} else {
// If this was the last attempt, mark as failed
if (result.isFinalAttempt) {
agent.status = 'failed';
}
}
// Clear active timer
this.activeTimers.delete(agentName);
// Recalculate aggregations
this.recalculateAggregations();
// Save to disk
await this.save();
}
/**
* Mark agent as rolled back
* @param {string} agentName - Agent name
* @returns {Promise<void>}
*/
async markRolledBack(agentName) {
if (!this.data.metrics.agents[agentName]) {
return; // Agent not tracked
}
const agent = this.data.metrics.agents[agentName];
agent.status = 'rolled-back';
agent.rolled_back_at = formatTimestamp();
// Recalculate aggregations (exclude rolled-back agents)
this.recalculateAggregations();
await this.save();
}
/**
* Mark multiple agents as rolled back
* @param {string[]} agentNames - Array of agent names
* @returns {Promise<void>}
*/
async markMultipleRolledBack(agentNames) {
for (const agentName of agentNames) {
if (this.data.metrics.agents[agentName]) {
const agent = this.data.metrics.agents[agentName];
agent.status = 'rolled-back';
agent.rolled_back_at = formatTimestamp();
}
}
this.recalculateAggregations();
await this.save();
}
/**
* Update session status
* @param {string} status - New status (in-progress, completed, failed)
* @returns {Promise<void>}
*/
async updateSessionStatus(status) {
this.data.session.status = status;
if (status === 'completed' || status === 'failed') {
this.data.session.completedAt = formatTimestamp();
}
await this.save();
}
/**
* Recalculate aggregations (total duration, total cost, phases)
* @private
*/
recalculateAggregations() {
const agents = this.data.metrics.agents;
// Only count successful agents (not rolled-back or failed)
const successfulAgents = Object.entries(agents)
.filter(([_, data]) => data.status === 'success');
// Calculate total duration and cost
const totalDuration = successfulAgents.reduce(
(sum, [_, data]) => sum + data.final_duration_ms,
0
);
const totalCost = successfulAgents.reduce(
(sum, [_, data]) => sum + data.total_cost_usd,
0
);
this.data.metrics.total_duration_ms = totalDuration;
this.data.metrics.total_cost_usd = totalCost;
// Calculate phase-level metrics
this.data.metrics.phases = this.calculatePhaseMetrics(successfulAgents);
}
/**
* Calculate phase-level metrics
* @private
* @param {Array} successfulAgents - Array of [agentName, agentData] tuples
* @returns {Object} Phase metrics
*/
calculatePhaseMetrics(successfulAgents) {
const phases = {
'pre-recon': [],
'recon': [],
'vulnerability-analysis': [],
'exploitation': [],
'reporting': []
};
// Map agents to phases
const agentPhaseMap = {
'pre-recon': 'pre-recon',
'recon': 'recon',
'injection-vuln': 'vulnerability-analysis',
'xss-vuln': 'vulnerability-analysis',
'auth-vuln': 'vulnerability-analysis',
'authz-vuln': 'vulnerability-analysis',
'ssrf-vuln': 'vulnerability-analysis',
'injection-exploit': 'exploitation',
'xss-exploit': 'exploitation',
'auth-exploit': 'exploitation',
'authz-exploit': 'exploitation',
'ssrf-exploit': 'exploitation',
'report': 'reporting'
};
// Group agents by phase
for (const [agentName, agentData] of successfulAgents) {
const phase = agentPhaseMap[agentName];
if (phase) {
phases[phase].push(agentData);
}
}
// Calculate metrics per phase
const phaseMetrics = {};
const totalDuration = this.data.metrics.total_duration_ms;
for (const [phaseName, agentList] of Object.entries(phases)) {
if (agentList.length === 0) continue;
const phaseDuration = agentList.reduce(
(sum, agent) => sum + agent.final_duration_ms,
0
);
const phaseCost = agentList.reduce(
(sum, agent) => sum + agent.total_cost_usd,
0
);
phaseMetrics[phaseName] = {
duration_ms: phaseDuration,
duration_percentage: calculatePercentage(phaseDuration, totalDuration),
cost_usd: phaseCost,
agent_count: agentList.length
};
}
return phaseMetrics;
}
/**
* Get current metrics
* @returns {Object} Current metrics data
*/
getMetrics() {
return JSON.parse(JSON.stringify(this.data));
}
/**
* Save metrics to session.json (atomic write)
* @private
* @returns {Promise<void>}
*/
async save() {
await atomicWrite(this.sessionJsonPath, this.data);
}
/**
* Reload metrics from disk
* @returns {Promise<void>}
*/
async reload() {
this.data = await readJson(this.sessionJsonPath);
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Audit System Utilities
*
* Core utility functions for path generation, atomic writes, and formatting.
* All functions are pure and crash-safe.
*/
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Get Shannon repository root
export const SHANNON_ROOT = path.resolve(__dirname, '..', '..');
export const AUDIT_LOGS_DIR = path.join(SHANNON_ROOT, 'audit-logs');
/**
* Generate standardized session identifier: {hostname}_{sessionId}
* @param {Object} sessionMetadata - Session metadata from Shannon store
* @param {string} sessionMetadata.id - UUID session ID
* @param {string} sessionMetadata.webUrl - Target web URL
* @returns {string} Formatted session identifier
*/
export function generateSessionIdentifier(sessionMetadata) {
const { id, webUrl } = sessionMetadata;
const hostname = new URL(webUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-');
return `${hostname}_${id}`;
}
/**
* Generate path to audit log directory for a session
* @param {Object} sessionMetadata - Session metadata
* @returns {string} Absolute path to session audit directory
*/
export function generateAuditPath(sessionMetadata) {
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
return path.join(AUDIT_LOGS_DIR, sessionIdentifier);
}
/**
* Generate path to agent log file
* @param {Object} sessionMetadata - Session metadata
* @param {string} agentName - Name of the agent
* @param {number} timestamp - Timestamp (ms since epoch)
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
* @returns {string} Absolute path to agent log file
*/
export function generateLogPath(sessionMetadata, agentName, timestamp, attemptNumber) {
const auditPath = generateAuditPath(sessionMetadata);
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
return path.join(auditPath, 'agents', filename);
}
/**
* Generate path to prompt snapshot file
* @param {Object} sessionMetadata - Session metadata
* @param {string} agentName - Name of the agent
* @returns {string} Absolute path to prompt file
*/
export function generatePromptPath(sessionMetadata, agentName) {
const auditPath = generateAuditPath(sessionMetadata);
return path.join(auditPath, 'prompts', `${agentName}.md`);
}
/**
* Generate path to session.json file
* @param {Object} sessionMetadata - Session metadata
* @returns {string} Absolute path to session.json
*/
export function generateSessionJsonPath(sessionMetadata) {
const auditPath = generateAuditPath(sessionMetadata);
return path.join(auditPath, 'session.json');
}
/**
* Ensure directory exists (idempotent, race-safe)
* @param {string} dirPath - Directory path to create
* @returns {Promise<void>}
*/
export async function ensureDirectory(dirPath) {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error) {
// Ignore EEXIST errors (race condition safe)
if (error.code !== 'EEXIST') {
throw error;
}
}
}
/**
* Atomic write using temp file + rename pattern
* Guarantees no partial writes or corruption on crash
* @param {string} filePath - Target file path
* @param {Object|string} data - Data to write (will be JSON.stringified if object)
* @returns {Promise<void>}
*/
export async function atomicWrite(filePath, data) {
const tempPath = `${filePath}.tmp`;
const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
try {
// Write to temp file
await fs.writeFile(tempPath, content, 'utf8');
// Atomic rename (POSIX guarantee: atomic on same filesystem)
await fs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file on failure
try {
await fs.unlink(tempPath);
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Format duration in milliseconds to human-readable string
* @param {number} ms - Duration in milliseconds
* @returns {string} Formatted duration (e.g., "2m 34s", "45s", "1.2s")
*/
export function formatDuration(ms) {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = ms / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
/**
* Format cost in USD
* @param {number} usd - Cost in USD
* @returns {string} Formatted cost (e.g., "$0.0823", "$2.14")
*/
export function formatCost(usd) {
return `$${usd.toFixed(4)}`;
}
/**
* Format timestamp to ISO 8601 string
* @param {number} [timestamp] - Unix timestamp in ms (defaults to now)
* @returns {string} ISO 8601 formatted string
*/
export function formatTimestamp(timestamp = Date.now()) {
return new Date(timestamp).toISOString();
}
/**
* Calculate percentage
* @param {number} part - Part value
* @param {number} total - Total value
* @returns {number} Percentage (0-100)
*/
export function calculatePercentage(part, total) {
if (total === 0) return 0;
return (part / total) * 100;
}
/**
* Read and parse JSON file
* @param {string} filePath - Path to JSON file
* @returns {Promise<Object>} Parsed JSON data
*/
export async function readJson(filePath) {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
}
/**
* Check if file exists
* @param {string} filePath - Path to check
* @returns {Promise<boolean>} True if file exists
*/
export async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Initialize audit directory structure for a session
* Creates: audit-logs/{sessionId}/, agents/, prompts/
* @param {Object} sessionMetadata - Session metadata
* @returns {Promise<void>}
*/
export async function initializeAuditStructure(sessionMetadata) {
const auditPath = generateAuditPath(sessionMetadata);
const agentsPath = path.join(auditPath, 'agents');
const promptsPath = path.join(auditPath, 'prompts');
await ensureDirectory(auditPath);
await ensureDirectory(agentsPath);
await ensureDirectory(promptsPath);
}