SuperCharge Claude Code v1.0.0 - Complete Customization Package

Features:
- 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents)
- RalphLoop autonomous agent integration
- Multi-AI consultation (Qwen)
- Agent management system with sync capabilities
- Custom hooks for session management
- MCP servers integration
- Plugin marketplace setup
- Comprehensive installation script

Components:
- Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc.
- Agents: 100+ agents across engineering, marketing, product, etc.
- Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger
- Commands: /brainstorm, /write-plan, /execute-plan
- MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread
- Binaries: ralphloop wrapper

Installation: ./supercharge.sh
This commit is contained in:
uroma
2026-01-22 15:35:55 +00:00
Unverified
commit 7a491b1548
1013 changed files with 170070 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
import { describe, expect, test } from 'bun:test';
import { homedir } from 'node:os';
import { analyzeCommand } from '../src/core/analyze.ts';
import type { Config } from '../src/types.ts';
const EMPTY_CONFIG: Config = { version: 1, rules: [] };
describe('analyzeCommand (coverage)', () => {
test('unclosed-quote cd segment handled', () => {
// Ensures cwd-tracking fallback runs for unparseable cd segments.
expect(
analyzeCommand('cd "unterminated', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('empty head token returns null', () => {
expect(
analyzeCommand('""', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('rm -rf in home cwd is blocked with dedicated message', () => {
const result = analyzeCommand('rm -rf build', {
cwd: homedir(),
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('rm -rf in home directory');
});
test('rm without -rf in home cwd is not blocked by home cwd guard', () => {
expect(
analyzeCommand('rm -f file.txt', {
cwd: homedir(),
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('custom rules can block rm after builtin allow', () => {
const config: Config = {
version: 1,
rules: [
{
name: 'block-rm-rf',
command: 'rm',
block_args: ['-rf'],
reason: 'No rm -rf.',
},
],
};
const result = analyzeCommand('rm -rf /tmp/test-dir', {
cwd: '/tmp',
config,
});
expect(result?.reason).toContain('[block-rm-rf] No rm -rf.');
});
test('custom rules can block find after builtin allow', () => {
const config: Config = {
version: 1,
rules: [
{
name: 'block-find-print',
command: 'find',
block_args: ['-print'],
reason: 'Avoid find -print in tests.',
},
],
};
const result = analyzeCommand('find . -print', { cwd: '/tmp', config });
expect(result?.reason).toContain('[block-find-print] Avoid find -print in tests.');
});
test('fallback scan catches embedded rm', () => {
const result = analyzeCommand('tool rm -rf /', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('extremely dangerous');
});
test('fallback scan ignores embedded rm when analyzeRm allows it', () => {
expect(
analyzeCommand('tool rm -rf /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('fallback scan catches embedded git', () => {
const result = analyzeCommand('tool git reset --hard', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('fallback scan ignores embedded git when safe', () => {
expect(
analyzeCommand('tool git status', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('fallback scan catches embedded find', () => {
const result = analyzeCommand('tool find . -delete', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('find -delete');
});
test('fallback scan ignores embedded find when safe', () => {
expect(
analyzeCommand('tool find . -print', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('TMPDIR override to a temp dir keeps $TMPDIR allowed', () => {
const result = analyzeCommand('TMPDIR=/tmp rm -rf $TMPDIR/test-dir', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('xargs child git command is analyzed', () => {
const result = analyzeCommand('xargs git reset --hard', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('xargs child git command can be safe', () => {
expect(
analyzeCommand('xargs git status', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
describe('parallel parsing/analysis branches', () => {
test('parallel bash -c with placeholder and no args analyzes template', () => {
const result = analyzeCommand("parallel bash -c 'echo {}'", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel bash -c with placeholder outside script is blocked', () => {
const result = analyzeCommand("parallel bash -c 'echo hi' {} ::: a", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel bash -c without script but with args is blocked', () => {
const result = analyzeCommand("parallel bash -c ::: 'echo hi'", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel bash -c without script or args is allowed', () => {
expect(
analyzeCommand('parallel bash -c', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('parallel bash with placeholder but missing -c arg is blocked', () => {
const result = analyzeCommand('parallel bash {} -c', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel rm -rf with explicit temp arg is allowed', () => {
const result = analyzeCommand('parallel rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel git tokens are analyzed', () => {
const result = analyzeCommand('parallel git reset --hard :::', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('parallel with -- separator parses template', () => {
const result = analyzeCommand('parallel -- rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel -j option consumes its value', () => {
const result = analyzeCommand('parallel -j 4 rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,276 @@
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);
});
});

View File

@@ -0,0 +1,447 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { HookOutput } from '../src/types.ts';
function clearEnv(): void {
delete process.env.SAFETY_NET_STRICT;
delete process.env.SAFETY_NET_PARANOID;
delete process.env.SAFETY_NET_PARANOID_RM;
delete process.env.SAFETY_NET_PARANOID_INTERPRETERS;
delete process.env.CLAUDE_SETTINGS_PATH;
}
describe('CLI wrapper output format', () => {
test('blocked command produces correct JSON structure', async () => {
const input = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: {
command: 'git reset --hard',
},
});
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], {
stdin: new Blob([input]),
stdout: 'pipe',
stderr: 'pipe',
});
const output = await new Response(proc.stdout).text();
await proc.exited;
const parsed = JSON.parse(output) as HookOutput;
expect(parsed.hookSpecificOutput).toBeDefined();
expect(parsed.hookSpecificOutput.hookEventName).toBe('PreToolUse');
expect(parsed.hookSpecificOutput.permissionDecision).toBe('deny');
expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('BLOCKED by Safety Net');
expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('git reset --hard');
});
test('allowed command produces no output', async () => {
const input = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: {
command: 'git status',
},
});
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], {
stdin: new Blob([input]),
stdout: 'pipe',
stderr: 'pipe',
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('');
expect(exitCode).toBe(0);
});
test('non-Bash tool produces no output', async () => {
const input = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: {
path: '/some/file.txt',
},
});
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], {
stdin: new Blob([input]),
stdout: 'pipe',
stderr: 'pipe',
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('');
expect(exitCode).toBe(0);
});
});
describe('--statusline flag', () => {
// Create a temp settings file with plugin enabled to test statusline modes
// When settings file doesn't exist, isPluginEnabled() defaults to false (disabled)
let tempDir: string;
let enabledSettingsPath: string;
beforeEach(async () => {
clearEnv();
tempDir = await mkdtemp(join(tmpdir(), 'safety-net-statusline-'));
enabledSettingsPath = join(tempDir, 'settings.json');
await writeFile(
enabledSettingsPath,
JSON.stringify({
enabledPlugins: { 'safety-net@cc-marketplace': true },
}),
);
process.env.CLAUDE_SETTINGS_PATH = enabledSettingsPath;
});
afterEach(async () => {
clearEnv();
await rm(tempDir, { recursive: true, force: true });
});
// 1. Enabled with no mode flags → ✅
test('outputs enabled status with no env flags', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ✅');
expect(exitCode).toBe(0);
});
// 3. Enabled + Strict → 🔒 (replaces ✅)
test('shows strict mode emoji when SAFETY_NET_STRICT=1', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1' },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒');
expect(exitCode).toBe(0);
});
// 4. Enabled + Paranoid → 👁️
test('shows paranoid emoji when SAFETY_NET_PARANOID=1', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID: '1' },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 👁️');
expect(exitCode).toBe(0);
});
// 7. Enabled + Strict + Paranoid → 🔒👁️ (concatenated)
test('shows strict + paranoid emojis when both set', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_STRICT: '1',
SAFETY_NET_PARANOID: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️');
expect(exitCode).toBe(0);
});
// 5. Enabled + Paranoid RM only → 🗑️
test('shows rm emoji when SAFETY_NET_PARANOID_RM=1 only', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_PARANOID_RM: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🗑️');
expect(exitCode).toBe(0);
});
// 8. Enabled + Strict + Paranoid RM only → 🔒🗑️
test('shows strict + rm emoji when STRICT and PARANOID_RM set', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_STRICT: '1',
SAFETY_NET_PARANOID_RM: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒🗑️');
expect(exitCode).toBe(0);
});
// 6. Enabled + Paranoid Interpreters only → 🐚
test('shows interpreters emoji when SAFETY_NET_PARANOID_INTERPRETERS=1', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_PARANOID_INTERPRETERS: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🐚');
expect(exitCode).toBe(0);
});
// 9. Enabled + Strict + Paranoid Interpreters only → 🔒🐚
test('shows strict + interpreters emoji', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_STRICT: '1',
SAFETY_NET_PARANOID_INTERPRETERS: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒🐚');
expect(exitCode).toBe(0);
});
// 4/7. PARANOID_RM + PARANOID_INTERPRETERS together → 👁️ (same as PARANOID)
test('shows paranoid emoji when both PARANOID_RM and PARANOID_INTERPRETERS set', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_PARANOID_RM: '1',
SAFETY_NET_PARANOID_INTERPRETERS: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 👁️');
expect(exitCode).toBe(0);
});
// 7. Strict + PARANOID_RM + PARANOID_INTERPRETERS → 🔒👁️
test('shows strict + paranoid when all three flags set', async () => {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: enabledSettingsPath,
SAFETY_NET_STRICT: '1',
SAFETY_NET_PARANOID_RM: '1',
SAFETY_NET_PARANOID_INTERPRETERS: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️');
expect(exitCode).toBe(0);
});
});
describe('--statusline enabled/disabled detection', () => {
let tempDir: string;
beforeEach(async () => {
clearEnv();
tempDir = await mkdtemp(join(tmpdir(), 'safety-net-test-'));
});
afterEach(async () => {
clearEnv();
await rm(tempDir, { recursive: true, force: true });
});
test('shows ❌ when plugin is disabled in settings', async () => {
const settingsPath = join(tempDir, 'settings.json');
await writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'safety-net@cc-marketplace': false,
},
}),
);
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ❌');
expect(exitCode).toBe(0);
});
test('shows ✅ when plugin is enabled in settings', async () => {
const settingsPath = join(tempDir, 'settings.json');
await writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'safety-net@cc-marketplace': true,
},
}),
);
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ✅');
expect(exitCode).toBe(0);
});
test('shows ❌ when settings file does not exist (default disabled)', async () => {
const settingsPath = join(tempDir, 'nonexistent.json');
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ❌');
expect(exitCode).toBe(0);
});
test('shows ❌ when enabledPlugins key is missing (default disabled)', async () => {
const settingsPath = join(tempDir, 'settings.json');
await writeFile(settingsPath, JSON.stringify({ model: 'opus' }));
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath },
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ❌');
expect(exitCode).toBe(0);
});
test('disabled plugin ignores mode flags (shows ❌ only)', async () => {
const settingsPath = join(tempDir, 'settings.json');
await writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'safety-net@cc-marketplace': false,
},
}),
);
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: settingsPath,
SAFETY_NET_STRICT: '1',
SAFETY_NET_PARANOID: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net ❌');
expect(exitCode).toBe(0);
});
test('enabled plugin with modes shows mode emojis', async () => {
const settingsPath = join(tempDir, 'settings.json');
await writeFile(
settingsPath,
JSON.stringify({
enabledPlugins: {
'safety-net@cc-marketplace': true,
},
}),
);
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], {
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CLAUDE_SETTINGS_PATH: settingsPath,
SAFETY_NET_STRICT: '1',
},
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(output.trim()).toBe('🛡️ Safety Net 🔒');
expect(exitCode).toBe(0);
});
});

View File

@@ -0,0 +1,706 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import {
getProjectConfigPath,
getUserConfigPath,
type LoadConfigOptions,
loadConfig,
validateConfig,
validateConfigFile,
} from '../src/core/config.ts';
describe('config validation', () => {
let tempDir: string;
let userConfigDir: string;
let loadOptions: LoadConfigOptions;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'safety-net-config-'));
userConfigDir = join(tempDir, '.cc-safety-net');
loadOptions = { userConfigDir };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeProjectConfig(data: unknown): void {
const path = join(tempDir, '.safety-net.json');
if (typeof data === 'string') {
writeFileSync(path, data, 'utf-8');
} else {
writeFileSync(path, JSON.stringify(data), 'utf-8');
}
}
function loadFromProject(data: unknown) {
writeProjectConfig(data);
return loadConfig(tempDir, loadOptions);
}
describe('valid configs', () => {
test('minimal valid config', () => {
const config = loadFromProject({ version: 1 });
expect(config.version).toBe(1);
expect(config.rules).toEqual([]);
});
test('valid config with rules', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all'],
reason: 'Use specific files.',
},
],
});
expect(config.rules.length).toBe(1);
const rule = config.rules[0];
expect(rule?.name).toBe('block-git-add-all');
expect(rule?.command).toBe('git');
expect(rule?.subcommand).toBe('add');
expect(rule?.block_args).toEqual(['-A', '--all']);
expect(rule?.reason).toBe('Use specific files.');
});
test('valid config without subcommand', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'block-npm-global',
command: 'npm',
block_args: ['-g'],
reason: 'No global installs.',
},
],
});
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.subcommand).toBeUndefined();
});
test('valid rule name patterns', () => {
const validNames = [
'a',
'A',
'rule1',
'my-rule',
'my_rule',
'MyRule123',
'a'.repeat(64), // max length
];
for (const name of validNames) {
const config = loadFromProject({
version: 1,
rules: [
{
name,
command: 'git',
block_args: ['-A'],
reason: 'test',
},
],
});
expect(config.rules[0]?.name).toBe(name);
}
});
test('unknown fields ignored', () => {
const config = loadFromProject({
version: 1,
future_field: 'ignored',
rules: [
{
name: 'test',
command: 'git',
block_args: ['-A'],
reason: 'test',
unknown_rule_field: true,
},
],
});
expect(config.rules.length).toBe(1);
});
});
describe('invalid configs (all return default config silently)', () => {
test('validateConfig rejects non-object', () => {
const result = validateConfig(null);
expect(result.errors).toEqual(['Config must be an object']);
});
test('invalid JSON syntax', () => {
const config = loadFromProject('{ invalid json }');
expect(config.rules).toEqual([]);
});
test('missing version', () => {
const config = loadFromProject({ rules: [] });
expect(config.rules).toEqual([]);
});
test('wrong version number', () => {
const config = loadFromProject({ version: 2 });
expect(config.rules).toEqual([]);
});
test('version not integer', () => {
const config = loadFromProject({ version: '1' });
expect(config.rules).toEqual([]);
});
test('missing required rule fields', () => {
// Missing name
let config = loadFromProject({
version: 1,
rules: [{ command: 'git', block_args: ['-A'], reason: 'x' }],
});
expect(config.rules).toEqual([]);
// Missing command
config = loadFromProject({
version: 1,
rules: [{ name: 'test', block_args: ['-A'], reason: 'x' }],
});
expect(config.rules).toEqual([]);
// Missing block_args
config = loadFromProject({
version: 1,
rules: [{ name: 'test', command: 'git', reason: 'x' }],
});
expect(config.rules).toEqual([]);
// Missing reason
config = loadFromProject({
version: 1,
rules: [{ name: 'test', command: 'git', block_args: ['-A'] }],
});
expect(config.rules).toEqual([]);
});
test('invalid name patterns', () => {
const invalidNames = [
'1rule', // starts with number
'-rule', // starts with hyphen
'_rule', // starts with underscore
'rule with space', // contains space
'rule.name', // contains dot
'a'.repeat(65), // too long
'', // empty
];
for (const name of invalidNames) {
const config = loadFromProject({
version: 1,
rules: [
{
name,
command: 'git',
block_args: ['-A'],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
}
});
test('invalid command patterns', () => {
const invalidCommands = [
'/usr/bin/git', // path, not just command
'git add', // contains space
'1git', // starts with number
'', // empty
];
for (const cmd of invalidCommands) {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: cmd,
block_args: ['-A'],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
}
});
test('invalid subcommand patterns', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
subcommand: 'add files', // space
block_args: ['-A'],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('subcommand must be string when provided', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
subcommand: 123,
block_args: ['-A'],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('duplicate rule names case insensitive', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'MyRule',
command: 'git',
block_args: ['-A'],
reason: 'test',
},
{
name: 'myrule',
command: 'npm',
block_args: ['-g'],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('empty block_args', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
block_args: [],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('empty string in block_args', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
block_args: ['-A', ''],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('non-string in block_args', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
block_args: ['-A', 123],
reason: 'test',
},
],
});
expect(config.rules).toEqual([]);
});
test('reason exceeds max length', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
block_args: ['-A'],
reason: 'x'.repeat(257),
},
],
});
expect(config.rules).toEqual([]);
});
test('empty reason', () => {
const config = loadFromProject({
version: 1,
rules: [
{
name: 'test',
command: 'git',
block_args: ['-A'],
reason: '',
},
],
});
expect(config.rules).toEqual([]);
});
test('empty config file', () => {
const config = loadFromProject('');
expect(config.rules).toEqual([]);
});
test('whitespace only config file', () => {
const config = loadFromProject(' \n\t ');
expect(config.rules).toEqual([]);
});
test('config not object', () => {
const config = loadFromProject('[]');
expect(config.rules).toEqual([]);
});
test('rules not array', () => {
const config = loadFromProject({ version: 1, rules: {} });
expect(config.rules).toEqual([]);
});
test('rule not object', () => {
const config = loadFromProject({
version: 1,
rules: ['not an object'],
});
expect(config.rules).toEqual([]);
});
});
});
describe('config scope merging', () => {
let tempDir: string;
let userConfigDir: string;
let loadOptions: LoadConfigOptions;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'safety-net-merge-'));
userConfigDir = join(tempDir, '.cc-safety-net');
loadOptions = { userConfigDir };
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeUserConfig(data: object): void {
mkdirSync(userConfigDir, { recursive: true });
writeFileSync(join(userConfigDir, 'config.json'), JSON.stringify(data), 'utf-8');
}
function writeProjectConfig(data: object): void {
writeFileSync(join(tempDir, '.safety-net.json'), JSON.stringify(data), 'utf-8');
}
test('no config returns default', () => {
const config = loadConfig(tempDir, loadOptions);
expect(config.rules).toEqual([]);
});
test('user scope only', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'user-rule',
command: 'git',
block_args: ['-A'],
reason: 'user',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('user-rule');
});
test('project scope only', () => {
writeProjectConfig({
version: 1,
rules: [
{
name: 'project-rule',
command: 'npm',
block_args: ['-g'],
reason: 'project',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('project-rule');
});
test('both scopes merged', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'user-rule',
command: 'git',
block_args: ['-A'],
reason: 'user',
},
],
});
writeProjectConfig({
version: 1,
rules: [
{
name: 'project-rule',
command: 'npm',
block_args: ['-g'],
reason: 'project',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(2);
const ruleNames = new Set(config.rules.map((r) => r.name));
expect(ruleNames).toEqual(new Set(['user-rule', 'project-rule']));
});
test('project overrides user on duplicate', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'shared-rule',
command: 'git',
block_args: ['-A'],
reason: 'user version',
},
],
});
writeProjectConfig({
version: 1,
rules: [
{
name: 'shared-rule',
command: 'git',
block_args: ['--all'],
reason: 'project version',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.reason).toBe('project version');
expect(config.rules[0]?.block_args).toEqual(['--all']);
});
test('project overrides case insensitive', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'MyRule',
command: 'git',
block_args: ['-A'],
reason: 'user',
},
],
});
writeProjectConfig({
version: 1,
rules: [
{
name: 'myrule',
command: 'npm',
block_args: ['-g'],
reason: 'project',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('myrule');
expect(config.rules[0]?.reason).toBe('project');
});
test('mixed override and merge', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'shared-rule',
command: 'git',
block_args: ['-A'],
reason: 'user shared',
},
{
name: 'user-only',
command: 'rm',
block_args: ['-rf'],
reason: 'user only',
},
],
});
writeProjectConfig({
version: 1,
rules: [
{
name: 'shared-rule',
command: 'git',
block_args: ['--all'],
reason: 'project shared',
},
{
name: 'project-only',
command: 'npm',
block_args: ['-g'],
reason: 'project only',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(3);
const rulesByName = Object.fromEntries(config.rules.map((r) => [r.name, r]));
expect(rulesByName['shared-rule']?.reason).toBe('project shared');
expect(rulesByName['user-only']?.reason).toBe('user only');
expect(rulesByName['project-only']?.reason).toBe('project only');
});
test('invalid user config ignored', () => {
mkdirSync(userConfigDir, { recursive: true });
writeFileSync(join(userConfigDir, 'config.json'), '{"version": 2}', 'utf-8');
writeProjectConfig({
version: 1,
rules: [
{
name: 'project-rule',
command: 'npm',
block_args: ['-g'],
reason: 'project',
},
],
});
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('project-rule');
});
test('invalid project config ignored', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'user-rule',
command: 'git',
block_args: ['-A'],
reason: 'user',
},
],
});
writeFileSync(join(tempDir, '.safety-net.json'), '{"version": 2}', 'utf-8');
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('user-rule');
});
test('both invalid returns default', () => {
mkdirSync(userConfigDir, { recursive: true });
writeFileSync(join(userConfigDir, 'config.json'), '{"version": 2}', 'utf-8');
writeFileSync(join(tempDir, '.safety-net.json'), 'invalid json', 'utf-8');
const config = loadConfig(tempDir, loadOptions);
expect(config.rules).toEqual([]);
});
test('empty project rules still merges', () => {
writeUserConfig({
version: 1,
rules: [
{
name: 'user-rule',
command: 'git',
block_args: ['-A'],
reason: 'user',
},
],
});
writeProjectConfig({ version: 1, rules: [] });
const config = loadConfig(tempDir, loadOptions);
expect(config.rules.length).toBe(1);
expect(config.rules[0]?.name).toBe('user-rule');
});
});
describe('validate config file', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'safety-net-validate-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test('valid file returns empty errors', () => {
const path = join(tempDir, 'config.json');
writeFileSync(path, JSON.stringify({ version: 1 }), 'utf-8');
const result = validateConfigFile(path);
expect(result.errors).toEqual([]);
});
test('nonexistent file returns error', () => {
const result = validateConfigFile('/nonexistent/config.json');
expect(result.errors.length).toBe(1);
expect(result.errors[0]).toContain('not found');
});
test('invalid file returns errors', () => {
const path = join(tempDir, 'config.json');
writeFileSync(path, JSON.stringify({ version: 2 }), 'utf-8');
const result = validateConfigFile(path);
expect(result.errors.length).toBe(1);
expect(result.errors[0]).toContain('version');
});
test('empty file returns error', () => {
const path = join(tempDir, 'config.json');
writeFileSync(path, '', 'utf-8');
const result = validateConfigFile(path);
expect(result.errors).toEqual(['Config file is empty']);
});
});
describe('config path helpers', () => {
test('getUserConfigPath returns the expected suffix', () => {
const p = getUserConfigPath();
expect(p).toContain(`${sep}.cc-safety-net${sep}config.json`);
});
test('getProjectConfigPath resolves cwd', () => {
expect(getProjectConfigPath('/tmp')).toBe(resolve('/tmp', '.safety-net.json'));
});
});

View File

@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { analyzeCommand } from '../src/core/analyze.ts';
import { loadConfig } from '../src/core/config.ts';
function writeConfig(dir: string, data: object): void {
const path = join(dir, '.safety-net.json');
writeFileSync(path, JSON.stringify(data), 'utf-8');
}
function runGuard(command: string, cwd?: string): string | null {
const config = loadConfig(cwd);
return analyzeCommand(command, { cwd, config })?.reason ?? null;
}
function assertBlocked(command: string, reasonContains: string, cwd?: string): void {
const result = runGuard(command, cwd);
expect(result).not.toBeNull();
expect(result).toContain(reasonContains);
}
function assertAllowed(command: string, cwd?: string): void {
const result = runGuard(command, cwd);
expect(result).toBeNull();
}
describe('custom rules integration', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'safety-net-custom-rules-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test('custom rule blocks command', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all', '.'],
reason: 'Use specific files.',
},
],
});
assertBlocked('git add -A', '[block-git-add-all] Use specific files.', tempDir);
});
test('custom rule blocks with dot', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all', '.'],
reason: 'Use specific files.',
},
],
});
assertBlocked('git add .', '[block-git-add-all]', tempDir);
});
test('custom rule allows non-matching command', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'Use specific files.',
},
],
});
assertAllowed('git add file.txt', tempDir);
});
test('builtin rule takes precedence', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'custom-reset-rule',
command: 'git',
subcommand: 'reset',
block_args: ['--soft'],
reason: 'Custom reason.',
},
],
});
// Built-in rule blocks git reset --hard, not custom rule
assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir);
});
test('multiple custom rules - any match triggers block', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'No blanket add.',
},
{
name: 'block-npm-global',
command: 'npm',
subcommand: 'install',
block_args: ['-g'],
reason: 'No global installs.',
},
],
});
assertBlocked('git add -A', '[block-git-add-all]', tempDir);
assertBlocked('npm install -g pkg', '[block-npm-global]', tempDir);
});
test('rule without subcommand matches any invocation', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-npm-global',
command: 'npm',
block_args: ['-g', '--global'],
reason: 'No global.',
},
],
});
assertBlocked('npm install -g pkg', '[block-npm-global]', tempDir);
assertBlocked('npm uninstall -g pkg', '[block-npm-global]', tempDir);
});
test('no config uses builtin only', () => {
// tempDir has no config file
assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir);
assertAllowed('git add -A', tempDir);
});
test('empty rules list uses builtin only', () => {
writeConfig(tempDir, { version: 1, rules: [] });
assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir);
assertAllowed('git add -A', tempDir);
});
test('invalid config uses builtin only', () => {
const path = join(tempDir, '.safety-net.json');
writeFileSync(path, '{"version": 2}', 'utf-8');
assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir);
assertAllowed('echo hello', tempDir);
});
test('custom rules not applied to embedded commands', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'No blanket add.',
},
],
});
// Direct command is blocked
assertBlocked('git add -A', '[block-git-add-all]', tempDir);
// Embedded in bash -c is NOT blocked by custom rule (per spec)
assertAllowed("bash -c 'git add -A'", tempDir);
});
test('custom rules apply to xargs', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-xargs-grep',
command: 'xargs',
block_args: ['grep'],
reason: 'Use ripgrep instead.',
},
],
});
assertBlocked('find . | xargs grep pattern', '[block-xargs-grep]', tempDir);
});
test('custom rules apply to parallel', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-parallel-curl',
command: 'parallel',
block_args: ['curl'],
reason: 'No parallel curl.',
},
],
});
assertBlocked('parallel curl ::: url1 url2', '[block-parallel-curl]', tempDir);
});
test('attached option value not false positive', () => {
writeConfig(tempDir, {
version: 1,
rules: [
{
name: 'block-p-flag',
command: 'git',
block_args: ['-p'],
reason: 'No -p allowed.',
},
],
});
// -C/path/to/project contains 'p' in the path, but should NOT match -p
assertAllowed('git -C/path/to/project status', tempDir);
});
});

View File

@@ -0,0 +1,416 @@
import { describe, expect, test } from 'bun:test';
import { checkCustomRules } from '../src/core/rules-custom.ts';
import type { CustomRule } from '../src/types.ts';
describe('custom rule matching', () => {
test('basic command match', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all'],
reason: 'Use specific files.',
},
];
const result = checkCustomRules(['git', 'add', '-A'], rules);
expect(result).toBe('[block-git-add-all] Use specific files.');
});
test('match with long option form', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all'],
reason: 'Use specific files.',
},
];
const result = checkCustomRules(['git', 'add', '--all'], rules);
expect(result).toBe('[block-git-add-all] Use specific files.');
});
test('no match when command differs', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'test',
},
];
const result = checkCustomRules(['npm', 'add', '-A'], rules);
expect(result).toBeNull();
});
test('no match when subcommand differs', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'test',
},
];
const result = checkCustomRules(['git', 'commit', '-A'], rules);
expect(result).toBeNull();
});
test('no match when no blocked args present', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-all',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all'],
reason: 'test',
},
];
const result = checkCustomRules(['git', 'add', 'file.txt'], rules);
expect(result).toBeNull();
});
test('rule without subcommand matches any invocation', () => {
const rules: CustomRule[] = [
{
name: 'block-npm-global',
command: 'npm',
subcommand: undefined,
block_args: ['-g', '--global'],
reason: 'No global installs.',
},
];
// Match with install subcommand
let result = checkCustomRules(['npm', 'install', '-g', 'pkg'], rules);
expect(result).toBe('[block-npm-global] No global installs.');
// Match with uninstall subcommand too
result = checkCustomRules(['npm', 'uninstall', '-g', 'pkg'], rules);
expect(result).toBe('[block-npm-global] No global installs.');
});
test('multiple rules first match wins', () => {
const rules: CustomRule[] = [
{
name: 'rule1',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'Rule 1 reason',
},
{
name: 'rule2',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'Rule 2 reason',
},
];
const result = checkCustomRules(['git', 'add', '-A'], rules);
expect(result).toBe('[rule1] Rule 1 reason');
});
test('case sensitive command matching', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: undefined,
block_args: ['-A'],
reason: 'test',
},
];
// Lowercase git matches
let result = checkCustomRules(['git', '-A'], rules);
expect(result).toBe('[test] test');
// Uppercase GIT does NOT match (case-sensitive)
result = checkCustomRules(['GIT', '-A'], rules);
expect(result).toBeNull();
});
test('case sensitive arg matching', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: undefined,
block_args: ['-A'],
reason: 'test',
},
];
// -A matches
let result = checkCustomRules(['git', '-A'], rules);
expect(result).not.toBeNull();
// -a does NOT match
result = checkCustomRules(['git', '-a'], rules);
expect(result).toBeNull();
});
test('args with values can be matched', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'docker',
subcommand: 'run',
block_args: ['--privileged'],
reason: 'No privileged mode.',
},
];
const result = checkCustomRules(['docker', 'run', '--privileged', 'image'], rules);
expect(result).toBe('[test] No privileged mode.');
});
test('subcommand with options before - git -C handled correctly', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'push',
block_args: ['--force'],
reason: 'No force push.',
},
];
// git -C /path push --force: correctly identifies push as subcommand
let result = checkCustomRules(['git', '-C', '/path', 'push', '--force'], rules);
expect(result).toBe('[test] No force push.');
// Attached form -C/path also works
result = checkCustomRules(['git', '-C/path', 'push', '--force'], rules);
expect(result).toBe('[test] No force push.');
});
test('docker compose pattern', () => {
const rules: CustomRule[] = [
{
name: 'block-docker-compose-up',
command: 'docker',
subcommand: 'compose',
block_args: ['up'],
reason: 'No docker compose up.',
},
];
const result = checkCustomRules(['docker', 'compose', 'up', '-d'], rules);
expect(result).toBe('[block-docker-compose-up] No docker compose up.');
});
test('empty tokens returns null', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: undefined,
block_args: ['-A'],
reason: 'test',
},
];
const result = checkCustomRules([], rules);
expect(result).toBeNull();
});
test('empty rules returns null', () => {
const result = checkCustomRules(['git', 'add', '-A'], []);
expect(result).toBeNull();
});
test('command with path normalized', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: undefined,
block_args: ['-A'],
reason: 'test',
},
];
const result = checkCustomRules(['/usr/bin/git', '-A'], rules);
expect(result).toBe('[test] test');
});
test('block args with equals value', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'npm',
subcommand: 'config',
block_args: ['--location=global'],
reason: 'No global config.',
},
];
const tokens = ['npm', 'config', 'set', '--location=global'];
const result = checkCustomRules(tokens, rules);
expect(result).toBe('[test] No global config.');
});
test('block dot for git add', () => {
const rules: CustomRule[] = [
{
name: 'block-git-add-dot',
command: 'git',
subcommand: 'add',
block_args: ['.'],
reason: 'Use specific files.',
},
];
let result = checkCustomRules(['git', 'add', '.'], rules);
expect(result).toBe('[block-git-add-dot] Use specific files.');
// git add file.txt should pass
result = checkCustomRules(['git', 'add', 'file.txt'], rules);
expect(result).toBeNull();
});
test('multiple blocked args any matches', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'add',
block_args: ['-A', '--all', '.', '-u'],
reason: 'No blanket add.',
},
];
// Each blocked arg should trigger
for (const arg of ['-A', '--all', '.', '-u']) {
const result = checkCustomRules(['git', 'add', arg], rules);
expect(result).not.toBeNull();
}
});
test('combined short options expanded', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'test',
},
];
// -Ap contains -A, so it should be blocked
const result = checkCustomRules(['git', 'add', '-Ap'], rules);
expect(result).toBe('[test] test');
});
test('combined short options case sensitive', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'add',
block_args: ['-A'],
reason: 'test',
},
];
// -ap does NOT contain -A (lowercase a != uppercase A)
const result = checkCustomRules(['git', 'add', '-ap'], rules);
expect(result).toBeNull();
});
test('combined short options multiple flags', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'add',
block_args: ['-u'],
reason: 'test',
},
];
// -Aup contains -u
const result = checkCustomRules(['git', 'add', '-Aup'], rules);
expect(result).toBe('[test] test');
});
test('long options not expanded', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'add',
block_args: ['--all'],
reason: 'test',
},
];
// --all-files is not --all
const result = checkCustomRules(['git', 'add', '--all-files'], rules);
expect(result).toBeNull();
});
test('subcommand after double dash', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'checkout',
block_args: ['--force'],
reason: 'test',
},
];
// git -- checkout --force: subcommand is checkout after --
const result = checkCustomRules(['git', '--', 'checkout', '--force'], rules);
expect(result).toBe('[test] test');
});
test('no subcommand after double dash at end', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'push',
block_args: ['--force'],
reason: 'test',
},
];
const result = checkCustomRules(['git', '--'], rules);
expect(result).toBeNull();
});
test('long option with equals', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'push',
block_args: ['--force'],
reason: 'test',
},
];
const result = checkCustomRules(['git', '--config=foo', 'push', '--force'], rules);
expect(result).toBe('[test] test');
});
test('long option without equals', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'push',
block_args: ['--force'],
reason: 'test',
},
];
// --verbose is a flag, push is subcommand
const result = checkCustomRules(['git', '--verbose', 'push', '--force'], rules);
expect(result).toBe('[test] test');
});
test('attached short option value', () => {
const rules: CustomRule[] = [
{
name: 'test',
command: 'git',
subcommand: 'push',
block_args: ['--force'],
reason: 'test',
},
];
// -C/path is attached, so push is next
const result = checkCustomRules(['git', '-C/path', 'push', '--force'], rules);
expect(result).toBe('[test] test');
});
});

View File

@@ -0,0 +1,750 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { assertAllowed, assertBlocked, runGuard, withEnv } from './helpers.ts';
describe('edge cases', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'safety-net-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('input validation', () => {
test('empty command allows', () => {
assertAllowed('');
});
test('whitespace command allows', () => {
assertAllowed(' ');
});
test('case insensitive matching blocks', () => {
assertBlocked('GIT CHECKOUT -- file', 'git checkout --');
});
});
describe('strict mode', () => {
test('strict mode parse error denies', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
const result = runGuard("git reset --hard 'unterminated");
expect(result).not.toBeNull();
});
});
test('strict mode unparseable safe command denies', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
const result = runGuard("echo 'unterminated");
expect(result).not.toBeNull();
expect(result).toContain('could not be safely analyzed');
});
});
test('non-strict mode unparseable safe command allows', () => {
assertAllowed("echo 'unterminated");
});
test('strict mode bash -c without arg allows', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('bash -c');
});
});
test('non strict bash -c without arg allows', () => {
assertAllowed('bash -c');
});
test('bash double dash does not treat dash c as wrapper allowed', () => {
assertAllowed("bash -- -c 'echo ok'");
});
test('strict mode bash without dash c allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('bash -l echo ok');
});
});
test('strict mode bash only allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('bash');
});
});
test('strict mode bash double dash does not treat dash c as wrapper allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed("bash -- -c 'echo ok'");
});
});
test('strict mode python without one liner allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('python script.py');
});
});
test('strict mode python double dash does not treat dash c as one liner allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed("python -- -c 'print(1)'");
});
});
test('strict mode python one liner allowed', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('python -c "print(\'ok\')"');
});
});
test('strict mode bash lc without arg allows', () => {
withEnv({ SAFETY_NET_STRICT: '1' }, () => {
assertAllowed('bash -lc');
});
});
});
describe('shell wrappers', () => {
test('sh -lc wrapper blocked', () => {
assertBlocked("sh -lc 'git reset --hard'", 'git reset --hard');
});
});
describe('unparseable commands with heuristics', () => {
test('non strict unparseable rm -rf still blocked by heuristic', () => {
assertBlocked("rm -rf /some/path 'unterminated", 'rm -rf');
});
test('non strict unparseable git push -f still blocked by heuristic', () => {
assertBlocked("git push -f origin main 'unterminated", 'push --force');
});
test('non strict unparseable find delete blocked by heuristic', () => {
assertBlocked("find . -delete 'unterminated", 'find -delete');
});
test('non strict unparseable non dangerous allows', () => {
assertAllowed("echo 'unterminated");
});
test('non strict unparseable git restore help allows', () => {
assertAllowed("git restore --help 'unterminated");
});
test('non strict unparseable git checkout dash dash still blocked by heuristic', () => {
assertBlocked("git checkout -- file.txt 'unterminated", 'git checkout --');
});
test('non strict unparseable git restore blocked by heuristic', () => {
assertBlocked("git restore file.txt 'unterminated", 'git restore');
});
test('non strict unparseable git restore worktree still blocked by heuristic', () => {
assertBlocked("git restore --worktree file.txt 'unterminated", 'git restore');
});
test('non strict unparseable git stash clear still blocked by heuristic', () => {
assertBlocked("git stash clear 'unterminated", 'git stash drop/clear');
});
test('non strict unparseable git branch D still blocked by heuristic', () => {
assertBlocked("git branch -D feature 'unterminated", 'git branch -D');
});
test('non strict unparseable git reset hard still blocked by heuristic', () => {
assertBlocked("git reset --hard 'unterminated", 'git reset --hard');
});
test('non strict unparseable git reset merge still blocked by heuristic', () => {
assertBlocked("git reset --merge 'unterminated", 'git reset --merge');
});
test('non strict unparseable git clean f still blocked by heuristic', () => {
assertBlocked("git clean -f 'unterminated", 'git clean -f');
});
test('non strict unparseable git stash drop still blocked by heuristic', () => {
assertBlocked("git stash drop stash@{0} 'unterminated", 'git stash drop');
});
test('non strict unparseable git push force still blocked by heuristic', () => {
assertBlocked("git push --force origin main 'unterminated", 'push --force');
});
test('unparseable echo mentions find delete allowed', () => {
assertAllowed('echo "find . -delete');
});
test('unparseable rg mentions find delete allowed', () => {
assertAllowed('rg "find . -delete');
});
});
describe('command substitution', () => {
test('command substitution git reset hard blocked', () => {
assertBlocked('echo $(git reset --hard )', 'git reset --hard');
});
test('command substitution find delete blocked', () => {
assertBlocked('echo $(find . -delete )', 'find -delete');
});
test('command substitution rm f allowed', () => {
assertAllowed('echo $(rm -f /tmp/a )');
});
test('command substitution git status allowed', () => {
assertAllowed('echo $(git status )');
});
test('command substitution find without delete allowed', () => {
assertAllowed('echo $(find . -name foo )');
});
});
describe('xargs', () => {
test('xargs rm -rf blocked', () => {
assertBlocked('echo / | xargs rm -rf', 'rm -rf');
});
test('xargs delimiter option still blocks child rm', () => {
assertBlocked("echo / | xargs --delimiter '\\n' rm -rf", 'rm -rf');
});
test('xargs dash i does not consume child cmd still blocks', () => {
assertBlocked('echo / | xargs -i rm -rf', 'rm -rf');
});
test('xargs attached n option still blocks child rm', () => {
assertBlocked('echo / | xargs -n1 rm -rf', 'rm -rf');
});
test('xargs attached P option still blocks child rm', () => {
assertBlocked('echo / | xargs -P2 rm -rf', 'rm -rf');
});
test('xargs long opt equals still blocks child rm', () => {
assertBlocked('echo / | xargs --arg-file=/tmp/paths rm -rf', 'rm -rf');
});
test('xargs only options without child command allowed', () => {
assertAllowed('echo ok | xargs -n1');
});
test('xargs attached i option still blocks child rm', () => {
assertBlocked('echo / | xargs -i{} rm -rf', 'rm -rf');
});
test('xargs bash c script analyzed blocks', () => {
assertBlocked("echo ok | xargs bash -c 'git reset --hard'", 'xargs');
});
test('xargs child wrappers only allowed', () => {
assertAllowed('echo ok | xargs sudo --');
});
test('xargs busybox rm non destructive allowed', () => {
assertAllowed('echo ok | xargs busybox rm -f /tmp/test');
});
test('xargs find without delete allowed', () => {
assertAllowed('echo ok | xargs find . -name foo');
});
test('xargs print0 rm -rf blocked', () => {
assertBlocked('find . -print0 | xargs -0 rm -rf', 'rm -rf');
});
test('xargs arg file option still blocks child rm', () => {
assertBlocked('echo ok | xargs -a /tmp/paths rm -rf', 'rm -rf');
});
test('xargs echo allowed', () => {
assertAllowed('echo ok | xargs echo');
});
test('xargs busybox rm -rf blocked', () => {
assertBlocked('echo / | xargs busybox rm -rf', 'rm -rf');
});
test('xargs busybox find delete blocked', () => {
assertBlocked('echo ok | xargs busybox find . -delete', 'find -delete');
});
test('xargs without child command allowed', () => {
assertAllowed('echo ok | xargs');
});
test('xargs find delete blocked', () => {
assertBlocked('echo ok | xargs find . -delete', 'find -delete');
});
test('xargs git reset hard blocked', () => {
assertBlocked('echo ok | xargs git reset --hard', 'git reset --hard');
});
test('xargs replace I rm rf blocked', () => {
assertBlocked('echo / | xargs -I{} rm -rf {}', 'xargs', tempDir);
});
test('xargs replace long option enables placeholder analysis', () => {
assertBlocked("echo / | xargs --replace bash -c 'rm -rf {}'", 'xargs');
});
test('xargs replace long option with custom token enables placeholder analysis', () => {
assertBlocked("echo / | xargs --replace=FOO bash -c 'rm -rf FOO'", 'xargs');
});
test('xargs replace long option empty value defaults to braces', () => {
assertBlocked("echo / | xargs --replace= bash -c 'rm -rf {}'", 'xargs');
});
test('xargs replacement token parsing ignores unknown options', () => {
assertBlocked("echo / | xargs --replace -t bash -c 'rm -rf {}'", 'xargs');
});
test('xargs replace I bash c script is input denied safe input', () => {
assertBlocked('echo ok | xargs -I{} bash -c {}', 'arbitrary');
});
test('xargs bash c without arg denied safe input', () => {
assertBlocked('echo ok | xargs bash -c', 'arbitrary');
});
test('xargs replace I bash c placeholder rm rf blocked', () => {
assertBlocked("echo / | xargs -I{} bash -c 'rm -rf {}'", 'xargs', tempDir);
});
test('xargs replace custom token bash c placeholder rm rf blocked', () => {
assertBlocked("echo / | xargs -I% bash -c 'rm -rf %'", 'xargs', tempDir);
});
test('xargs replace I bash c script is input denied', () => {
assertBlocked("echo 'rm -rf /' | xargs -I{} bash -c {}", 'xargs');
});
test('xargs J consumes value still blocks child rm', () => {
assertBlocked('echo / | xargs -J {} rm -rf {}', 'rm -rf');
});
test('xargs rm double dash prevents dash rf as option allowed', () => {
assertAllowed('echo ok | xargs rm -- -rf', tempDir);
});
test('xargs bash c dynamic denied', () => {
assertBlocked("echo 'rm -rf /' | xargs bash -c", 'xargs');
});
});
describe('parallel', () => {
test('parallel bash c dynamic denied', () => {
assertBlocked("parallel bash -c ::: 'rm -rf /'", 'parallel');
});
test('parallel stdin mode blocks rm -rf', () => {
assertBlocked('echo / | parallel rm -rf', 'rm -rf');
});
test('parallel busybox stdin mode blocks rm -rf', () => {
assertBlocked('echo / | parallel busybox rm -rf', 'rm -rf');
});
test('parallel busybox find delete blocked', () => {
assertBlocked('parallel busybox find . -delete ::: ok', 'find -delete');
});
test('parallel git reset hard blocked', () => {
assertBlocked('parallel git reset --hard ::: ok', 'git reset --hard');
});
test('parallel find delete blocked', () => {
assertBlocked('parallel find . -delete ::: ok', 'find -delete');
});
test('parallel find without delete allowed', () => {
assertAllowed('parallel find . -name foo ::: ok');
});
test('parallel busybox find without delete allowed', () => {
assertAllowed('parallel busybox find . -name foo ::: ok');
});
test('parallel stdin without template allowed', () => {
assertAllowed('echo ok | parallel');
});
test('parallel marker without template allowed', () => {
assertAllowed('parallel :::');
});
test('parallel bash c script is input denied', () => {
assertBlocked("echo 'rm -rf /' | parallel bash -c {}", 'parallel');
});
test('parallel bash c script is input denied safe input', () => {
assertBlocked('echo ok | parallel bash -c {}', 'arbitrary');
});
test('parallel results option blocks rm rf', () => {
assertBlocked('parallel --results out rm -rf {} ::: /', 'rm -rf', tempDir);
});
test('parallel jobs attached option blocks', () => {
assertBlocked('parallel -j2 rm -rf {} ::: /', 'root or home', tempDir);
});
test('parallel jobs long equals option blocks', () => {
assertBlocked('parallel --jobs=2 rm -rf {} ::: /', 'root or home', tempDir);
});
test('parallel unknown long option is ignored for template parsing', () => {
assertBlocked('parallel --eta rm -rf {} ::: /', 'root or home', tempDir);
});
test('parallel unknown short option ignored for template parsing', () => {
assertBlocked('parallel -q rm -rf {} ::: /', 'root or home', tempDir);
});
test('parallel bash c stdin mode blocks rm rf placeholder', () => {
assertBlocked("echo / | parallel bash -c 'rm -rf {}'", 'rm -rf');
});
test('parallel commands mode blocks rm rf', () => {
assertBlocked("parallel ::: 'rm -rf /'", 'rm -rf');
});
test('parallel commands mode allows when all commands safe', () => {
assertAllowed("parallel ::: 'echo ok' 'true'");
});
test('parallel rm rf args after marker without placeholder blocked', () => {
assertBlocked('parallel rm -rf ::: /', 'root or home');
});
test('parallel rm rf with replacement args analyzed', () => {
assertBlocked('parallel rm -rf {} ::: /', 'rm -rf', tempDir);
});
test('parallel bash c rm rf with replacement args analyzed', () => {
assertBlocked("parallel bash -c 'rm -rf {}' ::: /", 'rm -rf', tempDir);
});
test('parallel busybox rm rf with replacement args analyzed', () => {
assertBlocked('parallel busybox rm -rf {} ::: /', 'rm -rf', tempDir);
});
test('parallel rm rf with safe replacement allowed', () => {
assertAllowed('parallel rm -rf {} ::: build', tempDir);
});
test('parallel bash c rm rf with safe replacement allowed', () => {
assertAllowed("parallel bash -c 'rm -rf {}' ::: build", tempDir);
});
test('parallel busybox rm rf with safe replacement allowed', () => {
assertAllowed('parallel busybox rm -rf {} ::: build', tempDir);
});
test('parallel bash c without placeholder analyzes script', () => {
assertBlocked("parallel bash -c 'git reset --hard' ::: ok", 'git reset --hard');
});
test('parallel bash c without placeholder allows safe script', () => {
assertAllowed("parallel bash -c 'echo ok' ::: ok");
});
test('parallel busybox rm rf args after marker without placeholder blocked', () => {
assertBlocked('parallel busybox rm -rf ::: /', 'root or home');
});
});
describe('busybox', () => {
test('busybox find delete blocked', () => {
assertBlocked('busybox find . -delete', 'find -delete');
});
test('busybox find without delete allowed', () => {
assertAllowed('busybox find . -name foo');
});
});
describe('operators', () => {
test('or operator split blocked', () => {
assertBlocked('git status || git reset --hard', 'git reset --hard');
});
test('semicolon split blocked', () => {
assertBlocked('git status; git reset --hard', 'git reset --hard');
});
test('newline split blocked', () => {
assertBlocked('git status\ngit reset --hard', 'git reset --hard');
});
test('redirection ampersand does not split blocked', () => {
assertBlocked('echo ok 2>&1 && git reset --hard', 'git reset --hard');
});
test('redirection ampersand greater does not split blocked', () => {
assertBlocked('echo ok &>out && git reset --hard', 'git reset --hard');
});
test('pipe stderr and stdout split blocked', () => {
assertBlocked('echo ok |& git reset --hard', 'git reset --hard');
});
test('shell split with leading operator still blocks', () => {
assertBlocked('&& git reset --hard', 'git reset --hard');
});
test('shell split with leading pipe still blocks', () => {
assertBlocked('| git reset --hard', 'git reset --hard');
});
test('shell split with leading pipe stderr operator still blocks', () => {
assertBlocked('|& git reset --hard', 'git reset --hard');
});
test('shell split with leading background operator still blocks', () => {
assertBlocked('& git reset --hard', 'git reset --hard');
});
test('shell split with trailing separator ignored allowed', () => {
assertAllowed('git status;');
});
});
describe('env wrappers', () => {
test('sudo double dash wrapper bypass blocked', () => {
assertBlocked('sudo -- git reset --hard', 'git reset --hard');
});
test('env unset equals wrapper bypass blocked', () => {
assertBlocked('env --unset=PATH git reset --hard', 'git reset --hard');
});
test('env unset attached wrapper bypass blocked', () => {
assertBlocked('env -uPATH git reset --hard', 'git reset --hard');
});
test('env C attached wrapper bypass blocked', () => {
assertBlocked('env -C/tmp git reset --hard', 'git reset --hard');
});
test('env C separate wrapper bypass blocked', () => {
assertBlocked('env -C /tmp git reset --hard', 'git reset --hard');
});
test('env P wrapper bypass blocked', () => {
assertBlocked('env -P /usr/bin git reset --hard', 'git reset --hard');
});
test('env S wrapper bypass blocked', () => {
assertBlocked("env -S 'PATH=/usr/bin' git reset --hard", 'git reset --hard');
});
test('env dash breaks option scan still blocks', () => {
assertBlocked('env - git reset --hard', 'git reset --hard');
});
test('command combined short opts wrapper bypass blocked', () => {
assertBlocked('command -pv -- git reset --hard', 'git reset --hard');
});
test('command V wrapper bypass blocked', () => {
assertBlocked('command -V git reset --hard', 'git reset --hard');
});
test('command combined short opts with V wrapper bypass blocked', () => {
assertBlocked('command -pvV -- git reset --hard', 'git reset --hard');
});
test('env assignments stripped blocked', () => {
assertBlocked('FOO=1 BAR=2 git reset --hard', 'git reset --hard');
});
test('invalid env assignment key does not strip still blocks', () => {
assertBlocked('1A=2 git reset --hard', 'git reset --hard');
});
test('invalid env assignment chars does not strip still blocks', () => {
assertBlocked('A-B=2 git reset --hard', 'git reset --hard');
});
test('empty env assignment key does not strip still blocks', () => {
assertBlocked('=2 git reset --hard', 'git reset --hard');
});
test('only env assignments allowed', () => {
assertAllowed('FOO=1');
});
test('sudo option wrapper bypass blocked', () => {
assertBlocked('sudo -u root -- git reset --hard', 'git reset --hard');
});
test('env P attached wrapper bypass blocked', () => {
assertBlocked('env -P/usr/bin git reset --hard', 'git reset --hard');
});
test('env S attached wrapper bypass blocked', () => {
assertBlocked('env -SPATH=/usr/bin git reset --hard', 'git reset --hard');
});
test('env unknown option wrapper bypass blocked', () => {
assertBlocked('env -i git reset --hard', 'git reset --hard');
});
test('command unknown short opts not stripped still blocks', () => {
assertBlocked('command -px git reset --hard', 'git reset --hard');
});
});
describe('interpreter one-liners', () => {
test('node -e dangerous blocked', () => {
assertBlocked('node -e "rm -rf /"', 'rm -rf');
});
test('node -e safe allowed', () => {
assertAllowed('node -e "console.log(\\"ok\\")"');
});
test('ruby -e dangerous blocked', () => {
assertBlocked('ruby -e "rm -rf /"', 'rm -rf');
});
test('ruby -e safe allowed', () => {
assertAllowed('ruby -e "puts \'ok\'"');
});
test('perl -e dangerous blocked', () => {
assertBlocked('perl -e "rm -rf /"', 'rm -rf');
});
test('perl -e safe allowed', () => {
assertAllowed('perl -e "print \'ok\'"');
});
});
describe('paranoid mode', () => {
test('paranoid mode python one liner denies', () => {
withEnv({ SAFETY_NET_PARANOID_INTERPRETERS: '1' }, () => {
assertBlocked('python -c "print(\'ok\')"', 'Paranoid mode');
});
});
test('global paranoid mode python one liner denies', () => {
withEnv({ SAFETY_NET_PARANOID: '1' }, () => {
assertBlocked('python -c "print(\'ok\')"', 'Paranoid mode');
});
});
});
describe('recursion', () => {
test('shell dash c recursion limit reached blocks command', () => {
let cmd = 'rm -rf /some/path';
for (let i = 0; i < 11; i++) {
cmd = `bash -c ${JSON.stringify(cmd)}`;
}
assertBlocked(cmd, 'recursion');
});
});
describe('cwd handling', () => {
test('cwd empty string treated as unknown', () => {
assertBlocked('git reset --hard', 'git reset --hard', '');
});
});
describe('display-only commands bypass fallback scanning', () => {
test('echo with git reset --hard allowed', () => {
assertAllowed('echo git reset --hard');
});
test('echo with rm -rf allowed', () => {
assertAllowed('echo rm -rf /');
});
test('printf with git reset --hard allowed', () => {
assertAllowed("printf 'git reset --hard'");
});
test('printf with rm -rf allowed', () => {
assertAllowed("printf 'rm -rf /'");
});
test('cat with find -delete allowed', () => {
assertAllowed('cat find -delete');
});
test('grep with git checkout -- file allowed', () => {
assertAllowed("grep 'git checkout -- file' log.txt");
});
test('rg with rm -rf allowed', () => {
assertAllowed("rg 'rm -rf' .");
});
test('sed with git reset --hard allowed', () => {
assertAllowed("sed 's/git reset --hard/safe/' file.txt");
});
test('awk with rm -rf allowed', () => {
assertAllowed("awk '/rm -rf/ {print}' log.txt");
});
test('head with git clean -f allowed', () => {
assertAllowed('head git clean -f');
});
test('tail with git stash drop allowed', () => {
assertAllowed('tail git stash drop');
});
test('wc with rm -rf allowed', () => {
assertAllowed('wc rm -rf /');
});
test('less with git push --force allowed', () => {
assertAllowed('less git push --force');
});
});
describe('recursion depth boundary', () => {
test('shell dash c recursion at exactly MAX_RECURSION_DEPTH (10) blocks', () => {
let cmd = 'rm -rf /some/path';
for (let i = 0; i < 10; i++) {
cmd = `bash -c ${JSON.stringify(cmd)}`;
}
assertBlocked(cmd, 'recursion');
});
test('shell dash c recursion at depth 9 still blocks with rm reason', () => {
let cmd = 'rm -rf /some/path';
for (let i = 0; i < 9; i++) {
cmd = `bash -c ${JSON.stringify(cmd)}`;
}
assertBlocked(cmd, 'rm -rf');
});
});
describe('parallel rm placeholder expansion with mixed args', () => {
test('parallel rm -rf with one safe and one dangerous arg blocked', () => {
assertBlocked('parallel rm -rf {} ::: build /', 'rm -rf', tempDir);
});
test('parallel rm -rf with multiple dangerous args blocked', () => {
assertBlocked('parallel rm -rf {} ::: / ~', 'rm -rf', tempDir);
});
test('parallel rm -rf with all safe args allowed', () => {
assertAllowed('parallel rm -rf {} ::: build dist node_modules', tempDir);
});
test('parallel bash -c rm -rf with mixed args blocked', () => {
assertBlocked("parallel bash -c 'rm -rf {}' ::: build /", 'rm -rf', tempDir);
});
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test';
import { envTruthy } from '../src/core/env.ts';
describe('envTruthy', () => {
test("returns true for '1'", () => {
process.env.TEST_ENV_TRUTHY = '1';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(true);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns true for 'true'", () => {
process.env.TEST_ENV_TRUTHY = 'true';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(true);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns true for 'TRUE'", () => {
process.env.TEST_ENV_TRUTHY = 'TRUE';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(true);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns true for 'True'", () => {
process.env.TEST_ENV_TRUTHY = 'True';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(true);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns false for 'false'", () => {
process.env.TEST_ENV_TRUTHY = 'false';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns false for 'FALSE'", () => {
process.env.TEST_ENV_TRUTHY = 'FALSE';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
delete process.env.TEST_ENV_TRUTHY;
});
test("returns false for '0'", () => {
process.env.TEST_ENV_TRUTHY = '0';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
delete process.env.TEST_ENV_TRUTHY;
});
test('returns false for empty string', () => {
process.env.TEST_ENV_TRUTHY = '';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
delete process.env.TEST_ENV_TRUTHY;
});
test('returns false for undefined', () => {
delete process.env.TEST_ENV_TRUTHY;
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
});
test('returns false for random string', () => {
process.env.TEST_ENV_TRUTHY = 'yes';
expect(envTruthy('TEST_ENV_TRUTHY')).toBe(false);
delete process.env.TEST_ENV_TRUTHY;
});
});

View File

@@ -0,0 +1,105 @@
import { describe, test } from 'bun:test';
import { assertAllowed, assertBlocked } from './helpers.ts';
describe('find -delete tests', () => {
test('find delete blocked', () => {
assertBlocked('find . -name "*.pyc" -delete', 'find -delete');
});
test('find name argument delete allowed', () => {
assertAllowed('find . -name -delete -print');
});
test('find exec echo delete allowed', () => {
assertAllowed('find . -exec echo -delete \\; -print');
});
test('find exec plus terminator mentions delete allowed', () => {
assertAllowed('find . -exec echo -delete + -print');
});
test('busybox find delete blocked', () => {
assertBlocked('busybox find . -name "*.pyc" -delete', 'find -delete');
});
test('find print allowed', () => {
assertAllowed('find . -name "*.pyc" -print');
});
test('echo mentions find delete allowed', () => {
assertAllowed('echo "find . -name *.pyc -delete"');
});
test('rg mentions find delete allowed', () => {
assertAllowed('rg "find .* -delete" file.txt');
});
test('python c system find delete blocked', () => {
assertBlocked(
'python -c "import os; os.system(\\"find . -delete\\")"',
'dangerous command in interpreter',
);
});
});
describe('find -exec rm tests', () => {
test('find exec rm rf blocked', () => {
assertBlocked('find . -exec rm -rf {} \\;', 'rm -rf');
});
test('find execdir rm rf blocked', () => {
assertBlocked('find /tmp -execdir rm -rf {} +', 'rm -rf');
});
test('find exec rm r force blocked', () => {
assertBlocked("find . -name '*.tmp' -exec rm -r --force {} \\;", 'rm -rf');
});
test('find exec rm recursive blocked', () => {
assertBlocked('find . -exec rm --recursive -f {} \\;', 'rm -rf');
});
test('find exec rm no force allowed', () => {
assertAllowed('find . -exec rm -r {} \\;');
});
test('find exec rm no recursive allowed', () => {
assertAllowed('find . -exec rm -f {} \\;');
});
test('find exec echo allowed', () => {
assertAllowed('find . -exec echo {} \\;');
});
test('find exec cat allowed', () => {
assertAllowed('find . -type f -exec cat {} +');
});
test('busybox find exec rm rf blocked', () => {
assertBlocked('busybox find . -exec rm -rf {} \\;', 'rm -rf');
});
test('find exec rm rf in bash c blocked', () => {
assertBlocked("bash -c 'find . -exec rm -rf {} \\;'", 'rm -rf');
});
test('find exec env rm rf blocked', () => {
assertBlocked('find . -exec env rm -rf {} ;', 'rm -rf');
});
test('find exec sudo rm rf blocked', () => {
assertBlocked('find . -exec sudo rm -rf {} ;', 'rm -rf');
});
test('find exec command rm rf blocked', () => {
assertBlocked('find . -exec command rm -rf {} ;', 'rm -rf');
});
test('find exec busybox rm rf blocked', () => {
assertBlocked('find . -exec busybox rm -rf {} ;', 'rm -rf');
});
test('find execdir env rm rf blocked', () => {
assertBlocked('find /tmp -execdir env rm -rf {} +', 'rm -rf');
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test';
import { formatBlockedMessage } from '../src/core/format.ts';
describe('formatBlockedMessage', () => {
test('includes reason in output', () => {
const result = formatBlockedMessage({ reason: 'test reason' });
expect(result).toContain('BLOCKED by Safety Net');
expect(result).toContain('Reason: test reason');
});
test('includes command when provided', () => {
const result = formatBlockedMessage({
reason: 'test reason',
command: 'rm -rf /',
});
expect(result).toContain('Command: rm -rf /');
});
test('includes segment when provided', () => {
const result = formatBlockedMessage({
reason: 'test reason',
segment: 'git reset --hard',
});
expect(result).toContain('Segment: git reset --hard');
});
test('includes both command and segment when different', () => {
const result = formatBlockedMessage({
reason: 'test reason',
command: 'full command here',
segment: 'git reset --hard',
});
expect(result).toContain('Command: full command here');
expect(result).toContain('Segment: git reset --hard');
});
test('does not duplicate segment when same as command', () => {
const result = formatBlockedMessage({
reason: 'test reason',
command: 'git reset --hard',
segment: 'git reset --hard',
});
expect(result).toContain('Command: git reset --hard');
const segmentMatches = result.match(/Segment:/g);
expect(segmentMatches).toBeNull();
});
test('truncates long commands with maxLen', () => {
const longCommand = 'a'.repeat(300);
const result = formatBlockedMessage({
reason: 'test reason',
command: longCommand,
maxLen: 50,
});
expect(result).toContain('...');
expect(result.length).toBeLessThan(longCommand.length + 100);
});
test('uses default maxLen of 200', () => {
const longCommand = 'a'.repeat(300);
const result = formatBlockedMessage({
reason: 'test reason',
command: longCommand,
});
expect(result).toContain('...');
});
test('does not truncate short commands', () => {
const shortCommand = 'rm -rf /';
const result = formatBlockedMessage({
reason: 'test reason',
command: shortCommand,
});
expect(result).toContain(`Command: ${shortCommand}`);
expect(result).not.toContain('...');
});
test('includes footer about asking user', () => {
const result = formatBlockedMessage({ reason: 'test reason' });
expect(result).toContain('ask the user');
});
test('applies redact function to command', () => {
const redactFn = (text: string) => text.replace(/secret/g, '***');
const result = formatBlockedMessage({
reason: 'test reason',
command: 'rm -rf /secret/path',
redact: redactFn,
});
expect(result).toContain('Command: rm -rf /***/path');
expect(result).not.toContain('secret');
});
test('applies redact function to segment', () => {
const redactFn = (text: string) => text.replace(/password/g, '***');
const result = formatBlockedMessage({
reason: 'test reason',
command: 'full command',
segment: 'echo password',
redact: redactFn,
});
expect(result).toContain('Segment: echo ***');
expect(result).not.toContain('password');
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test';
async function runGeminiHook(
input: object,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '-gc'], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
proc.stdin.write(JSON.stringify(input));
proc.stdin.end();
const stdoutPromise = new Response(proc.stdout).text();
const stderrPromise = new Response(proc.stderr).text();
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
const exitCode = await proc.exited;
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
describe('Gemini CLI hook', () => {
describe('input parsing', () => {
test('blocks rm -rf via run_shell_command', async () => {
const input = {
hook_event_name: 'BeforeTool',
tool_name: 'run_shell_command',
tool_input: { command: 'rm -rf /' },
};
const { stdout, exitCode } = await runGeminiHook(input);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output.decision).toBe('deny');
expect(output.reason).toContain('rm -rf');
});
test('allows safe commands (no output)', async () => {
const input = {
hook_event_name: 'BeforeTool',
tool_name: 'run_shell_command',
tool_input: { command: 'ls -la' },
};
const { stdout, exitCode } = await runGeminiHook(input);
expect(exitCode).toBe(0);
expect(stdout).toBe(''); // No output means allowed
});
test('ignores non-BeforeTool events', async () => {
const input = {
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'rm -rf /' },
};
const { stdout, exitCode } = await runGeminiHook(input);
expect(exitCode).toBe(0);
expect(stdout).toBe(''); // Ignored, not blocked
});
test('ignores non-shell tools', async () => {
const input = {
hook_event_name: 'BeforeTool',
tool_name: 'write_file',
tool_input: { path: '/etc/passwd' },
};
const { stdout, exitCode } = await runGeminiHook(input);
expect(exitCode).toBe(0);
expect(stdout).toBe(''); // Ignored, not blocked
});
});
describe('output format', () => {
test('outputs Gemini format with decision: deny', async () => {
const input = {
hook_event_name: 'BeforeTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git reset --hard' },
};
const { stdout, exitCode } = await runGeminiHook(input);
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty('decision', 'deny');
expect(output).toHaveProperty('reason');
expect(output.reason).toContain('git reset --hard');
});
});
});

View File

@@ -0,0 +1,358 @@
import { describe, expect, test } from 'bun:test';
import {
type CommandRunner,
formatReleaseNotes,
generateChangelog,
getContributors,
getContributorsForRepo,
getLatestReleasedTag,
isIncludedCommit,
runChangelog,
} from '../scripts/generate-changelog';
type RunnerResponse = string | (() => string) | (() => Promise<string>);
function createRunner(responses: Record<string, RunnerResponse>): CommandRunner {
return (strings, ...values) => {
const command = strings.reduce(
(acc, part, index) => `${acc}${part}${String(values[index] ?? '')}`,
'',
);
return {
text: async () => {
const response = responses[command];
if (response === undefined) {
throw new Error(`Unexpected command: ${command}`);
}
if (typeof response === 'function') {
return await response();
}
return response;
},
};
};
}
describe('isIncludedCommit', () => {
describe('simple prefixes', () => {
test('includes feat: commits', () => {
expect(isIncludedCommit('feat: add new feature')).toBe(true);
});
test('includes fix: commits', () => {
expect(isIncludedCommit('fix: resolve bug')).toBe(true);
});
test('excludes chore: commits', () => {
expect(isIncludedCommit('chore: update deps')).toBe(false);
});
test('excludes docs: commits', () => {
expect(isIncludedCommit('docs: update readme')).toBe(false);
});
});
describe('scoped prefixes', () => {
test('includes feat(scope): commits', () => {
expect(isIncludedCommit('feat(api): add endpoint')).toBe(true);
});
test('includes fix(scope): commits', () => {
expect(isIncludedCommit('fix(commands): resolve issue')).toBe(true);
});
test('includes feat(multi-word): commits', () => {
expect(isIncludedCommit('feat(user-auth): add login')).toBe(true);
});
test('excludes chore(scope): commits', () => {
expect(isIncludedCommit('chore(deps): update')).toBe(false);
});
test('excludes docs(scope): commits', () => {
expect(isIncludedCommit('docs(readme): update')).toBe(false);
});
});
describe('with git hash prefix', () => {
test('includes abc1234 feat: commits', () => {
expect(isIncludedCommit('abc1234 feat: add feature')).toBe(true);
});
test('includes abc1234 fix(scope): commits', () => {
expect(isIncludedCommit('abc1234 fix(commands): fix bug')).toBe(true);
});
test('excludes abc1234 chore: commits', () => {
expect(isIncludedCommit('abc1234 chore: update')).toBe(false);
});
});
describe('case insensitivity', () => {
test('includes FEAT: commits', () => {
expect(isIncludedCommit('FEAT: add feature')).toBe(true);
});
test('includes FIX(scope): commits', () => {
expect(isIncludedCommit('FIX(commands): fix bug')).toBe(true);
});
});
});
describe('getLatestReleasedTag', () => {
test('returns latest tag', async () => {
const runner = createRunner({
"gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty'":
'v1.2.3\n',
});
await expect(getLatestReleasedTag(runner)).resolves.toBe('v1.2.3');
});
test('returns null on failure', async () => {
const runner = createRunner({});
await expect(getLatestReleasedTag(runner)).resolves.toBeNull();
});
});
describe('formatReleaseNotes', () => {
test('renders sections and contributors', () => {
const notes = formatReleaseNotes(
{
core: ['- abc123 feat: core change'],
claudeCode: ['- def456 fix(commands): adjust'],
openCode: ['- ghi789 fix(opencode): tweak'],
},
['', '**Thank you to 1 community contributor:**', '- @alice:', ' - feat: add thing'],
);
expect(notes).toEqual([
'## Core',
'- abc123 feat: core change',
'',
'## Claude Code',
'- def456 fix(commands): adjust',
'',
'## OpenCode',
'- ghi789 fix(opencode): tweak',
'',
'**Thank you to 1 community contributor:**',
'- @alice:',
' - feat: add thing',
]);
});
test('renders empty sections without contributors', () => {
const notes = formatReleaseNotes({ core: [], claudeCode: [], openCode: [] }, []);
expect(notes).toEqual([
'## Core',
'No changes in this release',
'',
'## Claude Code',
'No changes in this release',
'',
'## OpenCode',
'No changes in this release',
]);
});
});
describe('generateChangelog', () => {
test('categorizes commits by changed files', async () => {
const runner = createRunner({
'git log v1.0.0..HEAD --oneline --format="%h %s"': [
'abc123 feat: core change',
'bcd234 fix(commands): adjust',
'cde345 fix(opencode): tweak',
'eee111 feat: missing files',
'fff222 chore: skip',
].join('\n'),
'git diff-tree --no-commit-id --name-only -r abc123': 'src/core/analyze.ts\n',
'git diff-tree --no-commit-id --name-only -r bcd234': 'commands/example.json\n',
'git diff-tree --no-commit-id --name-only -r cde345': '.opencode/config.json\n',
'git diff-tree --no-commit-id --name-only -r eee111': () => {
throw new Error('boom');
},
});
const changelog = await generateChangelog('v1.0.0', runner);
expect(changelog).toEqual({
core: ['- abc123 feat: core change', '- eee111 feat: missing files'],
claudeCode: ['- bcd234 fix(commands): adjust'],
openCode: ['- cde345 fix(opencode): tweak'],
});
});
test('returns empty categories when git log fails', async () => {
const runner = createRunner({});
const changelog = await generateChangelog('v1.0.0', runner);
expect(changelog).toEqual({
core: [],
claudeCode: [],
openCode: [],
});
});
});
describe('getContributorsForRepo', () => {
test('includes unique contributors and their commits', async () => {
const compare = [
JSON.stringify({
login: 'alice',
message: 'feat: add thing\n\nBody',
}),
JSON.stringify({
login: 'bob',
message: 'fix: resolve issue',
}),
JSON.stringify({
login: 'alice',
message: 'feat: follow-up',
}),
JSON.stringify({
login: 'kenryu42',
message: 'feat: excluded author',
}),
JSON.stringify({
login: null,
message: 'feat: missing author',
}),
JSON.stringify({
login: 'carol',
message: 'chore: ignore',
}),
].join('\n');
const runner = createRunner({
'gh api "/repos/example/repo/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'':
compare,
});
const notes = await getContributorsForRepo('v1.0.0', 'example/repo', runner);
expect(notes).toEqual([
'',
'**Thank you to 2 community contributors:**',
'- @alice:',
' - feat: add thing',
' - feat: follow-up',
'- @bob:',
' - fix: resolve issue',
]);
});
test('returns empty list when no contributors qualify', async () => {
const compare = [
JSON.stringify({
login: 'kenryu42',
message: 'feat: excluded author',
}),
JSON.stringify({
login: 'carol',
message: 'chore: ignore',
}),
].join('\n');
const runner = createRunner({
'gh api "/repos/example/repo/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'':
compare,
});
const notes = await getContributorsForRepo('v1.0.0', 'example/repo', runner);
expect(notes).toEqual([]);
});
test('returns empty list on command failure', async () => {
const runner = createRunner({});
const notes = await getContributorsForRepo('v1.0.0', 'example/repo', runner);
expect(notes).toEqual([]);
});
});
describe('getContributors', () => {
test('uses default repo wrapper', async () => {
const runner = createRunner({
'gh api "/repos/kenryu42/claude-code-safety-net/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'':
JSON.stringify({
login: 'alice',
message: 'feat: add thing',
}),
});
const notes = await getContributors('v1.0.0', runner);
expect(notes).toEqual([
'',
'**Thank you to 1 community contributor:**',
'- @alice:',
' - feat: add thing',
]);
});
});
describe('runChangelog', () => {
test('prints initial release when no tag exists', async () => {
const runner = createRunner({
"gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty'":
'\n',
});
const logs: string[] = [];
await runChangelog({
runner,
log: (message) => {
logs.push(message);
},
});
expect(logs).toEqual(['Initial release']);
});
test('prints changelog and contributors for tagged releases', async () => {
const compare = JSON.stringify({
login: 'alice',
message: 'feat: add thing',
});
const runner = createRunner({
"gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty'":
'v1.0.0\n',
'git log v1.0.0..HEAD --oneline --format="%h %s"': 'abc123 feat: core change',
'git diff-tree --no-commit-id --name-only -r abc123': 'src/core/analyze.ts\n',
'gh api "/repos/kenryu42/claude-code-safety-net/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'':
compare,
});
const logs: string[] = [];
await runChangelog({
runner,
log: (message) => {
logs.push(message);
},
});
expect(logs).toEqual([
[
'## Core',
'- abc123 feat: core change',
'',
'## Claude Code',
'No changes in this release',
'',
'## OpenCode',
'No changes in this release',
'',
'**Thank you to 1 community contributor:**',
'- @alice:',
' - feat: add thing',
].join('\n'),
]);
});
});

View File

@@ -0,0 +1,63 @@
import { expect } from 'bun:test';
import { analyzeCommand } from '../src/core/analyze.ts';
import { loadConfig } from '../src/core/config.ts';
import type { AnalyzeOptions, Config } from '../src/types.ts';
function envTruthy(name: string): boolean {
const val = process.env[name];
return val === '1' || val === 'true' || val === 'yes';
}
// Default empty config for tests that don't specify a cwd
// This prevents loading the project's .safety-net.json
const DEFAULT_TEST_CONFIG: Config = { version: 1, rules: [] };
function getOptionsFromEnv(cwd?: string, config?: Config): AnalyzeOptions {
// If no cwd specified, use empty config to avoid loading project's config
const effectiveConfig = config ?? (cwd ? loadConfig(cwd) : DEFAULT_TEST_CONFIG);
return {
cwd,
config: effectiveConfig,
strict: envTruthy('SAFETY_NET_STRICT'),
paranoidRm: envTruthy('SAFETY_NET_PARANOID') || envTruthy('SAFETY_NET_PARANOID_RM'),
paranoidInterpreters:
envTruthy('SAFETY_NET_PARANOID') || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'),
};
}
export function assertBlocked(command: string, reasonContains: string, cwd?: string): void {
const options = getOptionsFromEnv(cwd);
const result = analyzeCommand(command, options);
expect(result).not.toBeNull();
expect(result?.reason).toContain(reasonContains);
}
export function assertAllowed(command: string, cwd?: string): void {
const options = getOptionsFromEnv(cwd);
const result = analyzeCommand(command, options);
expect(result).toBeNull();
}
export function runGuard(command: string, cwd?: string, config?: Config): string | null {
const options = getOptionsFromEnv(cwd, config);
return analyzeCommand(command, options)?.reason ?? null;
}
export function withEnv<T>(env: Record<string, string>, fn: () => T): T {
const original: Record<string, string | undefined> = {};
for (const key of Object.keys(env)) {
original[key] = process.env[key];
process.env[key] = env[key];
}
try {
return fn();
} finally {
for (const key of Object.keys(env)) {
if (original[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = original[key];
}
}
}
}

View File

@@ -0,0 +1,583 @@
/**
* Targeted unit tests for helper parsers in the safety net.
*
* These focus on option-scanning branches that are hard to hit via end-to-end
* command strings, improving confidence (and coverage) of the parsing logic.
*/
import { describe, expect, test } from 'bun:test';
import { dangerousInText } from '../src/core/analyze/dangerous-text.ts';
import { extractDashCArg } from '../src/core/analyze/shell-wrappers.ts';
import {
_extractParallelChildCommand,
_extractXargsChildCommand,
_findHasDelete,
_hasRecursiveForceFlags,
} from '../src/core/analyze.ts';
import { _extractGitSubcommandAndRest, _getCheckoutPositionalArgs } from '../src/core/rules-git.ts';
import { extractShortOpts, splitShellCommands, stripWrappersWithInfo } from '../src/core/shell.ts';
import { MAX_STRIP_ITERATIONS } from '../src/types.ts';
describe('shell parsing helpers', () => {
describe('extractDashCArg', () => {
test('returns null for empty tokens', () => {
expect(extractDashCArg([])).toBeNull();
});
test('returns null for single token', () => {
expect(extractDashCArg(['bash'])).toBeNull();
});
test('extracts arg after standalone -c', () => {
expect(extractDashCArg(['bash', '-c', 'echo ok'])).toBe('echo ok');
});
test('extracts arg after bundled -lc', () => {
expect(extractDashCArg(['bash', '-lc', 'echo ok'])).toBe('echo ok');
});
test('extracts arg after bundled -xc', () => {
expect(extractDashCArg(['sh', '-xc', 'rm -rf /'])).toBe('rm -rf /');
});
test('returns null when -c has no following arg', () => {
expect(extractDashCArg(['bash', '-c'])).toBeNull();
});
test('returns null when bundled option has no following arg', () => {
expect(extractDashCArg(['bash', '-lc'])).toBeNull();
});
test('handles -- separator before -c (implementation scans past it)', () => {
expect(extractDashCArg(['bash', '--', '-c', 'echo'])).toBe('echo');
});
test('ignores long options starting with --', () => {
expect(extractDashCArg(['bash', '--rcfile', 'script'])).toBeNull();
});
test('returns null when next token starts with dash', () => {
expect(extractDashCArg(['bash', '-lc', '-x'])).toBeNull();
});
test('handles -c appearing later in tokens', () => {
expect(extractDashCArg(['bash', '-l', '-c', 'echo ok'])).toBe('echo ok');
});
});
describe('extractShortOpts', () => {
test('stops at double dash', () => {
// given: tokens with -Ap after -- (a filename, not options)
// when: extracting short options
// then: A and p should NOT be in the result
expect(extractShortOpts(['git', 'add', '--', '-Ap'])).toEqual(new Set());
expect(extractShortOpts(['rm', '-r', '--', '-f'])).toEqual(new Set(['-r']));
});
test('extracts before double dash', () => {
// given: tokens with options before --
// when: extracting short options
// then: only options before -- are extracted
expect(extractShortOpts(['git', '-v', 'add', '-n', '--', '-x'])).toEqual(
new Set(['-v', '-n']),
);
});
});
describe('splitShellCommands', () => {
test('returns whole command when quotes are unclosed', () => {
expect(splitShellCommands('echo "unterminated')).toEqual([['echo "unterminated']]);
});
test('extracts arithmetic substitution segments (nested parens)', () => {
expect(splitShellCommands('echo $((1+2))')).toEqual([['echo'], ['1+2']]);
});
test('extracts backtick substitution segments', () => {
expect(splitShellCommands('echo `date`')).toEqual([['date'], ['echo', '`date`']]);
});
test('extracts $() substitution segments split on operators', () => {
expect(splitShellCommands('echo $(rm -rf /tmp/x && echo ok)')).toEqual([
['echo'],
['rm', '-rf', '/tmp/x'],
['echo', 'ok'],
]);
});
test('extracts multiple backtick substitutions from one token', () => {
expect(splitShellCommands('echo `a`:`b`')).toEqual([['a'], ['b'], ['echo', '`a`:`b`']]);
});
test('handles nested $(...) with operators', () => {
const result = splitShellCommands('echo $(echo $(rm -rf /tmp/x))');
expect(result.length).toBeGreaterThan(1);
const flat = result.flat();
expect(flat).toContain('rm');
expect(flat).toContain('-rf');
});
test('handles deeply nested $(...) substitutions', () => {
const result = splitShellCommands('echo $(a $(b $(c)))');
expect(result.length).toBeGreaterThan(1);
});
test('handles $(...) with semicolon operators', () => {
expect(splitShellCommands('echo $(cd /tmp; rm -rf .)')).toEqual([
['echo'],
['cd', '/tmp'],
['rm', '-rf', '.'],
]);
});
test('handles $(...) with pipe operators', () => {
expect(splitShellCommands('echo $(cat file | rm -rf /)')).toEqual([
['echo'],
['cat', 'file'],
['rm', '-rf', '/'],
]);
});
test('handles unterminated $() substitution (no hang, still extracts tokens)', () => {
expect(splitShellCommands('echo $(rm -rf /tmp/x')).toEqual([
['echo'],
['rm', '-rf', '/tmp/x'],
]);
});
});
describe('stripWrappersWithInfo', () => {
test('strips sudo options that consume a value', () => {
const result = stripWrappersWithInfo(['sudo', '-u', 'root', 'rm', '-rf', '/tmp/a']);
expect(result.tokens).toEqual(['rm', '-rf', '/tmp/a']);
});
test('strips env -C=...', () => {
const result = stripWrappersWithInfo(['env', '-C=/tmp', 'rm', '-rf']);
expect(result.tokens).toEqual(['rm', '-rf']);
});
test('strips command -pv and -- separator', () => {
const result = stripWrappersWithInfo(['command', '-pv', '--', 'git', 'status']);
expect(result.tokens).toEqual(['git', 'status']);
});
test('captures env assignments after hitting max strip iterations', () => {
const tokens = Array.from({ length: MAX_STRIP_ITERATIONS }, () => 'sudo');
tokens.push('FOO=bar', 'rm', '-rf');
const result = stripWrappersWithInfo(tokens);
expect(result.tokens).toEqual(['rm', '-rf']);
expect(result.envAssignments.get('FOO')).toBe('bar');
});
test('strips nested wrappers across iterations and preserves env assignments', () => {
const result = stripWrappersWithInfo([
'sudo',
'env',
'FOO=1',
'sudo',
'command',
'--',
'rm',
'-rf',
'/tmp/a',
]);
expect(result.tokens).toEqual(['rm', '-rf', '/tmp/a']);
expect(result.envAssignments.get('FOO')).toBe('1');
});
test("drops leading tokens containing '=' that are not NAME=value assignments", () => {
// Intentionally conservative: only strict NAME=value is treated as an env assignment.
// Shell-legal forms like NAME+=value are dropped to reach the real command head.
const result = stripWrappersWithInfo(['FOO+=bar', 'rm', '-rf']);
expect(result.tokens).toEqual(['rm', '-rf']);
expect(result.envAssignments.get('FOO')).toBeUndefined();
});
});
});
describe('rm parsing helpers', () => {
describe('hasRecursiveForceFlags', () => {
test('empty tokens returns false', () => {
expect(_hasRecursiveForceFlags([])).toBe(false);
});
test('stops at double dash', () => {
// -f after `--` is a positional arg, not an option.
expect(_hasRecursiveForceFlags(['rm', '-r', '--', '-f'])).toBe(false);
});
test('detects -rf combined', () => {
expect(_hasRecursiveForceFlags(['rm', '-rf', 'foo'])).toBe(true);
});
test('detects -r -f separate', () => {
expect(_hasRecursiveForceFlags(['rm', '-r', '-f', 'foo'])).toBe(true);
});
test('detects --recursive --force', () => {
expect(_hasRecursiveForceFlags(['rm', '--recursive', '--force', 'foo'])).toBe(true);
});
});
});
describe('find parsing helpers', () => {
describe('findHasDelete', () => {
test('exec without terminator ignored', () => {
// Un-terminated -exec should not cause a false positive on -delete.
expect(_findHasDelete(['-exec', 'echo', '-delete'])).toBe(false);
});
test('skips undefined tokens', () => {
// biome-ignore lint/suspicious/noExplicitAny: intentionally testing malformed input
expect(_findHasDelete([undefined as any, '-delete'] as any)).toBe(true);
});
test('delete outside exec detected', () => {
expect(_findHasDelete(['-name', '*.txt', '-delete'])).toBe(true);
});
test('delete inside exec not detected', () => {
expect(_findHasDelete(['-exec', 'rm', '-delete', ';', '-print'])).toBe(false);
});
test('options that consume a value treat -delete as an argument', () => {
const consumingValue = [
'-name',
'-iname',
'-path',
'-ipath',
'-regex',
'-iregex',
'-type',
'-user',
'-group',
'-perm',
'-size',
'-mtime',
'-ctime',
'-atime',
'-newer',
'-printf',
'-fprint',
'-fprintf',
] as const;
for (const opt of consumingValue) {
expect(_findHasDelete([opt, '-delete'])).toBe(false);
expect(_findHasDelete([opt, '-delete', '-delete'])).toBe(true);
}
});
});
});
describe('dangerousInText', () => {
test('detects rm -rf variants', () => {
expect(dangerousInText('rm -rf /tmp/x')).toBe('rm -rf');
expect(dangerousInText('rm -R -f /tmp/x')).toBe('rm -rf');
expect(dangerousInText('rm -fr /tmp/x')).toBe('rm -rf');
expect(dangerousInText('rm -f -r /tmp/x')).toBe('rm -rf');
});
test('detects with leading whitespace (trimStart)', () => {
expect(dangerousInText(' rm -rf /tmp/x')).toBe('rm -rf');
});
test('detects key git patterns', () => {
expect(dangerousInText('git reset --hard')).toBe('git reset --hard');
expect(dangerousInText('git clean -f')).toBe('git clean -f');
});
test('skips find -delete when text starts with echo/rg', () => {
expect(dangerousInText('echo "find . -delete')).toBeNull();
expect(dangerousInText('rg "find . -delete')).toBeNull();
});
});
describe('xargs parsing helpers', () => {
describe('extractXargsChildCommand', () => {
test('none when child unspecified', () => {
expect(_extractXargsChildCommand(['xargs'])).toEqual([]);
});
test('double dash starts child', () => {
expect(_extractXargsChildCommand(['xargs', '--', 'rm', '-rf'])).toEqual(['rm', '-rf']);
});
test('long option consumes value', () => {
expect(_extractXargsChildCommand(['xargs', '--max-args', '5', 'rm', '-rf'])).toEqual([
'rm',
'-rf',
]);
});
test('long option equals form', () => {
expect(_extractXargsChildCommand(['xargs', '--max-args=5', 'rm'])).toEqual(['rm']);
});
test('short option attached form', () => {
expect(_extractXargsChildCommand(['xargs', '-n1', 'rm'])).toEqual(['rm']);
});
test('dash i does not consume child', () => {
expect(_extractXargsChildCommand(['xargs', '-i', 'rm', '-rf'])).toEqual(['rm', '-rf']);
});
test('more attached forms', () => {
const cases: Array<[string[], string[]]> = [
[['xargs', '-P4', 'rm'], ['rm']],
[['xargs', '-L2', 'rm'], ['rm']],
[['xargs', '-n1', 'rm'], ['rm']],
];
for (const [tokens, expected] of cases) {
expect(_extractXargsChildCommand(tokens)).toEqual(expected);
}
});
});
});
describe('parallel parsing helpers', () => {
describe('extractParallelChildCommand', () => {
test('returns empty when ::: is first token after parallel', () => {
// When ::: is the first token after parallel (and options),
// it returns empty because args follow :::
expect(_extractParallelChildCommand(['parallel', ':::'])).toEqual([]);
});
test('extracts command with -- separator', () => {
expect(_extractParallelChildCommand(['parallel', '--', 'rm', '-rf'])).toEqual(['rm', '-rf']);
});
test('returns command and all following tokens', () => {
// The function returns all tokens starting from the first non-option
expect(_extractParallelChildCommand(['parallel', 'rm', '-rf'])).toEqual(['rm', '-rf']);
});
test('returns command including ::: marker when command comes first', () => {
// If command tokens appear before :::, all of them are returned
expect(_extractParallelChildCommand(['parallel', 'rm', '-rf', ':::', '/'])).toEqual([
'rm',
'-rf',
':::',
'/',
]);
});
test('consumes options', () => {
expect(_extractParallelChildCommand(['parallel', '-j4', '--', 'rm', '-rf'])).toEqual([
'rm',
'-rf',
]);
});
test('consumes --option=value', () => {
expect(_extractParallelChildCommand(['parallel', '--foo=bar', 'rm', '-rf'])).toEqual([
'rm',
'-rf',
]);
});
test('consumes options that take a value', () => {
expect(_extractParallelChildCommand(['parallel', '-S', 'sshlogin', 'rm', '-rf'])).toEqual([
'rm',
'-rf',
]);
});
test('consumes -j value form', () => {
expect(_extractParallelChildCommand(['parallel', '-j', '4', 'rm', '-rf'])).toEqual([
'rm',
'-rf',
]);
});
test('skips unknown short option', () => {
expect(_extractParallelChildCommand(['parallel', '-X', 'rm', '-rf'])).toEqual(['rm', '-rf']);
});
test('empty for just parallel', () => {
expect(_extractParallelChildCommand(['parallel'])).toEqual([]);
});
});
});
describe('git rules helpers', () => {
describe('extractGitSubcommandAndRest', () => {
test('git only returns null subcommand', () => {
const result = _extractGitSubcommandAndRest(['git']);
expect(result.subcommand).toBeNull();
expect(result.rest).toEqual([]);
});
test('non git returns null subcommand', () => {
const result = _extractGitSubcommandAndRest(['echo', 'ok']);
expect(result.subcommand).toBeNull();
expect(result.rest).toEqual([]);
});
test('unknown short option skipped', () => {
const result = _extractGitSubcommandAndRest(['git', '-x', 'reset', '--hard']);
expect(result.subcommand).toBe('reset');
expect(result.rest).toEqual(['--hard']);
});
test('unknown long option equals skipped', () => {
const result = _extractGitSubcommandAndRest(['git', '--unknown=1', 'reset', '--hard']);
expect(result.subcommand).toBe('reset');
expect(result.rest).toEqual(['--hard']);
});
test('opts with value separate consumed', () => {
const result = _extractGitSubcommandAndRest(['git', '-c', 'foo=bar', 'reset']);
expect(result.subcommand).toBe('reset');
expect(result.rest).toEqual([]);
});
test('double dash can introduce subcommand', () => {
const result = _extractGitSubcommandAndRest(['git', '--', 'reset', '--hard']);
expect(result.subcommand).toBe('reset');
expect(result.rest).toEqual(['--hard']);
});
test('double dash without a subcommand yields null', () => {
const result = _extractGitSubcommandAndRest(['git', '--', '--help']);
expect(result.subcommand).toBeNull();
expect(result.rest).toEqual(['--help']);
});
test('attached -C consumes itself', () => {
const result = _extractGitSubcommandAndRest(['git', '-C/tmp', 'reset', '--hard']);
expect(result.subcommand).toBe('reset');
expect(result.rest).toEqual(['--hard']);
});
});
describe('getCheckoutPositionalArgs', () => {
test('attached short opts ignored', () => {
expect(_getCheckoutPositionalArgs(['-bnew', 'main', 'file.txt'])).toEqual([
'main',
'file.txt',
]);
expect(_getCheckoutPositionalArgs(['-U3', 'main'])).toEqual(['main']);
});
test('long equals ignored', () => {
expect(_getCheckoutPositionalArgs(['--pathspec-from-file=paths.txt', 'main'])).toEqual([
'main',
]);
});
test('double dash breaks', () => {
expect(_getCheckoutPositionalArgs(['--', 'file.txt'])).toEqual([]);
});
test('options with value consumed', () => {
expect(_getCheckoutPositionalArgs(['-b', 'new', 'main'])).toEqual(['main']);
});
test('unknown long option consumes value', () => {
expect(_getCheckoutPositionalArgs(['--unknown', 'main', 'file.txt'])).toEqual(['file.txt']);
});
test('unknown short option skipped', () => {
expect(_getCheckoutPositionalArgs(['-x', 'main'])).toEqual(['main']);
});
test('optional value options recurse-submodules', () => {
expect(_getCheckoutPositionalArgs(['--recurse-submodules', 'main'])).toEqual(['main']);
expect(_getCheckoutPositionalArgs(['--recurse-submodules=on-demand', 'main'])).toEqual([
'main',
]);
});
test('optional value options track', () => {
expect(_getCheckoutPositionalArgs(['--track', 'main'])).toEqual(['main']);
expect(_getCheckoutPositionalArgs(['--track=direct', 'main'])).toEqual(['main']);
});
});
});
describe('cwd tracking helpers', () => {
const { _segmentChangesCwd } = require('../src/core/analyze.ts');
test('cd returns true', () => {
expect(_segmentChangesCwd(['cd', '..'])).toBe(true);
});
test('pushd returns true', () => {
expect(_segmentChangesCwd(['pushd', '/tmp'])).toBe(true);
});
test('popd returns true', () => {
expect(_segmentChangesCwd(['popd'])).toBe(true);
});
test('builtin cd returns true', () => {
expect(_segmentChangesCwd(['builtin', 'cd', '..'])).toBe(true);
});
test('builtin only returns false', () => {
expect(_segmentChangesCwd(['builtin'])).toBe(false);
});
test('grouped cd returns true', () => {
expect(_segmentChangesCwd(['{', 'cd', '..', ';', '}'])).toBe(true);
});
test('subshell cd returns true', () => {
expect(_segmentChangesCwd(['(', 'cd', '..', ')'])).toBe(true);
});
test('command substitution cd returns true', () => {
expect(_segmentChangesCwd(['$(', 'cd', '..', ')'])).toBe(true);
});
test('regex fallback on unparseable', () => {
expect(_segmentChangesCwd(['cd', "'unterminated"])).toBe(true);
});
test('non-cd command returns false', () => {
expect(_segmentChangesCwd(['ls', '-la'])).toBe(false);
});
});
describe('xargs parsing helpers', () => {
const { _extractXargsChildCommandWithInfo } = require('../src/core/analyze.ts');
test('replacement token from -I option', () => {
const result = _extractXargsChildCommandWithInfo(['xargs', '-I', '{}', 'rm', '-rf', '{}']);
expect(result.replacementToken).toBe('{}');
});
test('replacement token from -I attached', () => {
const result = _extractXargsChildCommandWithInfo(['xargs', '-I%', 'rm', '-rf', '%']);
expect(result.replacementToken).toBe('%');
});
test('replacement token from --replace defaults to braces', () => {
const result = _extractXargsChildCommandWithInfo(['xargs', '--replace', 'rm', '-rf', '{}']);
expect(result.replacementToken).toBe('{}');
});
test('replacement token from --replace= empty defaults to braces', () => {
const result = _extractXargsChildCommandWithInfo(['xargs', '--replace=', 'rm', '-rf', '{}']);
expect(result.replacementToken).toBe('{}');
});
test('replacement token from --replace=CUSTOM', () => {
const result = _extractXargsChildCommandWithInfo([
'xargs',
'--replace=FOO',
'rm',
'-rf',
'FOO',
]);
expect(result.replacementToken).toBe('FOO');
});
test('no replacement token when not specified', () => {
const result = _extractXargsChildCommandWithInfo(['xargs', 'rm', '-rf']);
expect(result.replacementToken).toBeNull();
});
});

View File

@@ -0,0 +1,450 @@
import { describe, test } from 'bun:test';
import { assertAllowed, assertBlocked } from './helpers.ts';
describe('git checkout', () => {
test('git checkout -- blocked', () => {
assertBlocked('git checkout -- file.txt', 'git checkout --');
});
test('git checkout -- multiple files blocked', () => {
assertBlocked('git checkout -- file1.txt file2.txt', 'git checkout --');
});
test('git checkout -- . blocked', () => {
assertBlocked('git checkout -- .', 'git checkout --');
});
test('git checkout ref -- blocked', () => {
assertBlocked('git checkout HEAD -- file.txt', 'git checkout <ref> -- <path>');
});
test('git checkout -b allowed', () => {
assertAllowed('git checkout -b new-branch');
});
test('git checkout --orphan allowed', () => {
assertAllowed('git checkout --orphan orphan-branch');
});
test('git checkout -bnew-branch allowed', () => {
assertAllowed('git checkout -bnew-branch');
});
test('git checkout -Bnew-branch allowed', () => {
assertAllowed('git checkout -Bnew-branch');
});
test('git checkout ref pathspec blocked', () => {
assertBlocked('git checkout HEAD file.txt', 'multiple positional args');
});
test('git checkout ref multiple pathspecs blocked', () => {
assertBlocked('git checkout main a.txt b.txt', 'multiple positional args');
});
test('git checkout branch only allowed', () => {
assertAllowed('git checkout main');
});
test('git checkout -U3 main allowed', () => {
assertAllowed('git checkout -U3 main');
});
test('git checkout - allowed', () => {
assertAllowed('git checkout -');
});
test('git checkout --detach allowed', () => {
assertAllowed('git checkout --detach main');
});
test('git checkout --recurse-submodules allowed', () => {
assertAllowed('git checkout --recurse-submodules main');
});
test('git checkout --pathspec-from-file blocked', () => {
assertBlocked(
'git checkout HEAD --pathspec-from-file=paths.txt',
'git checkout --pathspec-from-file',
);
});
test('git checkout ref pathspec from file arg blocked', () => {
assertBlocked(
'git checkout HEAD --pathspec-from-file paths.txt',
'git checkout --pathspec-from-file',
);
});
test('git checkout --conflict=merge allowed', () => {
assertAllowed('git checkout --conflict=merge main');
});
test('git checkout --conflict merge allowed', () => {
assertAllowed('git checkout --conflict merge main');
});
test('git checkout -q ref pathspec blocked', () => {
assertBlocked('git checkout -q main file.txt', 'multiple positional args');
});
test('git checkout --recurse-submodules=checkout allowed', () => {
assertAllowed('git checkout --recurse-submodules=checkout main');
});
test('git checkout --recurse-submodules=on-demand allowed', () => {
assertAllowed('git checkout --recurse-submodules=on-demand main');
});
test('git checkout --recurse-submodules ref pathspec blocked', () => {
assertBlocked('git checkout --recurse-submodules main file.txt', 'multiple positional args');
});
test('git checkout --recurse-submodules without mode allowed', () => {
assertAllowed('git checkout --recurse-submodules main');
});
test('git checkout --recurse-submodules without mode ref pathspec blocked', () => {
assertBlocked('git checkout --recurse-submodules main file.txt', 'multiple positional args');
});
test('git checkout --track=direct allowed', () => {
assertAllowed('git checkout --track=direct main');
});
test('git checkout --track=inherit allowed', () => {
assertAllowed('git checkout --track=inherit main');
});
test('git checkout --track without mode ref pathspec blocked', () => {
assertBlocked('git checkout --track main file.txt', 'multiple positional args');
});
test('git checkout --unified 3 allowed', () => {
assertAllowed('git checkout --unified 3 main');
});
test('git checkout -U attached value allowed', () => {
assertAllowed('git checkout -U3 main');
});
test('git checkout unknown long option consumes value allowed', () => {
assertAllowed('git checkout --unknown main file.txt');
});
test('git checkout unknown long option does not consume option value allowed', () => {
assertAllowed('git checkout --unknown -q main');
});
test('git checkout unknown long option equals allowed', () => {
assertAllowed('git checkout --unknown=value main');
});
});
describe('git restore', () => {
test('git restore file blocked', () => {
assertBlocked('git restore file.txt', 'git restore');
});
test('git restore multiple files blocked', () => {
assertBlocked('git restore a.txt b.txt', 'git restore');
});
test('git restore --worktree blocked', () => {
assertBlocked('git restore --worktree file.txt', 'git restore --worktree');
});
test('git restore --staged allowed', () => {
assertAllowed('git restore --staged file.txt');
});
test('git restore --staged . allowed', () => {
assertAllowed('git restore --staged .');
});
test('git restore --help allowed', () => {
assertAllowed('git restore --help');
});
});
describe('git reset', () => {
test('git reset --hard blocked', () => {
assertBlocked('git reset --hard', 'git reset --hard');
});
test('git reset --hard HEAD~1 blocked', () => {
assertBlocked('git reset --hard HEAD~1', 'git reset --hard');
});
test('git reset -q --hard blocked', () => {
assertBlocked('git reset -q --hard', 'git reset --hard');
});
test('echo ok | git reset --hard blocked', () => {
assertBlocked('echo ok | git reset --hard', 'git reset --hard');
});
test('git -C repo reset --hard blocked', () => {
assertBlocked('git -C repo reset --hard', 'git reset --hard');
});
test('git -Crepo reset --hard blocked', () => {
assertBlocked('git -Crepo reset --hard', 'git reset --hard');
});
test('git reset --hard global option -C attached blocked', () => {
assertBlocked('git -Crepo reset --hard', 'git reset --hard');
});
test('git --git-dir=repo/.git reset --hard blocked', () => {
assertBlocked('git --git-dir=repo/.git reset --hard', 'git reset --hard');
});
test('git --git-dir repo/.git reset --hard blocked', () => {
assertBlocked('git --git-dir repo/.git reset --hard', 'git reset --hard');
});
test('git --work-tree=repo reset --hard blocked', () => {
assertBlocked('git --work-tree=repo reset --hard', 'git reset --hard');
});
test('git --no-pager reset --hard blocked', () => {
assertBlocked('git --no-pager reset --hard', 'git reset --hard');
});
test('git -c foo=bar reset --hard blocked', () => {
assertBlocked('git -c foo=bar reset --hard', 'git reset --hard');
});
test('git -- reset --hard blocked', () => {
assertBlocked('git -- reset --hard', 'reset --hard');
});
test('git -cfoo=bar reset --hard blocked', () => {
assertBlocked('git -cfoo=bar reset --hard', 'git reset --hard');
});
test('sudo env VAR=1 git reset --hard blocked', () => {
assertBlocked('sudo env VAR=1 git reset --hard', 'git reset --hard');
});
test('env -- git reset --hard blocked', () => {
assertBlocked('env -- git reset --hard', 'git reset --hard');
});
test('command -- git reset --hard blocked', () => {
assertBlocked('command -- git reset --hard', 'git reset --hard');
});
test('env -u PATH git reset --hard blocked', () => {
assertBlocked('env -u PATH git reset --hard', 'git reset --hard');
});
test('git reset --merge blocked', () => {
assertBlocked('git reset --merge', 'git reset --merge');
});
test("sh -c 'git reset --hard' blocked", () => {
assertBlocked("sh -c 'git reset --hard'", 'git reset --hard');
});
});
describe('git clean', () => {
test('git clean -f blocked', () => {
assertBlocked('git clean -f', 'git clean');
});
test('git clean --force blocked', () => {
assertBlocked('git clean --force', 'git clean -f');
});
test('git clean -nf blocked', () => {
assertBlocked('git clean -nf', 'git clean -f');
});
test('git clean -n && git clean -f blocked', () => {
assertBlocked('git clean -n && git clean -f', 'git clean -f');
});
test('git clean -fd blocked', () => {
assertBlocked('git clean -fd', 'git clean');
});
test('git clean -xf blocked', () => {
assertBlocked('git clean -xf', 'git clean');
});
test('git clean -n allowed', () => {
assertAllowed('git clean -n');
});
test('git clean --dry-run allowed', () => {
assertAllowed('git clean --dry-run');
});
test('git clean -nd allowed', () => {
assertAllowed('git clean -nd');
});
});
describe('git push', () => {
test('git push --force blocked', () => {
assertBlocked('git push --force', 'push --force');
});
test('git push --force origin main blocked', () => {
assertBlocked('git push --force origin main', 'push --force');
});
test('git push -f blocked', () => {
assertBlocked('git push -f', 'push --force');
});
test('git push -f origin main blocked', () => {
assertBlocked('git push -f origin main', 'push --force');
});
test('git push --force-with-lease allowed', () => {
assertAllowed('git push --force-with-lease');
});
test('git push --force-with-lease origin main allowed', () => {
assertAllowed('git push --force-with-lease origin main');
});
test('git push --force-with-lease=refs/heads/main allowed', () => {
assertAllowed('git push --force-with-lease=refs/heads/main');
});
test('git push --force --force-with-lease allowed', () => {
assertAllowed('git push --force --force-with-lease');
});
test('git push -f --force-with-lease allowed', () => {
assertAllowed('git push -f --force-with-lease');
});
test('git push origin main allowed', () => {
assertAllowed('git push origin main');
});
});
describe('git worktree', () => {
test('git worktree remove --force blocked', () => {
assertBlocked('git worktree remove --force /tmp/wt', 'git worktree remove --force');
});
test('git worktree remove -f blocked', () => {
assertBlocked('git worktree remove -f /tmp/wt', 'git worktree remove --force');
});
test('git worktree remove without force allowed', () => {
assertAllowed('git worktree remove /tmp/wt');
});
test('git worktree remove -- -f allowed', () => {
assertAllowed('git worktree remove -- -f');
});
});
describe('git branch', () => {
test('git branch -D blocked', () => {
assertBlocked('git branch -D feature', 'git branch -D');
});
test('git branch -Dv blocked', () => {
assertBlocked('git branch -Dv feature', 'git branch -D');
});
test('git branch -d allowed', () => {
assertAllowed('git branch -d feature');
});
});
describe('git stash', () => {
test('git stash drop blocked', () => {
assertBlocked('git stash drop', 'git stash drop');
});
test('git stash drop stash@{0} blocked', () => {
assertBlocked('git stash drop stash@{0}', 'git stash drop');
});
test('git stash clear blocked', () => {
assertBlocked('git stash clear', 'git stash clear');
});
test('git stash allowed', () => {
assertAllowed('git stash');
});
test('git stash list allowed', () => {
assertAllowed('git stash list');
});
test('git stash pop allowed', () => {
assertAllowed('git stash pop');
});
});
describe('safe commands', () => {
test('git allowed', () => {
assertAllowed('git');
});
test('git --help allowed', () => {
assertAllowed('git --help');
});
test('git status allowed', () => {
assertAllowed('git status');
});
test('git -C repo status allowed', () => {
assertAllowed('git -C repo status');
});
test('git status global option -C allowed', () => {
assertAllowed('git -Crepo status');
});
test('sudo env VAR=1 git status allowed', () => {
assertAllowed('sudo env VAR=1 git status');
});
test('git diff allowed', () => {
assertAllowed('git diff');
});
test('git log --oneline -10 allowed', () => {
assertAllowed('git log --oneline -10');
});
test('git add . allowed', () => {
assertAllowed('git add .');
});
test("git commit -m 'test' allowed", () => {
assertAllowed("git commit -m 'test'");
});
test('git pull allowed', () => {
assertAllowed('git pull');
});
test("bash -c 'echo ok' allowed", () => {
assertAllowed("bash -c 'echo ok'");
});
test('python -c "print(\'ok\')" allowed', () => {
assertAllowed('python -c "print(\'ok\')"');
});
test('ls -la allowed', () => {
assertAllowed('ls -la');
});
test('cat file.txt allowed', () => {
assertAllowed('cat file.txt');
});
});

View File

@@ -0,0 +1,454 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { analyzeRm } from '../src/core/rules-rm.ts';
import { assertAllowed, assertBlocked, withEnv } from './helpers.ts';
describe('rm -rf blocked', () => {
test('rm -rf blocked', () => {
assertBlocked('rm -rf /some/path', 'rm -rf');
});
test('rm -Rf blocked', () => {
assertBlocked('rm -Rf /some/path', 'rm -rf');
});
test('rm -R -f blocked', () => {
assertBlocked('rm -R -f /some/path', 'rm -rf');
});
test('rm -rf ~/projects blocked', () => {
assertBlocked('rm -rf ~/projects', 'rm -rf');
});
test('rm -fr blocked', () => {
assertBlocked('rm -fr /some/path', 'rm -rf');
});
test('true & rm -rf blocked', () => {
assertBlocked('true & rm -rf /some/path', 'rm -rf');
});
test('rm -rf /tmp/../Users/some/path blocked', () => {
assertBlocked('rm -rf /tmp/../Users/some/path', 'rm -rf');
});
test('/bin/rm -rf blocked', () => {
assertBlocked('/bin/rm -rf /some/path', 'rm -rf');
});
test('busybox rm -rf blocked', () => {
assertBlocked('busybox rm -rf /some/path', 'rm -rf');
});
test('busybox rm -R -f blocked', () => {
assertBlocked('busybox rm -R -f /some/path', 'rm -rf');
});
test("bash -c 'rm -rf /some/path' blocked", () => {
assertBlocked("bash -c 'rm -rf /some/path'", 'rm -rf');
});
test('python -c rm -rf blocked', () => {
assertBlocked('python -c \'import os; os.system("rm -rf /some/path")\'', 'dangerous');
});
test('echo $(rm -rf /some/path) blocked', () => {
assertBlocked('echo $(rm -rf /some/path)', 'rm -rf');
});
test('TMPDIR=/Users rm -rf $TMPDIR/test-dir blocked', () => {
assertBlocked('TMPDIR=/Users rm -rf $TMPDIR/test-dir', 'rm -rf');
});
test('rm -rf / blocked (root)', () => {
assertBlocked('rm -rf /', 'extremely dangerous');
});
test('rm -rf ~ blocked (home)', () => {
assertBlocked('rm -rf ~', 'extremely dangerous');
});
test('rm -rf -- / blocked', () => {
assertBlocked('rm -rf -- /', 'extremely dangerous');
});
test('rm -rf $TMPDIR/../escape blocked', () => {
assertBlocked('rm -rf $TMPDIR/../escape', 'rm -rf');
});
test('rm -rf `pwd`/escape blocked', () => {
assertBlocked('rm -rf `pwd`/escape', 'rm -rf');
});
test('rm -rf ~someone/escape blocked', () => {
assertBlocked('rm -rf ~someone/escape', 'rm -rf');
});
});
describe('rm -rf allowed', () => {
test('rm -rf /tmp/test-dir allowed', () => {
assertAllowed('rm -rf /tmp/test-dir');
});
test('rm -rf /var/tmp/test-dir allowed', () => {
assertAllowed('rm -rf /var/tmp/test-dir');
});
test('rm -rf $TMPDIR/test-dir allowed', () => {
assertAllowed('rm -rf $TMPDIR/test-dir');
});
test('rm -rf ${TMPDIR}/test-dir allowed', () => {
assertAllowed('rm -rf ${TMPDIR}/test-dir');
});
test('rm -rf "$TMPDIR/test-dir" allowed', () => {
assertAllowed('rm -rf "$TMPDIR/test-dir"');
});
test('rm -rf $TMPDIR allowed', () => {
assertAllowed('rm -rf $TMPDIR');
});
test('rm -rf /tmp allowed', () => {
assertAllowed('rm -rf /tmp');
});
test('rm -r without force allowed', () => {
assertAllowed('rm -r /some/path');
});
test('rm -R without force allowed', () => {
assertAllowed('rm -R /some/path');
});
test('rm -f without recursive allowed', () => {
assertAllowed('rm -f /some/path');
});
test('/bin/rm -rf /tmp/test-dir allowed', () => {
assertAllowed('/bin/rm -rf /tmp/test-dir');
});
test('busybox rm -rf /tmp/test-dir allowed', () => {
assertAllowed('busybox rm -rf /tmp/test-dir');
});
});
describe('rm -rf cwd-aware', () => {
let tmpDir: string;
const setup = () => {
tmpDir = mkdtempSync(join(tmpdir(), 'safety-net-test-'));
};
const cleanup = () => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
};
test('rm -rf relative path in home cwd blocked', () => {
setup();
try {
withEnv({ HOME: tmpDir }, () => {
assertBlocked('rm -rf build', 'rm -rf', tmpDir);
});
} finally {
cleanup();
}
});
test('rm -rf relative path in subdir of home allowed', () => {
setup();
try {
const repo = join(tmpDir, 'repo');
require('node:fs').mkdirSync(repo);
withEnv({ HOME: tmpDir }, () => {
assertAllowed('rm -rf build', repo);
});
} finally {
cleanup();
}
});
test('rm -rf relative path allowed', () => {
setup();
try {
assertAllowed('rm -rf build', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf ./dist allowed', () => {
setup();
try {
assertAllowed('rm -rf ./dist', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf ../other blocked', () => {
setup();
try {
assertBlocked('rm -rf ../other', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf /other/path blocked', () => {
setup();
try {
assertBlocked('rm -rf /other/path', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf absolute inside cwd allowed', () => {
setup();
try {
const inside = join(tmpDir, 'dist');
assertAllowed(`rm -rf ${inside}`, tmpDir);
} finally {
cleanup();
}
});
test('rm -rf . blocked', () => {
setup();
try {
assertBlocked('rm -rf .', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf cwd itself blocked', () => {
setup();
try {
assertBlocked(`rm -rf ${tmpDir}`, 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('cd .. && rm -rf build blocked', () => {
setup();
try {
assertBlocked('cd .. && rm -rf build', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('paranoid rm blocks within cwd', () => {
setup();
try {
withEnv({ SAFETY_NET_PARANOID_RM: '1' }, () => {
assertBlocked('rm -rf build', 'SAFETY_NET_PARANOID', tmpDir);
});
} finally {
cleanup();
}
});
test('global paranoid blocks within cwd', () => {
setup();
try {
withEnv({ SAFETY_NET_PARANOID: '1' }, () => {
assertBlocked('rm -rf build', 'SAFETY_NET_PARANOID', tmpDir);
});
} finally {
cleanup();
}
});
test('rm -rf after builtin cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('builtin cd .. && rm -rf build', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf after command substitution cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('$( cd ..; rm -rf build )', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf after grouped cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('{ cd ..; rm -rf build; }', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf after safe command substitution cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('$( cd ..; echo ok ) && rm -rf build', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf after safe grouped cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('{ cd ..; echo ok; } && rm -rf build', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf after subshell cd bypasses cwd allowlist blocked', () => {
setup();
try {
assertBlocked('( cd ..; rm -rf build )', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('rm -rf strict mode allows within cwd', () => {
setup();
try {
assertAllowed('rm -rf build', tmpDir);
} finally {
cleanup();
}
});
test('command substitution rm rf blocked', () => {
setup();
try {
assertBlocked('echo $(rm -rf /some/path)', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('TMPDIR assignment not trusted blocked', () => {
setup();
try {
assertBlocked('TMPDIR=/Users rm -rf $TMPDIR/test-dir', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('TMPDIR= empty assignment blocked (expands to /)', () => {
setup();
try {
assertBlocked('TMPDIR= rm -rf $TMPDIR/test-dir', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('TMPDIR=/tmp-malicious blocked (not a real temp path)', () => {
setup();
try {
assertBlocked('TMPDIR=/tmp-malicious rm -rf $TMPDIR/test-dir', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
test('TMPDIR=/tmp/subdir allowed (subpath of /tmp)', () => {
setup();
try {
assertAllowed('TMPDIR=/tmp/subdir rm -rf $TMPDIR/test-dir', tmpDir);
} finally {
cleanup();
}
});
test('TMPDIR=/var/tmp-malicious blocked (not a real temp path)', () => {
setup();
try {
assertBlocked('TMPDIR=/var/tmp-malicious rm -rf $TMPDIR/test-dir', 'rm -rf', tmpDir);
} finally {
cleanup();
}
});
});
describe('analyzeRm (unit)', () => {
test('does not treat flags after -- as rm -rf', () => {
expect(analyzeRm(['rm', '--', '-rf', '/'], { cwd: '/tmp' })).toBeNull();
});
test('blocks $HOME targets', () => {
expect(analyzeRm(['rm', '-rf', '$HOME/*'], { cwd: '/tmp' })).toContain('extremely dangerous');
});
test('blocks ${HOME} targets', () => {
expect(analyzeRm(['rm', '-rf', '${HOME}/*'], { cwd: '/tmp' })).toContain('extremely dangerous');
});
test('treats ${TMPDIR} paths as temp when allowed', () => {
expect(
analyzeRm(['rm', '-rf', '${TMPDIR}/test'], {
cwd: '/tmp',
allowTmpdirVar: true,
}),
).toBeNull();
});
test('does not trust ${TMPDIR} when disallowed', () => {
expect(
analyzeRm(['rm', '-rf', '${TMPDIR}/test'], {
cwd: '/tmp',
allowTmpdirVar: false,
}),
).toContain('rm -rf outside cwd');
});
test('handles non-string cwd defensively', () => {
const badCwd = 1 as unknown as string;
expect(analyzeRm(['rm', '-rf', 'foo'], { cwd: badCwd })).toContain('rm -rf outside cwd');
});
test('handles absolute-path checks defensively', () => {
const badCwd = 1 as unknown as string;
expect(analyzeRm(['rm', '-rf', '/abs'], { cwd: badCwd })).toContain('rm -rf outside cwd');
});
test('blocks tilde-prefixed paths (not cwd-relative)', () => {
expect(analyzeRm(['rm', '-rf', '~/somewhere'], { cwd: '/tmp' })).toContain(
'rm -rf outside cwd',
);
});
test('blocks ../ paths', () => {
expect(analyzeRm(['rm', '-rf', '../escape'], { cwd: '/tmp' })).toContain('rm -rf outside cwd');
});
test('allows nested relative paths within cwd', () => {
const cwd = mkdtempSync(join(tmpdir(), 'safety-net-rm-unit-'));
try {
expect(
analyzeRm(['rm', '-rf', 'subdir/file'], {
cwd,
originalCwd: cwd,
}),
).toBeNull();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,362 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { verifyConfig as main, type VerifyConfigOptions } from '../src/bin/verify-config.ts';
describe('verify-config', () => {
let tempDir: string;
let userConfigPath: string;
let projectConfigPath: string;
let capturedStdout: string[];
let capturedStderr: string[];
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
beforeEach(() => {
// Create unique temp directory
tempDir = join(
tmpdir(),
`verify-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempDir, { recursive: true });
// Set up paths
userConfigPath = join(tempDir, '.cc-safety-net', 'config.json');
projectConfigPath = join(tempDir, '.safety-net.json');
// Capture console output
capturedStdout = [];
capturedStderr = [];
originalConsoleLog = console.log;
originalConsoleError = console.error;
console.log = (...args: unknown[]) => {
capturedStdout.push(args.map(String).join(' '));
};
console.error = (...args: unknown[]) => {
capturedStderr.push(args.map(String).join(' '));
};
});
afterEach(() => {
// Restore console
console.log = originalConsoleLog;
console.error = originalConsoleError;
// Clean up temp directory
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function writeUserConfig(content: string): void {
const dir = join(tempDir, '.cc-safety-net');
mkdirSync(dir, { recursive: true });
writeFileSync(userConfigPath, content, 'utf-8');
}
function writeProjectConfig(content: string): void {
writeFileSync(projectConfigPath, content, 'utf-8');
}
function runMain(): number {
const options: VerifyConfigOptions = {
userConfigPath,
projectConfigPath,
};
return main(options);
}
function getStdout(): string {
return capturedStdout.join('\n');
}
function getStderr(): string {
return capturedStderr.join('\n');
}
describe('no configs', () => {
test('returns zero when no configs exist', () => {
const result = runMain();
expect(result).toBe(0);
});
test('prints header', () => {
runMain();
const output = getStdout();
expect(output).toContain('Safety Net Config');
expect(output).toContain('═');
});
test('prints no configs message', () => {
runMain();
const output = getStdout();
expect(output).toContain('No config files found');
expect(output).toContain('Using built-in rules only');
});
});
describe('valid configs', () => {
test('user config only returns zero', () => {
writeUserConfig('{"version": 1}');
const result = runMain();
expect(result).toBe(0);
});
test('user config prints checkmark', () => {
writeUserConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('✓ User config:');
});
test('user config shows rules none', () => {
writeUserConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('Rules: (none)');
});
test('user config with rules shows numbered list', () => {
writeUserConfig(
JSON.stringify({
version: 1,
rules: [
{
name: 'block-foo',
command: 'foo',
block_args: ['-x'],
reason: 'Blocked',
},
{
name: 'block-bar',
command: 'bar',
block_args: ['-y'],
reason: 'Blocked',
},
],
}),
);
runMain();
const output = getStdout();
expect(output).toContain('Rules:');
expect(output).toContain('1. block-foo');
expect(output).toContain('2. block-bar');
});
test('project config only returns zero', () => {
writeProjectConfig('{"version": 1}');
const result = runMain();
expect(result).toBe(0);
});
test('project config prints checkmark', () => {
writeProjectConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('✓ Project config:');
});
test('both configs returns zero', () => {
writeUserConfig('{"version": 1}');
writeProjectConfig('{"version": 1}');
const result = runMain();
expect(result).toBe(0);
});
test('both configs prints both checkmarks', () => {
writeUserConfig('{"version": 1}');
writeProjectConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('✓ User config:');
expect(output).toContain('✓ Project config:');
});
test('valid config prints success message', () => {
writeProjectConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('All configs valid.');
});
});
describe('invalid configs', () => {
test('invalid user config returns one', () => {
writeUserConfig('{"version": 2}');
const result = runMain();
expect(result).toBe(1);
});
test('invalid user config prints x mark', () => {
writeUserConfig('{"version": 2}');
runMain();
const output = getStderr();
expect(output).toContain('✗ User config:');
});
test('invalid config shows numbered errors', () => {
writeUserConfig('{"version": 2}');
runMain();
const output = getStderr();
expect(output).toContain('Errors:');
expect(output).toContain('1.');
expect(output).toContain('version');
});
test('invalid project config returns one', () => {
writeProjectConfig('{"rules": []}');
const result = runMain();
expect(result).toBe(1);
});
test('invalid project config prints x mark', () => {
writeProjectConfig('{"rules": []}');
runMain();
const output = getStderr();
expect(output).toContain('✗ Project config:');
});
test('both invalid returns one', () => {
writeUserConfig('{"version": 2}');
writeProjectConfig('{"rules": []}');
const result = runMain();
expect(result).toBe(1);
});
test('both invalid prints both errors', () => {
writeUserConfig('{"version": 2}');
writeProjectConfig('{"rules": []}');
runMain();
const output = getStderr();
expect(output).toContain('✗ User config:');
expect(output).toContain('✗ Project config:');
});
test('invalid json prints error', () => {
writeProjectConfig('{ not valid json }');
runMain();
const output = getStderr();
expect(output).toContain('✗ Project config:');
});
test('validation failed message', () => {
writeProjectConfig('{"version": 2}');
runMain();
const output = getStderr();
expect(output).toContain('Config validation failed.');
});
});
describe('mixed validity', () => {
test('valid user invalid project returns one', () => {
writeUserConfig('{"version": 1}');
writeProjectConfig('{"version": 2}');
const result = runMain();
expect(result).toBe(1);
});
test('valid user invalid project shows both', () => {
writeUserConfig('{"version": 1}');
writeProjectConfig('{"version": 2}');
runMain();
const stdout = getStdout();
const stderr = getStderr();
expect(stdout).toContain('✓ User config:');
expect(stderr).toContain('✗ Project config:');
});
test('invalid user valid project returns one', () => {
writeUserConfig('{"version": 2}');
writeProjectConfig('{"version": 1}');
const result = runMain();
expect(result).toBe(1);
});
test('invalid user valid project shows both', () => {
writeUserConfig('{"version": 2}');
writeProjectConfig('{"version": 1}');
runMain();
const stdout = getStdout();
const stderr = getStderr();
expect(stderr).toContain('✗ User config:');
expect(stdout).toContain('✓ Project config:');
});
});
describe('schema auto-add', () => {
function readProjectConfig(): Record<string, unknown> {
return JSON.parse(readFileSync(projectConfigPath, 'utf-8'));
}
function readUserConfig(): Record<string, unknown> {
return JSON.parse(readFileSync(userConfigPath, 'utf-8'));
}
test('adds $schema to valid project config missing it', () => {
writeProjectConfig('{"version": 1}');
runMain();
const config = readProjectConfig();
expect(config.$schema).toBe(
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json',
);
});
test('adds $schema to valid user config missing it', () => {
writeUserConfig('{"version": 1}');
runMain();
const config = readUserConfig();
expect(config.$schema).toBe(
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json',
);
});
test('does not modify config that already has $schema', () => {
const originalConfig = {
$schema:
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json',
version: 1,
};
writeProjectConfig(JSON.stringify(originalConfig, null, 2));
runMain();
const config = readProjectConfig();
expect(config).toEqual(originalConfig);
});
test('preserves existing rules when adding $schema', () => {
const originalConfig = {
version: 1,
rules: [
{
name: 'block-foo',
command: 'foo',
block_args: ['-x'],
reason: 'Blocked',
},
],
};
writeProjectConfig(JSON.stringify(originalConfig));
runMain();
const config = readProjectConfig();
expect(config.$schema).toBe(
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json',
);
expect(config.version).toBe(1);
expect(config.rules).toEqual(originalConfig.rules);
});
test('does not add $schema to invalid config', () => {
writeProjectConfig('{"version": 2}');
runMain();
const config = readProjectConfig();
expect(config.$schema).toBeUndefined();
});
test('prints message when $schema is added', () => {
writeProjectConfig('{"version": 1}');
runMain();
const output = getStdout();
expect(output).toContain('Added $schema');
});
});
});