From b8cb6b247f40054e7948a9f74db08d6d9138afef Mon Sep 17 00:00:00 2001 From: RAJVEER42 Date: Fri, 22 May 2026 02:51:52 +0530 Subject: [PATCH] fix(config-validator): accept scanner keys in \$global and validate removeOnException KNOWN_GLOBAL_KEYS was missing semgrep, trufflehog, claude, and piAgent, so using \$global.semgrep (functional since the config-inheritance fix) caused validate-config to report an unknown key error despite the config being correct. Also wires their content validators inside validateGlobal. validateLabels was also missing removeOnException from its checked key list even though the worker reads and applies it for exception label cleanup. --- .changeset/green-owls-jump.md | 16 +++++ src/__tests__/config-validator.test.ts | 83 ++++++++++++++++++++++++++ src/config-validator.ts | 8 ++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 .changeset/green-owls-jump.md create mode 100644 src/__tests__/config-validator.test.ts 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`);