Reorganize: Move all skills to skills/ folder
- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
276
skills/plugins/claude-code-safety-net/tests/audit.test.ts
Normal file
276
skills/plugins/claude-code-safety-net/tests/audit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
447
skills/plugins/claude-code-safety-net/tests/cli-wrapper.test.ts
Normal file
447
skills/plugins/claude-code-safety-net/tests/cli-wrapper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
706
skills/plugins/claude-code-safety-net/tests/config.test.ts
Normal file
706
skills/plugins/claude-code-safety-net/tests/config.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
416
skills/plugins/claude-code-safety-net/tests/custom-rules.test.ts
Normal file
416
skills/plugins/claude-code-safety-net/tests/custom-rules.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
750
skills/plugins/claude-code-safety-net/tests/edge-cases.test.ts
Normal file
750
skills/plugins/claude-code-safety-net/tests/edge-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
skills/plugins/claude-code-safety-net/tests/env.test.ts
Normal file
63
skills/plugins/claude-code-safety-net/tests/env.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
105
skills/plugins/claude-code-safety-net/tests/find.test.ts
Normal file
105
skills/plugins/claude-code-safety-net/tests/find.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
105
skills/plugins/claude-code-safety-net/tests/format.test.ts
Normal file
105
skills/plugins/claude-code-safety-net/tests/format.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
63
skills/plugins/claude-code-safety-net/tests/helpers.ts
Normal file
63
skills/plugins/claude-code-safety-net/tests/helpers.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
450
skills/plugins/claude-code-safety-net/tests/rules-git.test.ts
Normal file
450
skills/plugins/claude-code-safety-net/tests/rules-git.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
454
skills/plugins/claude-code-safety-net/tests/rules-rm.test.ts
Normal file
454
skills/plugins/claude-code-safety-net/tests/rules-rm.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user