Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions dist/lib/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } fr
import { dirname, join } from 'path';
import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
import { redactString, redactObject } from './redaction';
export class Logger {
sessionId;
toolsUsed = new Set();
Expand Down Expand Up @@ -89,7 +90,7 @@ export class Logger {
if (tool === 'Bash' || tool === 'bash') {
const command = props?.input?.command || props?.tool_input?.command;
if (command)
this.commandsExecuted.push(command);
this.commandsExecuted.push(redactString(command));
}
if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
const path = props?.input?.file_path || props?.input?.path ||
Expand All @@ -99,7 +100,7 @@ export class Logger {
}
}
}
this.writeEvent(anyEvent.type, payload);
this.writeEvent(anyEvent.type, redactObject(payload));
}
/**
* Log tool execution from tool.execute.after hook
Expand Down Expand Up @@ -127,7 +128,7 @@ export class Logger {
tool_metadata: metadata,
call_id: input.callID,
};
this.writeEvent('ToolUse', payload, toolName, metadata);
this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
}
async generateSessionSummary() {
try {
Expand Down
5 changes: 5 additions & 0 deletions dist/lib/redaction.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Redaction utility to scrub sensitive data from logs
*/
export declare function redactString(str: string): string;
export declare function redactObject(obj: any): any;
65 changes: 65 additions & 0 deletions dist/lib/redaction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Redaction utility to scrub sensitive data from logs
*/
const SENSITIVE_KEYS = [
'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
'auth', 'credential', 'private_key', 'client_secret', 'access_key'
];
const SECRET_PATTERNS = [
// AWS Access Key ID
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
// GitHub Personal Access Token (classic)
/\bghp_[a-zA-Z0-9]{36}\b/g,
// Generic Private Key
/-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
// Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
/\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
];
// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
// We construct this dynamically from SENSITIVE_KEYS
const SENSITIVE_KEY_PATTERN = new RegExp(`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, 'gi');
export function redactString(str) {
if (!str)
return str;
let redacted = str;
// 1. Redact specific patterns (like AWS keys)
for (const pattern of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, '[REDACTED]');
}
// 2. Redact key-value pairs where key suggests sensitivity
// We use a callback to preserve the key and redact the value
redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
// If value is already redacted, skip
if (value === '[REDACTED]')
return match;
// Replace the value part with [REDACTED]
return match.replace(value, '[REDACTED]');
});
return redacted;
}
export function redactObject(obj) {
if (obj === null || obj === undefined)
return obj;
if (typeof obj === 'string') {
return redactString(obj);
}
if (Array.isArray(obj)) {
return obj.map(item => redactObject(item));
}
if (typeof obj === 'object') {
const newObj = {};
for (const [key, value] of Object.entries(obj)) {
// If the key itself is sensitive, redact the value blindly if it's a string/number
const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
newObj[key] = '[REDACTED]';
}
else {
newObj[key] = redactObject(value);
}
}
return newObj;
}
return obj;
}
7 changes: 4 additions & 3 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { dirname, join } from 'path';
import type { Event } from '@opencode-ai/sdk';
import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
import { redactString, redactObject } from './redaction';

interface HookEvent {
source_app: string;
Expand Down Expand Up @@ -115,7 +116,7 @@ export class Logger {

if (tool === 'Bash' || tool === 'bash') {
const command = props?.input?.command || props?.tool_input?.command;
if (command) this.commandsExecuted.push(command);
if (command) this.commandsExecuted.push(redactString(command));
}

if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
Expand All @@ -126,7 +127,7 @@ export class Logger {
}
}

this.writeEvent(anyEvent.type, payload);
this.writeEvent(anyEvent.type, redactObject(payload));
}

/**
Expand Down Expand Up @@ -162,7 +163,7 @@ export class Logger {
call_id: input.callID,
};

this.writeEvent('ToolUse', payload, toolName, metadata);
this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
}

public async generateSessionSummary(): Promise<string | null> {
Expand Down
90 changes: 90 additions & 0 deletions src/lib/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Redaction utility to scrub sensitive data from logs
*/

const SENSITIVE_KEYS = [
'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
'auth', 'credential', 'private_key', 'client_secret', 'access_key'
];

const SECRET_PATTERNS = [
// AWS Access Key ID
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
// GitHub Personal Access Token (classic)
/\bghp_[a-zA-Z0-9]{36}\b/g,
// Generic Private Key
/-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
// Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
/\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
];

// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
// We construct this dynamically from SENSITIVE_KEYS
const SENSITIVE_KEY_PATTERN = new RegExp(
`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`,
'gi'
);

export function redactString(str: string): string {
if (!str) return str;

let redacted = str;

// 1. Redact specific patterns (like AWS keys)
for (const pattern of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, '[REDACTED]');
}

// 2. Redact key-value pairs where key suggests sensitivity
// We use a callback to preserve the key and redact the value
redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
// If value is already redacted, skip
if (value === '[REDACTED]') return match;
// Replace the value part with [REDACTED]
return match.replace(value, '[REDACTED]');
});

return redacted;
}

export function redactObject(obj: any, visited = new WeakSet<any>()): any {
if (obj === null || obj === undefined) return obj;

if (typeof obj === 'string') {
return redactString(obj);
}

if (typeof obj !== 'object') {
return obj;
}

if (obj instanceof Date) {
return obj;
}

if (visited.has(obj)) {
return '[CIRCULAR]';
}
visited.add(obj);

if (Array.isArray(obj)) {
return obj.map(item => redactObject(item, visited));
}

if (typeof obj === 'object') {
const newObj: any = {};
for (const [key, value] of Object.entries(obj)) {
// If the key itself is sensitive, redact the value blindly if it's a string/number
const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
newObj[key] = '[REDACTED]';
} else {
newObj[key] = redactObject(value, visited);
}
}
return newObj;
}

return obj;
}
4 changes: 3 additions & 1 deletion src/lib/security.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redactString } from './redaction';

/**
* Security Library for PAI Plugin
* Ported from legacy security-validator.ts
Expand Down Expand Up @@ -54,7 +56,7 @@ export function validateCommand(command: string): SecurityResult {
return {
status: 'deny',
category,
feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${command.slice(0, 50)}...`,
feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${redactString(command).slice(0, 50)}...`,
};
}
}
Expand Down
66 changes: 66 additions & 0 deletions tests/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'bun:test';
import { redactString, redactObject } from '../src/lib/redaction';

describe('Redaction Utility', () => {
it('should redact AWS keys', () => {
const input = 'export AWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE';
const output = redactString(input);
expect(output).toContain('AWS_SECRET_KEY=[REDACTED]');
expect(output).not.toContain('AKIAIOSFODNN7EXAMPLE');
});

it('should redact sensitive key-value pairs', () => {
const input = 'password="superSecretPassword"';
const output = redactString(input);
expect(output).toContain('password="[REDACTED]"');
expect(output).not.toContain('superSecretPassword');
});

it('should redact generic secrets', () => {
const input = 'my_secret_token = abcdef1234567890';
const output = redactString(input);
expect(output).toContain('my_secret_token = [REDACTED]');
expect(output).not.toContain('abcdef1234567890');
});

it('should redact inside objects', () => {
const input = {
user: 'alice',
credentials: {
password: 'password123',
apiKey: 'AKIAIOSFODNN7EXAMPLE'
}
};
const output = redactObject(input);
expect(output.user).toBe('alice');
expect(output.credentials.password).toBe('[REDACTED]');
expect(output.credentials.apiKey).toBe('[REDACTED]');
});

it('should not redact harmless values', () => {
const input = 'user_id=123';
const output = redactString(input);
expect(output).toBe(input);
});

it('should redact short values in objects if key is sensitive', () => {
const obj = { password: '123' };
const output = redactObject(obj);
expect(output.password).toBe('[REDACTED]');
});

it('should handle circular references', () => {
const obj: any = { name: 'circular' };
obj.self = obj;
const output = redactObject(obj);
expect(output.name).toBe('circular');
expect(output.self).toBe('[CIRCULAR]');
});

it('should preserve Date objects', () => {
const date = new Date('2023-01-01');
const obj = { created_at: date };
const output = redactObject(obj);
expect(output.created_at).toEqual(date);
});
});