Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/green-owls-jump.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions src/__tests__/config-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
8 changes: 6 additions & 2 deletions src/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -57,6 +57,10 @@ function validateGlobal(block: Record<string, unknown>, 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);
Expand Down Expand Up @@ -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<string, unknown>;
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`);
Expand Down
Loading