diff --git a/src/cli/primitives/PolicyPrimitive.ts b/src/cli/primitives/PolicyPrimitive.ts index 950416ef8..c3be8a233 100644 --- a/src/cli/primitives/PolicyPrimitive.ts +++ b/src/cli/primitives/PolicyPrimitive.ts @@ -9,7 +9,14 @@ import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { PolicyValidationMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; -import { type PolicyEffect, authorizationPhaseForEffect, defaultDataPathForEffect } from '../tui/screens/policy/types'; +import { + FILTERS_BY_CATEGORY, + type GuardrailCategoryType, + type PolicyEffect, + authorizationPhaseForEffect, + defaultDataPathForEffect, + invalidFiltersForCategory, +} from '../tui/screens/policy/types'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; @@ -408,6 +415,15 @@ export class PolicyPrimitive extends BasePrimitive s.trim()); + const category = cliOptions.formCategory as GuardrailCategoryType; + const invalidFilters = invalidFiltersForCategory(category, filters); + if (invalidFilters.length > 0) { + throw new ValidationError( + `Invalid filter(s) for category '${category}': ${invalidFilters.join(', ')}. ` + + `Allowed: ${FILTERS_BY_CATEGORY[category].join(', ')}` + ); + } + let resolvedGatewayArn: string | undefined; let resolvedTargetName: string | undefined = cliOptions.target; if (cliOptions.gateway) { @@ -446,7 +462,7 @@ export class PolicyPrimitive extends BasePrimitive { expect(result).toContain('[context.input.prompt]'); }); + it('generates a policy with the INSULTS content filter (canonical plural name)', () => { + const form: GuardrailFormConfig = { ...baseForm, filters: ['INSULTS'] }; + const result = synthesizeCedar(form); + expect(result).toContain('["INSULTS"]'); + }); + it('generates permit policy with single filter using lessThanOrEqual', () => { const form: GuardrailFormConfig = { ...baseForm, effect: 'permit' }; const result = synthesizeCedar(form); diff --git a/src/cli/tui/screens/policy/__tests__/types.test.ts b/src/cli/tui/screens/policy/__tests__/types.test.ts new file mode 100644 index 000000000..28cd47ca1 --- /dev/null +++ b/src/cli/tui/screens/policy/__tests__/types.test.ts @@ -0,0 +1,19 @@ +import { invalidFiltersForCategory } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('invalidFiltersForCategory', () => { + it('returns an empty array when every filter is valid for the category', () => { + expect(invalidFiltersForCategory('contentFilter', ['VIOLENCE', 'INSULTS'])).toEqual([]); + expect(invalidFiltersForCategory('promptAttack', ['JAILBREAK'])).toEqual([]); + expect(invalidFiltersForCategory('sensitiveInformation', ['EMAIL', 'PHONE'])).toEqual([]); + }); + + it('rejects an unknown filter value', () => { + expect(invalidFiltersForCategory('contentFilter', ['VIOLENCE', 'NOTAREAL'])).toEqual(['NOTAREAL']); + }); + + it('rejects the legacy singular INSULT (regression guard for #1571)', () => { + expect(invalidFiltersForCategory('contentFilter', ['INSULT'])).toEqual(['INSULT']); + expect(invalidFiltersForCategory('contentFilter', ['INSULTS'])).toEqual([]); + }); +}); diff --git a/src/cli/tui/screens/policy/types.ts b/src/cli/tui/screens/policy/types.ts index b1bceaf5b..7b94045c7 100644 --- a/src/cli/tui/screens/policy/types.ts +++ b/src/cli/tui/screens/policy/types.ts @@ -12,7 +12,7 @@ export type PolicySourceMethod = 'file' | 'inline' | 'generate' | 'form'; export type GuardrailCategoryType = 'contentFilter' | 'promptAttack' | 'sensitiveInformation'; -export const CONTENT_FILTER_FILTERS = ['VIOLENCE', 'HATE', 'SEXUAL', 'MISCONDUCT', 'INSULT'] as const; +export const CONTENT_FILTER_FILTERS = ['VIOLENCE', 'HATE', 'SEXUAL', 'MISCONDUCT', 'INSULTS'] as const; export type ContentFilterCategory = (typeof CONTENT_FILTER_FILTERS)[number]; export const PROMPT_ATTACK_FILTERS = ['JAILBREAK', 'PROMPT_INJECTION', 'PROMPT_LEAKAGE'] as const; @@ -81,6 +81,25 @@ export const GUARDRAIL_CATEGORY_OPTIONS: { }, ]; +/** + * Valid filters per guardrail category. Single source of truth for both the TUI options + * and client-side validation of the non-interactive `--form-filters` flag. + */ +export const FILTERS_BY_CATEGORY: Record = { + contentFilter: CONTENT_FILTER_FILTERS, + promptAttack: PROMPT_ATTACK_FILTERS, + sensitiveInformation: SENSITIVE_INFO_FILTERS, +}; + +/** + * Returns the filter values that are not valid for the given category. + * An empty array means every supplied filter is valid. + */ +export function invalidFiltersForCategory(category: GuardrailCategoryType, filters: string[]): string[] { + const allowed = FILTERS_BY_CATEGORY[category]; + return filters.filter(f => !allowed.includes(f)); +} + export type PolicyEffect = 'permit' | 'forbid' | 'suppressOutput'; /**