- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { redactSecrets, sanitizeSessionIdForFilename, writeAuditLog } from '../src/core/audit.ts';
|
|
import type { AuditLogEntry } from '../src/types.ts';
|
|
|
|
describe('sanitizeSessionIdForFilename', () => {
|
|
test('returns valid session id unchanged', () => {
|
|
expect(sanitizeSessionIdForFilename('test-session-123')).toBe('test-session-123');
|
|
});
|
|
|
|
test('replaces invalid characters with underscores', () => {
|
|
expect(sanitizeSessionIdForFilename('test/session')).toBe('test_session');
|
|
expect(sanitizeSessionIdForFilename('test\\session')).toBe('test_session');
|
|
expect(sanitizeSessionIdForFilename('test:session')).toBe('test_session');
|
|
});
|
|
|
|
test('strips leading/trailing special chars', () => {
|
|
expect(sanitizeSessionIdForFilename('.session')).toBe('session');
|
|
expect(sanitizeSessionIdForFilename('session.')).toBe('session');
|
|
expect(sanitizeSessionIdForFilename('-session-')).toBe('session');
|
|
expect(sanitizeSessionIdForFilename('_session_')).toBe('session');
|
|
});
|
|
|
|
test('returns null for empty or invalid input', () => {
|
|
expect(sanitizeSessionIdForFilename('')).toBeNull();
|
|
expect(sanitizeSessionIdForFilename(' ')).toBeNull();
|
|
expect(sanitizeSessionIdForFilename('...')).toBeNull();
|
|
expect(sanitizeSessionIdForFilename('..')).toBeNull();
|
|
expect(sanitizeSessionIdForFilename('.')).toBeNull();
|
|
});
|
|
|
|
test('truncates long session ids', () => {
|
|
const longId = 'a'.repeat(200);
|
|
const result = sanitizeSessionIdForFilename(longId);
|
|
expect(result?.length).toBeLessThanOrEqual(128);
|
|
});
|
|
|
|
test('handles path traversal attempts', () => {
|
|
const result = sanitizeSessionIdForFilename('../../etc/passwd');
|
|
expect(result).not.toContain('/');
|
|
expect(result).not.toContain('..');
|
|
});
|
|
});
|
|
|
|
describe('redactSecrets', () => {
|
|
test('redacts TOKEN=value patterns', () => {
|
|
const result = redactSecrets('TOKEN=secret123 git reset --hard');
|
|
expect(result).toContain('<redacted>');
|
|
expect(result).not.toContain('secret123');
|
|
});
|
|
|
|
test('redacts API_KEY patterns', () => {
|
|
const result = redactSecrets('API_KEY=mysecretkey');
|
|
expect(result).toContain('<redacted>');
|
|
expect(result).not.toContain('mysecretkey');
|
|
});
|
|
|
|
test('redacts GitHub tokens', () => {
|
|
const result = redactSecrets('ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
|
|
expect(result).toBe('<redacted>');
|
|
});
|
|
|
|
test('redacts URL credentials', () => {
|
|
const result = redactSecrets('https://user:password@example.com');
|
|
expect(result).not.toContain('password');
|
|
expect(result).toContain('<redacted>');
|
|
});
|
|
|
|
test('preserves non-secret content', () => {
|
|
const result = redactSecrets('git reset --hard');
|
|
expect(result).toBe('git reset --hard');
|
|
});
|
|
|
|
test('redacts Authorization Bearer token', () => {
|
|
const result = redactSecrets('curl -H "Authorization: Bearer abc123" https://example.com');
|
|
expect(result).not.toContain('abc123');
|
|
expect(result).toContain('<redacted>');
|
|
});
|
|
|
|
test('redacts Authorization Basic token', () => {
|
|
const result = redactSecrets("curl -H 'Authorization: Basic abc123' https://example.com");
|
|
expect(result).not.toContain('abc123');
|
|
expect(result).toContain('<redacted>');
|
|
});
|
|
});
|
|
|
|
describe('writeAuditLog', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(
|
|
tmpdir(),
|
|
`safety-net-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function getLogFile(sessionId: string): string {
|
|
return join(testDir, '.cc-safety-net', 'logs', `${sessionId}.jsonl`);
|
|
}
|
|
|
|
function readLogEntries(sessionId: string): AuditLogEntry[] {
|
|
const logFile = getLogFile(sessionId);
|
|
if (!existsSync(logFile)) {
|
|
return [];
|
|
}
|
|
const content = readFileSync(logFile, 'utf-8');
|
|
return content
|
|
.split('\n')
|
|
.filter((line) => line.trim())
|
|
.map((line) => JSON.parse(line) as AuditLogEntry);
|
|
}
|
|
|
|
test('denied command creates log entry', () => {
|
|
const sessionId = 'test-session-123';
|
|
writeAuditLog(
|
|
sessionId,
|
|
'git reset --hard',
|
|
'git reset --hard',
|
|
'git reset --hard destroys uncommitted changes',
|
|
'/home/user/project',
|
|
{ homeDir: testDir },
|
|
);
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0]?.command).toContain('git reset --hard');
|
|
});
|
|
|
|
test('log format has correct fields', () => {
|
|
const sessionId = 'test-session-789';
|
|
writeAuditLog(
|
|
sessionId,
|
|
'git reset --hard',
|
|
'git reset --hard',
|
|
'git reset --hard destroys uncommitted changes',
|
|
'/home/user/project',
|
|
{ homeDir: testDir },
|
|
);
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(1);
|
|
|
|
expect(entries[0]).toHaveProperty('ts');
|
|
expect(entries[0]).toHaveProperty('command');
|
|
expect(entries[0]).toHaveProperty('segment');
|
|
expect(entries[0]).toHaveProperty('reason');
|
|
expect(entries[0]).toHaveProperty('cwd');
|
|
|
|
expect(entries[0]?.cwd).toBe('/home/user/project');
|
|
expect(entries[0]?.reason).toContain('git reset --hard');
|
|
});
|
|
|
|
test('log redacts secrets', () => {
|
|
const sessionId = 'test-session-redact';
|
|
writeAuditLog(
|
|
sessionId,
|
|
'TOKEN=secret123 git reset --hard',
|
|
'TOKEN=secret123 git reset --hard',
|
|
'git reset --hard destroys uncommitted changes',
|
|
null,
|
|
{ homeDir: testDir },
|
|
);
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0]?.command).not.toContain('secret123');
|
|
expect(entries[0]?.command).toContain('<redacted>');
|
|
});
|
|
|
|
test('missing session id creates no log', () => {
|
|
// Empty session ID
|
|
writeAuditLog('', 'git reset --hard', 'git reset --hard', 'reason', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
const logsDir = join(testDir, '.cc-safety-net', 'logs');
|
|
if (existsSync(logsDir)) {
|
|
const files = readdirSync(logsDir);
|
|
expect(files.length).toBe(0);
|
|
}
|
|
});
|
|
|
|
test('multiple denials append to same log', () => {
|
|
const sessionId = 'test-session-multi';
|
|
writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason1', null, {
|
|
homeDir: testDir,
|
|
});
|
|
writeAuditLog(sessionId, 'git clean -f', 'git clean -f', 'reason2', null, {
|
|
homeDir: testDir,
|
|
});
|
|
writeAuditLog(sessionId, 'rm -rf /', 'rm -rf /', 'reason3', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(3);
|
|
expect(entries[0]?.command).toContain('git reset --hard');
|
|
expect(entries[1]?.command).toContain('git clean -f');
|
|
expect(entries[2]?.command).toContain('rm -rf /');
|
|
});
|
|
|
|
test('session id path traversal does not escape logs dir', () => {
|
|
const sessionId = '../../outside';
|
|
writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
// Verify no file was created outside the logs dir
|
|
expect(existsSync(join(testDir, 'outside.jsonl'))).toBe(false);
|
|
|
|
// Verify log was created in the correct location
|
|
const logsDir = join(testDir, '.cc-safety-net', 'logs');
|
|
if (existsSync(logsDir)) {
|
|
const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl'));
|
|
expect(files.length).toBe(1);
|
|
// The file should be inside logs dir
|
|
for (const file of files) {
|
|
const fullPath = join(logsDir, file);
|
|
expect(fullPath.startsWith(logsDir)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('session id absolute path does not escape logs dir', () => {
|
|
const sessionId = join(testDir, 'escaped');
|
|
writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
// Verify no file was created at the escaped location
|
|
expect(existsSync(join(testDir, 'escaped.jsonl'))).toBe(false);
|
|
|
|
// Verify log was created in the correct location
|
|
const logsDir = join(testDir, '.cc-safety-net', 'logs');
|
|
if (existsSync(logsDir)) {
|
|
const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl'));
|
|
expect(files.length).toBe(1);
|
|
for (const file of files) {
|
|
const fullPath = join(logsDir, file);
|
|
expect(fullPath.startsWith(logsDir)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('cwd null when not provided', () => {
|
|
const sessionId = 'test-session-no-cwd';
|
|
writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0]?.cwd).toBeNull();
|
|
});
|
|
|
|
test('truncates long commands', () => {
|
|
const sessionId = 'test-session-long';
|
|
const longCommand = `git reset --hard ${'x'.repeat(500)}`;
|
|
writeAuditLog(sessionId, longCommand, longCommand, 'reason', null, {
|
|
homeDir: testDir,
|
|
});
|
|
|
|
const entries = readLogEntries(sessionId);
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0]?.command.length).toBeLessThanOrEqual(300);
|
|
});
|
|
});
|