feat(audit): add correlation ids and redaction

This commit is contained in:
William Valentin
2026-02-15 10:16:58 -08:00
parent 67058c8719
commit 28304ac397
3 changed files with 167 additions and 1 deletions
+6
View File
@@ -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<string, unknown> });
}
toolApproval(event: ToolApprovalEvent): void {
if (!this.shouldLog('tools', 'debug')) {return;}
this.write({ level: 'debug', event_type: 'tool.approval', event: event as unknown as Record<string, unknown> });
}
skillsInstallerExecutionBlocked(event: SkillsInstallerExecutionBlockedEvent): void {
if (!this.shouldLog('tools', 'warn')) {return;}
this.write({
+133
View File
@@ -0,0 +1,133 @@
const SECRET_KEY_REGEX = /(api[_-]?key|auth|authorization|token|secret|password|cookie)/i;
export interface RedactionResult<T = unknown> {
value: T;
redactions: number;
}
function redactString(input: string): RedactionResult<string> {
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<object>();
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<string, unknown>;
if (seen.has(obj)) {
return { value: '[CYCLE]', redactions: 0 };
}
seen.add(obj);
let redactions = 0;
const out: Record<string, unknown> = {};
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<object>();
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<string, unknown>;
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);
}
+28 -1
View File
@@ -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';