diff --git a/.changeset/green-owls-jump.md b/.changeset/green-owls-jump.md new file mode 100644 index 0000000..aee39d7 --- /dev/null +++ b/.changeset/green-owls-jump.md @@ -0,0 +1,16 @@ +--- +"layne": patch +--- + +fix(config-validator): accept scanner keys in $global and validate removeOnException label key + +`KNOWN_GLOBAL_KEYS` was missing `semgrep`, `trufflehog`, `claude`, and +`piAgent`, so using `$global.semgrep` (functional since the previous +config-inheritance fix) would cause `npm run validate-config` to report +an unknown key error. Added all four scanner keys and wired their +respective validators (`validateScanner`, `validateClaude`, +`validatePiAgent`) inside `validateGlobal`. + +`validateLabels` was also missing `removeOnException` from its checked +key list, even though the worker reads and applies it. The key is now +validated alongside the other five label keys. diff --git a/src/__tests__/config-validator.test.ts b/src/__tests__/config-validator.test.ts new file mode 100644 index 0000000..7aad123 --- /dev/null +++ b/src/__tests__/config-validator.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { validateConfig } from '../config-validator.js'; + +describe('validateConfig()', () => { + describe('$global scanner keys', () => { + it('accepts $global.semgrep without flagging it as an unknown key', () => { + const result = validateConfig({ + '$global': { semgrep: { extraArgs: ['--config', 'p/owasp-top-ten'] } }, + }); + expect(result.valid).toBe(true); + }); + + it('accepts $global.trufflehog without flagging it as an unknown key', () => { + const result = validateConfig({ + '$global': { trufflehog: { enabled: false } }, + }); + expect(result.valid).toBe(true); + }); + + it('accepts $global.claude without flagging it as an unknown key', () => { + const result = validateConfig({ + '$global': { claude: { enabled: true, model: 'claude-sonnet-4-6' } }, + }); + expect(result.valid).toBe(true); + }); + + it('accepts $global.piAgent without flagging it as an unknown key', () => { + const result = validateConfig({ + '$global': { piAgent: { enabled: false, model: 'claude-opus-4-6' } }, + }); + expect(result.valid).toBe(true); + }); + + it('still rejects a genuinely unknown key in $global', () => { + const result = validateConfig({ + '$global': { typo_key: true }, + }); + expect(result.valid).toBe(false); + expect((result as { valid: false; errors: string[] }).errors[0]).toMatch(/unknown key/); + }); + + it('validates the contents of $global.semgrep (rejects bad extraArgs)', () => { + const result = validateConfig({ + '$global': { semgrep: { extraArgs: [123] } }, + }); + expect(result.valid).toBe(false); + expect((result as { valid: false; errors: string[] }).errors[0]).toMatch(/extraArgs/); + }); + + it('validates the contents of $global.claude (rejects non-claude model)', () => { + const result = validateConfig({ + '$global': { claude: { enabled: true, model: 'gpt-4o' } }, + }); + expect(result.valid).toBe(false); + expect((result as { valid: false; errors: string[] }).errors[0]).toMatch(/model/); + }); + }); + + describe('validateLabels — removeOnException', () => { + it('accepts labels.removeOnException as a valid key', () => { + const result = validateConfig({ + 'acme/app': { labels: { removeOnException: ['security-exception'] } }, + }); + expect(result.valid).toBe(true); + }); + + it('rejects labels.removeOnException when not an array', () => { + const result = validateConfig({ + 'acme/app': { labels: { removeOnException: 'security-exception' } }, + }); + expect(result.valid).toBe(false); + expect((result as { valid: false; errors: string[] }).errors[0]).toMatch(/removeOnException/); + }); + + it('rejects labels.removeOnException when items are not strings', () => { + const result = validateConfig({ + 'acme/app': { labels: { removeOnException: [42] } }, + }); + expect(result.valid).toBe(false); + expect((result as { valid: false; errors: string[] }).errors[0]).toMatch(/removeOnException/); + }); + }); +}); diff --git a/src/config-validator.ts b/src/config-validator.ts index a0d99fc..d349aa9 100644 --- a/src/config-validator.ts +++ b/src/config-validator.ts @@ -9,7 +9,7 @@ */ const KNOWN_REPO_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'semgrep', 'trufflehog', 'claude', 'piAgent', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); -const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); +const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'semgrep', 'trufflehog', 'claude', 'piAgent', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); const VALID_MODES = new Set(['changed_files', 'diff_only']); const VALID_TRIGGER_ONS = new Set(['pull_request', 'workflow_run', 'workflow_job']); const VALID_CONCLUSIONS = new Set(['success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required']); @@ -57,6 +57,10 @@ function validateGlobal(block: Record, ctx: string, errors: str } } validateScanMode(block, ctx, errors); + if (block['semgrep'] !== undefined) validateScanner(block['semgrep'], `${ctx}.semgrep`, errors); + if (block['trufflehog'] !== undefined) validateScanner(block['trufflehog'], `${ctx}.trufflehog`, errors); + if (block['claude'] !== undefined) validateClaude(block['claude'], `${ctx}.claude`, errors); + if (block['piAgent'] !== undefined) validatePiAgent(block['piAgent'], `${ctx}.piAgent`, errors); if (block['notifications'] !== undefined) validateNotifications(block['notifications'], `${ctx}.notifications`, errors); if (block['labels'] !== undefined) validateLabels(block['labels'], `${ctx}.labels`, errors); if (block['trigger'] !== undefined) validateTrigger(block['trigger'], `${ctx}.trigger`, errors); @@ -251,7 +255,7 @@ function validateComment(block: unknown, ctx: string, errors: string[]): void { function validateLabels(block: unknown, ctx: string, errors: string[]): void { if (typeof block !== 'object' || block === null) { errors.push(`${ctx}: must be an object`); return; } const b = block as Record; - for (const key of ['onFailure', 'removeOnFailure', 'onSuccess', 'removeOnSuccess', 'onException']) { + for (const key of ['onFailure', 'removeOnFailure', 'onSuccess', 'removeOnSuccess', 'onException', 'removeOnException']) { if (b[key] === undefined) continue; if (!Array.isArray(b[key])) errors.push(`${ctx}.${key}: must be an array`);