diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 4adbcd8..b08bf16 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -7,6 +7,7 @@ import type { ToolSuccessEvent, ToolErrorEvent, ToolDeniedEvent, + ToolApprovalEvent, SkillsInstallerExecutionBlockedEvent, SkillsInstallerCommandResultEvent, SessionCreateEvent, @@ -88,6 +89,11 @@ export class AuditLogger { this.write({ level: 'warn', event_type: 'tool.denied', event: event as unknown as Record }); } + toolApproval(event: ToolApprovalEvent): void { + if (!this.shouldLog('tools', 'debug')) {return;} + this.write({ level: 'debug', event_type: 'tool.approval', event: event as unknown as Record }); + } + skillsInstallerExecutionBlocked(event: SkillsInstallerExecutionBlockedEvent): void { if (!this.shouldLog('tools', 'warn')) {return;} this.write({ diff --git a/src/audit/redact.ts b/src/audit/redact.ts new file mode 100644 index 0000000..87fa2aa --- /dev/null +++ b/src/audit/redact.ts @@ -0,0 +1,133 @@ +const SECRET_KEY_REGEX = /(api[_-]?key|auth|authorization|token|secret|password|cookie)/i; + +export interface RedactionResult { + value: T; + redactions: number; +} + +function redactString(input: string): RedactionResult { + let redactions = 0; + let output = input; + + // Redact common Authorization header patterns. + const beforeAuth = output; + output = output.replace(/\bBearer\s+[A-Za-z0-9._\-~=+/]+\b/g, '[REDACTED_BEARER]'); + if (output !== beforeAuth) { + redactions += 1; + } + + // Redact very long high-entropy tokens (heuristic). + const beforeLong = output; + output = output.replace(/\b[A-Za-z0-9_\-]{32,}\b/g, '[REDACTED_TOKEN]'); + if (output !== beforeLong) { + redactions += 1; + } + + return { value: output, redactions }; +} + +function shouldRedactKey(key: string): boolean { + return SECRET_KEY_REGEX.test(key); +} + +export function redactForAudit(value: unknown, opts?: { maxDepth?: number }): RedactionResult { + const maxDepth = opts?.maxDepth ?? 6; + + const seen = new WeakSet(); + + const walk = (v: unknown, depth: number): RedactionResult => { + if (depth > maxDepth) { + return { value: '[TRUNCATED_DEPTH]', redactions: 0 }; + } + + if (typeof v === 'string') { + return redactString(v); + } + + if (v === null || v === undefined) { + return { value: v, redactions: 0 }; + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return { value: v, redactions: 0 }; + } + + if (Array.isArray(v)) { + let redactions = 0; + const arr: unknown[] = []; + for (const item of v) { + const r = walk(item, depth + 1); + arr.push(r.value); + redactions += r.redactions; + } + return { value: arr, redactions }; + } + + if (typeof v === 'object') { + const obj = v as Record; + if (seen.has(obj)) { + return { value: '[CYCLE]', redactions: 0 }; + } + seen.add(obj); + + let redactions = 0; + const out: Record = {}; + for (const [k, val] of Object.entries(obj)) { + if (shouldRedactKey(k)) { + out[k] = '[REDACTED]'; + redactions += 1; + continue; + } + const r = walk(val, depth + 1); + out[k] = r.value; + redactions += r.redactions; + } + return { value: out, redactions }; + } + + // bigint/symbol/function - stringify to be safe. + return { value: String(v), redactions: 0 }; + }; + + return walk(value, 0); +} + +export function containsSecretLikeKeys(value: unknown, opts?: { maxDepth?: number }): boolean { + const maxDepth = opts?.maxDepth ?? 6; + const seen = new WeakSet(); + + const walk = (v: unknown, depth: number): boolean => { + if (depth > maxDepth) { + return false; + } + if (v === null || v === undefined) { + return false; + } + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + return false; + } + if (Array.isArray(v)) { + return v.some((item) => walk(item, depth + 1)); + } + if (typeof v === 'object') { + const obj = v as Record; + if (seen.has(obj)) { + return false; + } + seen.add(obj); + + for (const [k, val] of Object.entries(obj)) { + if (shouldRedactKey(k)) { + return true; + } + if (walk(val, depth + 1)) { + return true; + } + } + return false; + } + return false; + }; + + return walk(value, 0); +} diff --git a/src/audit/types.ts b/src/audit/types.ts index 8c4228c..a66b6c2 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -2,7 +2,7 @@ export type AuditLevel = 'debug' | 'info' | 'warn' | 'error'; export type AuditEventType = // Tool execution - | 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied' + | 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied' | 'tool.approval' // Skills installer | 'skills.installer.execution_blocked' | 'skills.installer.command_result' // Session lifecycle @@ -49,6 +49,10 @@ export interface AuditQuery { export interface ToolStartEvent { tool_name: string; tool_args: unknown; + execution_id?: string; + execution_environment?: 'host' | 'sandbox'; + skill_name?: string; + redactions_applied?: number; session_id?: string; channel?: string; sender?: string; @@ -59,6 +63,10 @@ export interface ToolSuccessEvent { tool_name: string; result: { success: boolean; output: string; error?: string }; duration_ms: number; + execution_id?: string; + execution_environment?: 'host' | 'sandbox'; + skill_name?: string; + redactions_applied?: number; session_id?: string; } @@ -67,16 +75,35 @@ export interface ToolErrorEvent { error: string; duration_ms: number; reason?: string; + execution_id?: string; + execution_environment?: 'host' | 'sandbox'; + skill_name?: string; + redactions_applied?: number; session_id?: string; } export interface ToolDeniedEvent { tool_name: string; reason: string; + execution_id?: string; + execution_environment?: 'host' | 'sandbox'; + skill_name?: string; + redactions_applied?: number; session_id?: string; denial_type: 'policy' | 'hook' | 'not_found' | 'autonomy_override'; } +export interface ToolApprovalEvent { + tool_name: string; + approved: boolean; + reason?: string; + execution_id?: string; + execution_environment?: 'host' | 'sandbox'; + skill_name?: string; + redactions_applied?: number; + session_id?: string; +} + export interface SkillsInstallerExecutionBlockedEvent { skill_name: string; phase: 'install' | 'execute';