diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md index e6fa069b9..5ae9a4957 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/README.md +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -8,9 +8,10 @@ re-exported from `./canvas`). Members: the definitions layer (wire types, parser copy and `useGuardrailDefinitions`), `GuardrailList` (the applied-guardrails section), `GuardrailPalette` (the add-guardrail picker), `GuardrailRemoveDialog` (the removal confirmation), `GuardrailBuilder` (the whole Add/Edit screen), `GuardrailFormLayout` (the -screen shell), and `GuardrailValidatorForm` (the validator parameter section, also rendered -inside the builder), plus the leaves the sections compose: `GuardrailStatusChip`, -`GuardrailStatusBanner` and `MixedScopesBanner`. +screen shell), `GuardrailValidatorForm` (the validator parameter section, also rendered +inside the builder), and `CentralizedGuardrailsSection` + `CentralizedGuardrailDetails` (the +read-only governance guardrails a policy enforces), plus the leaves the sections compose: +`GuardrailStatusChip`, `GuardrailStatusBanner` and `MixedScopesBanner`. ## Hover and focus, family-wide @@ -503,6 +504,99 @@ comes from Agents' `common.close`; its own dialog labels that button with a hard `chore(l10n): sync from Localization`, and until it runs `useSafeLingui` renders the English default, so nothing is missing on screen. +## CentralizedGuardrailsSection + +The read-only list of guardrails an organization's AI Trust Layer governance policy enforces +on an agent, and `CentralizedGuardrailDetails`, the content behind a row. + +```tsx +import { + CentralizedGuardrailDetails, + CentralizedGuardrailsSection, + getApplicableCentralizedGuardrails, +} from '@uipath/apollo-react/canvas/guardrails'; + +; +``` + +Contract highlights: + +- **Governance guardrails are their own record.** `CentralizedGuardrail` mirrors both + products' policy schemas: no `id`, `scopes` at the top level rather than under a + `selector`, and `action` as a bare discriminator rather than an object. It is deliberately + not a variant of `GuardrailBuilderValue`. `executionStage` stays `string` because both + products parse it as one; `action` is the closed four-value union both close it to, and a + TypeScript string enum member assigns to its literal, so Agents' `ActionType` fits. +- **Props, never contexts.** Both products hold the policy and the definitions in a context of + their own (`useGovernance`, `GuardrailDefinitionsContext`, `useAiTrustLayerGovernancePolicy`); + passing them in is what lets one component serve both. +- **The host filters, the component renders.** `getApplicableCentralizedGuardrails` is the + predicate for the agent kind being edited, exported so no host rewrites it. An empty + `guardrails` renders nothing; `emptyState` overrides that, and an explicit `null` is + honoured. +- **`definitions` is optional, and `undefined` means "not loaded yet".** That is what keeps a + row from claiming a configuration was deleted while the catalog is still in flight. An + empty array means it loaded and the configuration really is gone. +- **Scopes, actions and execution stages default to the family's own labels**, with + `formatScope` / `formatAction` to override. Every one of those strings already existed in + the canvas catalog, so defaulting removes a prop an adapter can forget for a visible + regression (`Llm` instead of "LLM calls"). +- **A broken BYO configuration gets a chip and a sentence.** The chip is `GuardrailList`'s + own (`Unavailable` / `Disabled`, same ids) and makes the row findable in a long policy; the + sentence under it, which both products already show, says what to do about it. +- **The row's accessible name is its own text.** Both products put an `aria-label` on it, + which overrides the content and hides the description, the provider and the + broken-configuration message from screen readers entirely. +- **Layout knobs for both hosts**: `unstyled` drops the card border and padding, `hideHeader` + drops the heading, info popover and policy caption. Agents nests the section in its own + `SectionAccordion` and uses both. +- **`docsHref` is opt-in.** Product documentation URLs never ship in this package. + +### CentralizedGuardrailDetails + +The details **content**, not a shell: Agents opens a dialog and Flow pushes a panel overlay, +each with its own header, breadcrumb and dismissal, so the surrounding chrome stays host +orchestration. The `Details in a dialog` and `Details in a panel overlay` stories show both. + +```tsx +; +``` + +- **One configuration renderer for both origins.** A BYO guardrail states its configuration + as connector parameters and a built-in as `entities` / `entityThresholds`. + `resolveCentralizedGuardrailParameters` lifts the built-in fields onto the parameter shape + so one resolver covers both, and a threshold map absorbs its `keySource` list into its key + column. +- **Labels and entity names come from the matching definition**, so a centralized guardrail + names its entities the way the guardrail editor names them ("US Social Security Number + (SSN)", not `USSocialSecurityNumber`) and each validator names its own configuration + ("Severity thresholds" for harmful content, "Detection thresholds" for PII). With no + definition matched it falls back to generic labels and raw values, which is what both + products render today. +- **A read-only value is text, not a disabled input.** The family's parameter editors are the + MetadataForm stack and have no read-only mode, and these values arrive as untyped wire data + rather than `GuardrailValidatorParameter`s. A disabled input, which is how Flow renders this + today, is also worse than text: it cannot be focused, so its content is not selectable, not + copyable and skipped by a screen reader. + +Both components resolve a built-in validator's name and description from the canonical copy +table (see *Definitions layer*), never from the definitions array: a policy can enforce a +validator this tenant is not entitled to and therefore has no definition for. A BYO +guardrail's description comes from its connector definition and never from the curated table, +since a connector may expose a validator id a built-in also uses. + ## GuardrailBuilder The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.test.tsx new file mode 100644 index 000000000..51ef3c21f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, within } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it } from 'vitest'; +import { ApI18nProvider } from '../../../i18n'; +import { CentralizedGuardrailDetails } from './centralized-guardrail-details'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from './centralized-types'; + +const guardrail = (overrides: Partial = {}): CentralizedGuardrail => ({ + validator: 'pii_detection', + executionStage: 'Both', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: true, + scopes: ['Agent', 'Tool'], + action: 'escalate', + ...overrides, +}); + +const PII_DEFINITION: CentralizedGuardrailDefinition = { + validator: 'pii_detection', + parameters: [ + { + id: 'entities', + type: 'enum-list', + label: 'Entities to detect', + optionLabels: { Email: 'Email address', USSocialSecurityNumber: 'US SSN' }, + }, + { + id: 'entityThresholds', + type: 'map-enum', + label: 'Detection thresholds', + keySource: 'entities', + }, + ], +}; + +const BYO_DEFINITION: CentralizedGuardrailDefinition = { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + byoConnectorName: 'Acme Security', + description: 'Acme runs its own detector.', + parameters: [ + { id: 'mode', type: 'enum', label: 'Detection mode', optionLabels: { fast: 'Fast' } }, + { id: 'strict', type: 'boolean', label: 'Strict matching' }, + ], +}; + +/** The value rendered under a `
`, as a screen reader would pair them. */ +const valueFor = (label: string) => + screen.getByText(label).parentElement?.querySelector('dd')?.textContent; + +describe('CentralizedGuardrailDetails', () => { + it('explains that the configuration is not editable here', () => { + render(); + + expect( + screen.getByText(/governance policy manages this configuration\. You cannot edit it here\./) + ).toBeInTheDocument(); + }); + + it('states the guardrail, the policy enforcing it, and what it does', () => { + render(); + + expect(valueFor('Guardrail type')).toContain('PII detection'); + expect(valueFor('AI Trust Layer policy')).toBe('Acme policy'); + expect(valueFor('Execution stage')).toBe('Pre & post-execution'); + expect(valueFor('Scopes')).toBe('Agent, Tools'); + expect(valueFor('Action')).toBe('Escalate'); + }); + + it('offers a line of its own when nothing describes the guardrail', () => { + render( + + ); + + expect(valueFor('Guardrail description')).toBe('No description available.'); + }); + + it('shows the connector and its description for a BYO guardrail', () => { + render( + + ); + + expect(valueFor('Guardrail type')).toContain('Acme PII'); + expect(screen.getByText('BYO')).toBeInTheDocument(); + expect(valueFor('Provider')).toBe('Acme Security'); + expect(valueFor('Guardrail description')).toBe('Acme runs its own detector.'); + }); + + it('banners a missing configuration and a disabled one', () => { + const { rerender } = render( + + ); + expect(screen.getByText(/could not be found/)).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText(/has been disabled/)).toBeInTheDocument(); + }); + + it('names the entities the way the guardrail editor names them', () => { + render( + + ); + + const thresholds = screen.getByText('Detection thresholds').parentElement as HTMLElement; + const rows = within(thresholds).getAllByRole('listitem'); + expect(rows.map((row) => row.textContent)).toEqual(['Email address0.8', 'US SSN—']); + }); + + it('renders a BYO connector configuration in the order the connector declares', () => { + render( + + ); + + const configuration = screen.getByText('Configuration').parentElement as HTMLElement; + const terms = within(configuration).getAllByRole('term'); + expect(terms.map((term) => term.textContent)).toEqual(['Detection mode', 'Strict matching']); + expect(valueFor('Detection mode')).toBe('Fast'); + expect(valueFor('Strict matching')).toBe('Enabled'); + }); + + it('drops the configuration block for a guardrail the policy left unconfigured', () => { + render( + + ); + + expect(screen.queryByText('Configuration')).not.toBeInTheDocument(); + }); + + it('lets a host replace the scope and action names', () => { + render( + `<${scope}>`} + formatAction={(action) => action.toUpperCase()} + /> + ); + + expect(valueFor('Scopes')).toBe(', '); + expect(valueFor('Action')).toBe('ESCALATE'); + }); + + it('translates through the ambient catalog', () => { + render( + + + + ); + + // The type, description and scope labels reuse the builder's ids, so they are translated + // already; this view's own ids render English until the l10n sync reaches them. + expect(screen.getByText('ガードレールの種類')).toBeInTheDocument(); + expect(screen.getByText('スコープ')).toBeInTheDocument(); + }); + + it('has no axe violations', async () => { + const { container } = render( + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.tsx new file mode 100644 index 000000000..969a9a7e8 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-details.tsx @@ -0,0 +1,126 @@ +import { Alert, AlertDescription, cn } from '@uipath/apollo-wind'; +import { Info } from 'lucide-react'; +import type { GuardrailScope } from './builder-types'; +import { + findCentralizedBuiltInDefinition, + findCentralizedByoDefinition, + formatCentralizedAction, + formatCentralizedExecutionStage, + formatCentralizedScope, + getCentralizedGuardrailDisplay, + isCentralizedGuardrailConfigMissing, + resolveCentralizedGuardrailParameters, +} from './centralized-guardrail-utils'; +import type { + CentralizedGuardrail, + CentralizedGuardrailActionType, + CentralizedGuardrailDefinition, +} from './centralized-types'; +import { CentralizedDetailField } from './components/centralized-detail-field'; +import { CentralizedGuardrailOriginChip } from './components/centralized-guardrail-origin-chip'; +import { CentralizedGuardrailParameters } from './components/centralized-guardrail-parameters'; +import { GuardrailStatusBanner } from './components/guardrail-status-banner'; +import { useGuardrailDefinitionCopy } from './definitions-copy'; +import type { CentralizedGuardrailsLabels } from './i18n'; +import { useCentralizedGuardrailsLabels } from './i18n'; + +export interface CentralizedGuardrailDetailsProps< + TDefinition extends CentralizedGuardrailDefinition = CentralizedGuardrailDefinition, +> { + guardrail: CentralizedGuardrail; + /** Name of the AI Trust Layer policy enforcing it. */ + policyName: string; + /** The same array the section receives; `undefined` while the catalog is loading. */ + definitions?: TDefinition[]; + /** Replace the localized scope names. Defaults to the family's own scope labels. */ + formatScope?: (scope: GuardrailScope) => string; + /** Replace the localized action name. Defaults to the family's own action labels. */ + formatAction?: (action: CentralizedGuardrailActionType) => string; + labels?: Partial; + className?: string; +} + +/** Read-only details of one centralized guardrail: content only, the host owns the shell. */ +export function CentralizedGuardrailDetails< + TDefinition extends CentralizedGuardrailDefinition = CentralizedGuardrailDefinition, +>({ + guardrail, + policyName, + definitions, + formatScope, + formatAction, + labels: labelOverrides, + className, +}: CentralizedGuardrailDetailsProps) { + const labels = useCentralizedGuardrailsLabels(labelOverrides); + const copy = useGuardrailDefinitionCopy(); + + const byoDefinition = findCentralizedByoDefinition(guardrail, definitions); + const definition = guardrail.isByo + ? byoDefinition + : findCentralizedBuiltInDefinition(guardrail, definitions); + const { name, description } = getCentralizedGuardrailDisplay(guardrail, { + definition: byoDefinition, + copy, + }); + const isConfigMissing = isCentralizedGuardrailConfigMissing(guardrail, definitions); + const isConfigDisabled = byoDefinition?.status === 'Disabled'; + + const parameterRows = resolveCentralizedGuardrailParameters(guardrail, { + definition, + labels: { + enabled: labels.parameterEnabled, + disabled: labels.parameterDisabled, + entities: labels.entitiesFallback, + thresholds: labels.thresholdsFallback, + }, + }); + + return ( +
+ {/* `note`, not `alert`: nothing just happened. `mt-0` as in `GuardrailStatusBanner`. */} + + + {labels.managedMessage} + + + {isConfigMissing && ( + + )} + {isConfigDisabled && ( + + )} + +
+ + + {name} + + + + {policyName} + {byoDefinition?.byoConnectorName !== undefined && ( + + {byoDefinition.byoConnectorName} + + )} + + {description ?? labels.noDescription} + + + {formatCentralizedExecutionStage(guardrail.executionStage, labels)} + + + {guardrail.scopes + .map((scope) => formatScope?.(scope) ?? formatCentralizedScope(scope, labels)) + .join(', ')} + + + {formatAction?.(guardrail.action) ?? formatCentralizedAction(guardrail.action, labels)} + +
+ + +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts new file mode 100644 index 000000000..e43e322ab --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from 'vitest'; +import { + findCentralizedBuiltInDefinition, + findCentralizedByoDefinition, + formatCentralizedAction, + formatCentralizedExecutionStage, + formatCentralizedScope, + getApplicableCentralizedGuardrails, + getCentralizedGuardrailDisplay, + getCentralizedGuardrailItemId, + isCentralizedGuardrailConfigMissing, + resolveCentralizedGuardrailParameters, +} from './centralized-guardrail-utils'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from './centralized-types'; +import type { GuardrailCopyTable } from './definitions-copy'; +import { CENTRALIZED_GUARDRAILS_EN_LABELS } from './i18n'; + +const FALLBACK_LABELS = { + enabled: CENTRALIZED_GUARDRAILS_EN_LABELS.parameterEnabled, + disabled: CENTRALIZED_GUARDRAILS_EN_LABELS.parameterDisabled, + entities: CENTRALIZED_GUARDRAILS_EN_LABELS.entitiesFallback, + thresholds: CENTRALIZED_GUARDRAILS_EN_LABELS.thresholdsFallback, +}; + +const guardrail = (overrides: Partial = {}): CentralizedGuardrail => ({ + validator: 'pii_detection', + executionStage: 'Pre', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: false, + scopes: ['Agent'], + action: 'block', + ...overrides, +}); + +const PII_DEFINITION: CentralizedGuardrailDefinition = { + validator: 'pii_detection', + parameters: [ + { + id: 'entities', + type: 'enum-list', + label: 'Entities to detect', + optionLabels: { Email: 'Email address', USSocialSecurityNumber: 'US SSN' }, + }, + { + id: 'entityThresholds', + type: 'map-enum', + label: 'Detection thresholds', + keySource: 'entities', + }, + ], +}; + +describe('getApplicableCentralizedGuardrails', () => { + it('keeps the ones that apply to the agent kind being edited', () => { + const autonomous = guardrail({ validator: 'a' }); + const conversational = guardrail({ + validator: 'b', + appliesToAutonomousAgents: false, + appliesToConversationalAgents: true, + }); + + expect( + getApplicableCentralizedGuardrails([autonomous, conversational], {}).map((g) => g.validator) + ).toEqual(['a']); + expect( + getApplicableCentralizedGuardrails([autonomous, conversational], { + isConversational: true, + }).map((g) => g.validator) + ).toEqual(['b']); + }); +}); + +describe('getCentralizedGuardrailItemId', () => { + it('separates the same validator enforced at two stages', () => { + expect(getCentralizedGuardrailItemId(guardrail({ executionStage: 'Pre' }))).not.toBe( + getCentralizedGuardrailItemId(guardrail({ executionStage: 'Post' })) + ); + }); + + it('separates a BYO configuration from a built-in sharing its validator id', () => { + expect(getCentralizedGuardrailItemId(guardrail({ isByo: true, name: 'Acme PII' }))).not.toBe( + getCentralizedGuardrailItemId(guardrail()) + ); + }); + + it('keeps fields apart that a joined string would run together', () => { + expect( + getCentralizedGuardrailItemId(guardrail({ validator: 'a-b', executionStage: 'c' })) + ).not.toBe(getCentralizedGuardrailItemId(guardrail({ validator: 'a', executionStage: 'b-c' }))); + expect(getCentralizedGuardrailItemId(guardrail({ isByo: true, name: 'builtin' }))).not.toBe( + getCentralizedGuardrailItemId(guardrail()) + ); + }); + + it('stays the same for an unchanged guardrail in a reordered policy', () => { + const one = guardrail({ isByo: true, name: 'Acme PII' }); + expect(getCentralizedGuardrailItemId(one)).toBe( + getCentralizedGuardrailItemId({ ...one, action: 'log' }) + ); + }); +}); + +describe('findCentralizedByoDefinition', () => { + const definitions: CentralizedGuardrailDefinition[] = [ + { validator: 'pii_detection', byoValidatorName: 'Acme PII', byoConnectorName: 'Acme' }, + { validator: 'harmful_content', byoValidatorName: 'Acme PII', byoConnectorName: 'Acme' }, + { validator: 'pii_detection' }, + ]; + + it('matches on the configuration name and the validator together', () => { + const found = findCentralizedByoDefinition( + { validator: 'pii_detection', name: 'Acme PII', isByo: true }, + definitions + ); + + expect(found?.validator).toBe('pii_detection'); + expect(found?.byoConnectorName).toBe('Acme'); + }); + + it('does not match a same-named configuration for another validator', () => { + expect( + findCentralizedByoDefinition( + { validator: 'user_prompt_attacks', name: 'Acme PII', isByo: true }, + definitions + ) + ).toBeUndefined(); + }); + + it('never matches a built-in guardrail, nor a BYO one with no name', () => { + expect( + findCentralizedByoDefinition({ validator: 'pii_detection', name: 'Acme PII' }, definitions) + ).toBeUndefined(); + expect( + findCentralizedByoDefinition({ validator: 'pii_detection', isByo: true }, definitions) + ).toBeUndefined(); + }); +}); + +describe('findCentralizedBuiltInDefinition', () => { + const definitions: CentralizedGuardrailDefinition[] = [ + { validator: 'pii_detection', byoValidatorName: 'Acme PII' }, + { validator: 'pii_detection', description: 'the built-in one' }, + ]; + + it('skips the BYO definition sharing the validator id', () => { + expect( + findCentralizedBuiltInDefinition({ validator: 'pii_detection' }, definitions)?.description + ).toBe('the built-in one'); + }); + + it('returns nothing for a BYO guardrail', () => { + expect( + findCentralizedBuiltInDefinition({ validator: 'pii_detection', isByo: true }, definitions) + ).toBeUndefined(); + }); +}); + +describe('isCentralizedGuardrailConfigMissing', () => { + const identity = { validator: 'pii_detection', name: 'Acme PII', isByo: true }; + + it('reports nothing while the catalog has not loaded', () => { + expect(isCentralizedGuardrailConfigMissing(identity, undefined)).toBe(false); + }); + + it('reports a missing configuration once the catalog is in', () => { + expect(isCentralizedGuardrailConfigMissing(identity, [])).toBe(true); + expect( + isCentralizedGuardrailConfigMissing(identity, [ + { validator: 'pii_detection', byoValidatorName: 'Acme PII' }, + ]) + ).toBe(false); + }); + + it('never reports one for a built-in guardrail', () => { + expect(isCentralizedGuardrailConfigMissing({ validator: 'pii_detection' }, [])).toBe(false); + }); +}); + +describe('getCentralizedGuardrailDisplay', () => { + const copy = { + pii_detection: { + displayName: 'PII detection', + description: 'Detect personally identifiable information.', + paramLabels: {}, + }, + } as unknown as GuardrailCopyTable; + + it('takes a built-in name and description from the curated table', () => { + expect(getCentralizedGuardrailDisplay({ validator: 'pii_detection' }, { copy })).toEqual({ + name: 'PII detection', + description: 'Detect personally identifiable information.', + }); + }); + + it('falls back to the raw validator id for a validator the table does not know', () => { + expect(getCentralizedGuardrailDisplay({ validator: 'acme_custom' }, { copy })).toEqual({ + name: 'acme_custom', + description: undefined, + }); + }); + + it('takes a BYO description from the connector, never the curated one', () => { + expect( + getCentralizedGuardrailDisplay( + { validator: 'pii_detection', name: 'Acme PII', isByo: true }, + { copy, definition: { validator: 'pii_detection', description: "Acme's own check" } } + ) + ).toEqual({ name: 'Acme PII', description: "Acme's own check" }); + }); + + it('never gives a BYO guardrail the curated name, even when it has none of its own', () => { + expect( + getCentralizedGuardrailDisplay({ validator: 'pii_detection', isByo: true }, { copy }).name + ).toBe('pii_detection'); + }); + + it('treats an empty description as absent, so the caller can offer its own line', () => { + expect( + getCentralizedGuardrailDisplay( + { validator: 'pii_detection', name: 'Acme PII', isByo: true }, + { copy, definition: { validator: 'pii_detection', description: '' } } + ).description + ).toBeUndefined(); + }); +}); + +describe('resolveCentralizedGuardrailParameters, built-in guardrails', () => { + it('folds the entity list into the threshold rows through the definition keySource', () => { + const rows = resolveCentralizedGuardrailParameters( + guardrail({ + entities: ['Email', 'USSocialSecurityNumber'], + entityThresholds: { Email: 0.8 }, + }), + { definition: PII_DEFINITION, labels: FALLBACK_LABELS } + ); + + expect(rows).toEqual([ + { + id: 'entityThresholds', + kind: 'thresholds', + label: 'Detection thresholds', + thresholds: [ + { key: 'Email', label: 'Email address', value: 0.8 }, + { key: 'USSocialSecurityNumber', label: 'US SSN', value: undefined }, + ], + }, + ]); + }); + + it('labels the rows from the definition, so each validator names its own configuration', () => { + const rows = resolveCentralizedGuardrailParameters( + guardrail({ validator: 'harmful_content', entityThresholds: { Hate: 2 } }), + { + definition: { + validator: 'harmful_content', + parameters: [ + { id: 'harmfulContentEntities', type: 'enum-list', label: 'Content categories' }, + { + id: 'harmfulContentEntityThresholds', + type: 'map-enum', + label: 'Severity thresholds', + keySource: 'harmfulContentEntities', + }, + ], + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows.map((row) => row.label)).toEqual(['Severity thresholds']); + }); + + it('renders an entity list with no thresholds as its own line', () => { + const rows = resolveCentralizedGuardrailParameters( + guardrail({ validator: 'intellectual_property', entities: ['Text', 'Code'] }), + { + definition: { + validator: 'intellectual_property', + parameters: [{ id: 'ipEntities', type: 'enum-list', label: 'Content types' }], + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows).toEqual([ + { id: 'ipEntities', kind: 'value', label: 'Content types', value: 'Text, Code' }, + ]); + }); + + it('still folds and labels generically when no definition matched', () => { + const rows = resolveCentralizedGuardrailParameters( + guardrail({ entities: ['Email'], entityThresholds: { Email: 0.8 } }), + { labels: FALLBACK_LABELS } + ); + + expect(rows).toEqual([ + { + id: 'entityThresholds', + kind: 'thresholds', + label: 'Detection thresholds', + thresholds: [{ key: 'Email', label: 'Email', value: 0.8 }], + }, + ]); + }); + + it('has nothing to show for a guardrail the policy left unconfigured', () => { + expect( + resolveCentralizedGuardrailParameters(guardrail(), { + definition: PII_DEFINITION, + labels: FALLBACK_LABELS, + }) + ).toEqual([]); + }); +}); + +describe('resolveCentralizedGuardrailParameters, BYO guardrails', () => { + const byo = (parameters: CentralizedGuardrail['parameters']): CentralizedGuardrail => + guardrail({ isByo: true, name: 'Acme PII', parameters }); + + it('renders each value type the way its own shape implies', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([ + { id: 'strict', parameterType: 'boolean', value: true }, + { id: 'lenient', parameterType: 'boolean', value: false }, + { id: 'mode', parameterType: 'enum', value: 'Fast' }, + { id: 'count', parameterType: 'number', value: 3 }, + { id: 'terms', parameterType: 'text-list', value: ['alpha', 'beta'] }, + ]), + { labels: FALLBACK_LABELS } + ); + + expect(rows).toEqual([ + { id: 'strict', kind: 'value', label: 'strict', value: 'Enabled' }, + { id: 'lenient', kind: 'value', label: 'lenient', value: 'Disabled' }, + { id: 'mode', kind: 'value', label: 'mode', value: 'Fast' }, + { id: 'count', kind: 'value', label: 'count', value: '3' }, + { id: 'terms', kind: 'value', label: 'terms', value: 'alpha, beta' }, + ]); + }); + + it('renders a plain object as a threshold table however `parameterType` is spelled', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([{ id: 'scores', parameterType: 'mapEnum', value: { Email: 0.5 } }]), + { labels: FALLBACK_LABELS } + ); + + expect(rows).toEqual([ + { + id: 'scores', + kind: 'thresholds', + label: 'Detection thresholds', + thresholds: [{ key: 'Email', label: 'Email', value: 0.5 }], + }, + ]); + }); + + it('renders a scalar as a value even when `parameterType` claims a map', () => { + expect( + resolveCentralizedGuardrailParameters( + byo([{ id: 'limit', parameterType: 'map-enum', value: 3 }]), + { labels: FALLBACK_LABELS } + ) + ).toEqual([{ id: 'limit', kind: 'value', label: 'limit', value: '3' }]); + }); + + it('follows the connector order, then appends values it has no definition for', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([ + { id: 'second', value: 'b' }, + { id: 'undeclared', value: 'c' }, + { id: 'first', value: 'a' }, + ]), + { + definition: { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + parameters: [ + { id: 'first', type: 'text', label: 'First' }, + { id: 'second', type: 'text', label: 'Second' }, + ], + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows.map((row) => row.id)).toEqual(['first', 'second', 'undeclared']); + expect(rows.map((row) => row.label)).toEqual(['First', 'Second', 'undeclared']); + }); + + it('keeps a selected key list visible when its threshold map has no value at all', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([{ id: 'entities', value: ['Email'] }]), + { + definition: { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + parameters: PII_DEFINITION.parameters, + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows).toEqual([ + { id: 'entities', kind: 'value', label: 'Entities to detect', value: 'Email address' }, + ]); + }); + + it('keeps the key list as its own row when the threshold map was persisted empty', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([ + { id: 'entities', value: ['Email'] }, + { id: 'entityThresholds', value: null }, + ]), + { + definition: { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + parameters: PII_DEFINITION.parameters, + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows).toEqual([ + { id: 'entities', kind: 'value', label: 'Entities to detect', value: 'Email address' }, + ]); + }); + + it('reads option labels and thresholds as own properties only', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([ + { id: 'mode', value: 'constructor' }, + { id: 'entities', value: ['toString'] }, + { id: 'entityThresholds', value: JSON.parse('{"__proto__": 0.5}') }, + ]), + { + definition: { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + parameters: [ + { id: 'mode', type: 'enum', label: 'Mode', optionLabels: {} }, + ...(PII_DEFINITION.parameters ?? []), + ], + }, + labels: FALLBACK_LABELS, + } + ); + + expect(rows).toEqual([ + { id: 'mode', kind: 'value', label: 'Mode', value: 'constructor' }, + { + id: 'entityThresholds', + kind: 'thresholds', + label: 'Detection thresholds', + thresholds: [ + { key: '__proto__', label: '__proto__', value: 0.5 }, + { key: 'toString', label: 'toString', value: undefined }, + ], + }, + ]); + }); + + it('skips a parameter the connector persisted empty', () => { + expect( + resolveCentralizedGuardrailParameters(byo([{ id: 'note', value: null }]), { + labels: FALLBACK_LABELS, + }) + ).toEqual([]); + }); + + it('renders one row per id, however many times the policy repeated it', () => { + const rows = resolveCentralizedGuardrailParameters( + byo([ + { id: 'note', value: 'first' }, + { id: 'note', value: 'second' }, + ]), + { labels: FALLBACK_LABELS } + ); + + // Two rows would share a React key, and the list is keyed by parameter id. + expect(rows).toHaveLength(1); + }); +}); + +describe('the label formatters', () => { + const labels = CENTRALIZED_GUARDRAILS_EN_LABELS; + + it('names every scope and action both products close their sets to', () => { + expect( + (['Agent', 'Llm', 'Tool'] as const).map((s) => formatCentralizedScope(s, labels)) + ).toEqual(['Agent', 'LLM calls', 'Tools']); + expect( + (['block', 'escalate', 'filter', 'log'] as const).map((a) => + formatCentralizedAction(a, labels) + ) + ).toEqual(['Block', 'Escalate', 'Filter', 'Log']); + }); + + it('names the three execution stages and passes an unknown one through', () => { + expect( + ['Pre', 'Post', 'Both'].map((stage) => formatCentralizedExecutionStage(stage, labels)) + ).toEqual(['Pre-execution', 'Post-execution', 'Pre & post-execution']); + // The stage is `z.string()` in both products, so a new one has to render as something. + expect(formatCentralizedExecutionStage('Streaming', labels)).toBe('Streaming'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts new file mode 100644 index 000000000..651ed15be --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrail-utils.ts @@ -0,0 +1,318 @@ +import type { GuardrailScope } from './builder-types'; +import type { + CentralizedGuardrail, + CentralizedGuardrailActionType, + CentralizedGuardrailDefinition, + CentralizedGuardrailParameter, + CentralizedGuardrailParameterDefinition, + CentralizedGuardrailParameterRow, +} from './centralized-types'; +import type { GuardrailCopyTable } from './definitions-copy'; +import type { CentralizedGuardrailsLabels } from './i18n'; + +/** The identity fields the display and matching helpers read. */ +export type CentralizedGuardrailIdentity = Pick< + CentralizedGuardrail, + 'validator' | 'name' | 'isByo' +>; + +/** The guardrails a policy applies to one kind of agent. */ +export function getApplicableCentralizedGuardrails( + guardrails: readonly T[], + { isConversational }: { isConversational?: boolean } +): T[] { + return guardrails.filter((guardrail) => + isConversational ? guardrail.appliesToConversationalAgents : guardrail.appliesToAutonomousAgents + ); +} + +/** Row key: `validator` alone repeats across execution stages and BYO configurations. */ +export function getCentralizedGuardrailItemId(guardrail: CentralizedGuardrail): string { + // A tuple, not a joined string: free wire text in any field could run into the next. + return JSON.stringify([ + guardrail.validator, + guardrail.executionStage, + guardrail.isByo ? (guardrail.name ?? null) : false, + ]); +} + +/** Matched on name and validator, so a same-named configuration of another validator misses. */ +export function findCentralizedByoDefinition( + guardrail: CentralizedGuardrailIdentity, + definitions: readonly T[] | undefined +): T | undefined { + if (!guardrail.isByo || guardrail.name == null) return undefined; + return definitions?.find( + (definition) => + definition.byoValidatorName === guardrail.name && definition.validator === guardrail.validator + ); +} + +/** A built-in's definition, used only to label its configuration rows. */ +export function findCentralizedBuiltInDefinition( + guardrail: CentralizedGuardrailIdentity, + definitions: readonly T[] | undefined +): T | undefined { + if (guardrail.isByo) return undefined; + return definitions?.find( + (definition) => + definition.validator === guardrail.validator && definition.byoValidatorName === undefined + ); +} + +/** Whether a BYO configuration is gone. `undefined` definitions (still loading) report `false`. */ +export function isCentralizedGuardrailConfigMissing( + guardrail: CentralizedGuardrailIdentity, + definitions: readonly CentralizedGuardrailDefinition[] | undefined +): boolean { + if (!guardrail.isByo || definitions === undefined) return false; + return findCentralizedByoDefinition(guardrail, definitions) === undefined; +} + +/** + * A BYO guardrail never borrows curated copy, since its connector may reuse a built-in's + * validator id; a built-in's comes from the curated table, as the tenant may have no definition. + */ +export function getCentralizedGuardrailDisplay( + guardrail: CentralizedGuardrailIdentity, + { + definition, + copy, + }: { definition?: CentralizedGuardrailDefinition; copy?: GuardrailCopyTable } = {} +): { name: string; description?: string } { + const curated = guardrail.isByo ? undefined : copy?.[guardrail.validator]; + const description = guardrail.isByo ? definition?.description : curated?.description; + return { + name: guardrail.name ?? curated?.displayName ?? guardrail.validator, + description: description === undefined || description === '' ? undefined : description, + }; +} + +/** Labels the parameter resolver needs when neither a definition nor the value supplies one. */ +export interface CentralizedParameterFallbackLabels { + enabled: string; + disabled: string; + entities: string; + thresholds: string; +} + +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +const asNumberRecord = (value: unknown): Record => + isPlainObject(value) + ? Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number' + ) + ) + : {}; + +/** Lifts a built-in's `entities` / `entityThresholds` onto the parameter shape. */ +function liftBuiltInConfiguration( + guardrail: CentralizedGuardrail, + definition: CentralizedGuardrailDefinition | undefined, + labels: CentralizedParameterFallbackLabels +): { + parameters: CentralizedGuardrailParameter[]; + definitions: CentralizedGuardrailParameterDefinition[]; +} { + const declared = definition?.parameters ?? []; + const thresholdsDefinition = declared.find((parameter) => parameter.type === 'map-enum'); + const entitiesDefinition = thresholdsDefinition?.keySource + ? declared.find((parameter) => parameter.id === thresholdsDefinition.keySource) + : declared.find((parameter) => parameter.type === 'enum-list'); + + const entitiesId = entitiesDefinition?.id ?? 'entities'; + const thresholdsId = thresholdsDefinition?.id ?? 'entityThresholds'; + + const parameters: CentralizedGuardrailParameter[] = []; + if (guardrail.entities != null) { + parameters.push({ id: entitiesId, value: guardrail.entities }); + } + if (guardrail.entityThresholds != null) { + parameters.push({ id: thresholdsId, value: guardrail.entityThresholds }); + } + + const definitions: CentralizedGuardrailParameterDefinition[] = + declared.length > 0 + ? declared + : [ + { id: entitiesId, type: 'enum-list', label: labels.entities }, + { id: thresholdsId, type: 'map-enum', label: labels.thresholds, keySource: entitiesId }, + ]; + + return { parameters, definitions }; +} + +/** + * A centralized guardrail's configuration as display rows, for either origin. A plain object is + * a threshold table and anything else a value, whatever the unvalidated `parameterType` says. + */ +export function resolveCentralizedGuardrailParameters( + guardrail: CentralizedGuardrail, + { + definition, + labels, + }: { definition?: CentralizedGuardrailDefinition; labels: CentralizedParameterFallbackLabels } +): CentralizedGuardrailParameterRow[] { + const { parameters, definitions } = guardrail.isByo + ? { parameters: guardrail.parameters ?? [], definitions: definition?.parameters ?? [] } + : liftBuiltInConfiguration(guardrail, definition, labels); + + const valuesById = new Map(parameters.map((parameter) => [parameter.id, parameter.value])); + const definitionsById = new Map( + definitions.map((parameterDefinition) => [parameterDefinition.id, parameterDefinition]) + ); + + const rendersAsThresholds = (id: string): boolean => isPlainObject(valuesById.get(id)); + + // Own properties only, as in `definitions-enrich.ts`: `option` is wire data, and a bare + // lookup of `constructor` or `__proto__` returns something React cannot render. + const optionLabel = ( + parameterDefinition: CentralizedGuardrailParameterDefinition | undefined, + option: string + ): string => { + const optionLabels = parameterDefinition?.optionLabels; + const label = + optionLabels !== undefined && Object.hasOwn(optionLabels, option) + ? optionLabels[option] + : undefined; + return typeof label === 'string' ? label : option; + }; + + const formatValue = ( + parameterDefinition: CentralizedGuardrailParameterDefinition | undefined, + value: unknown + ): string => { + if (typeof value === 'boolean') return value ? labels.enabled : labels.disabled; + if (Array.isArray(value)) { + return asStringArray(value) + .map((item) => optionLabel(parameterDefinition, item)) + .join(', '); + } + if (value === null || value === undefined) return ''; + return typeof value === 'string' ? optionLabel(parameterDefinition, value) : String(value); + }; + + // A threshold map absorbs its `keySource` list, but only when it has a value to render. + const consumedIds = new Set(); + for (const parameterDefinition of definitions) { + if ( + parameterDefinition.keySource !== undefined && + rendersAsThresholds(parameterDefinition.id) + ) { + consumedIds.add(parameterDefinition.keySource); + } + } + + const toThresholdRow = ( + id: string, + parameterDefinition: CentralizedGuardrailParameterDefinition | undefined + ): CentralizedGuardrailParameterRow | undefined => { + const thresholds = asNumberRecord(valuesById.get(id)); + const keySourceDefinition = + parameterDefinition?.keySource !== undefined + ? definitionsById.get(parameterDefinition.keySource) + : undefined; + const keySourceList = + keySourceDefinition?.type === 'enum-list' ? keySourceDefinition : undefined; + const selectedKeys = keySourceList ? asStringArray(valuesById.get(keySourceList.id)) : []; + const keys = Array.from(new Set([...Object.keys(thresholds), ...selectedKeys])); + if (keys.length === 0) return undefined; + + return { + id, + kind: 'thresholds', + label: parameterDefinition?.label ?? labels.thresholds, + thresholds: keys.map((key) => ({ + key, + label: optionLabel(keySourceList, key), + value: Object.hasOwn(thresholds, key) ? thresholds[key] : undefined, + })), + }; + }; + + const toValueRow = ( + id: string, + parameterDefinition: CentralizedGuardrailParameterDefinition | undefined + ): CentralizedGuardrailParameterRow | undefined => { + const value = formatValue(parameterDefinition, valuesById.get(id)); + if (value === '') return undefined; + return { id, kind: 'value', label: parameterDefinition?.label ?? id, value }; + }; + + // Declared order first, then values with no definition, so nothing persisted is hidden. + const orderedIds = Array.from( + new Set([ + ...definitions.map((parameterDefinition) => parameterDefinition.id), + ...parameters.map((parameter) => parameter.id), + ]) + ); + + return orderedIds + .filter((id) => !consumedIds.has(id) && valuesById.has(id)) + .map((id) => { + const parameterDefinition = definitionsById.get(id); + return rendersAsThresholds(id) + ? toThresholdRow(id, parameterDefinition) + : toValueRow(id, parameterDefinition); + }) + .filter((row): row is CentralizedGuardrailParameterRow => row !== undefined); +} + +/** Localized execution-stage label; an unknown stage renders raw. */ +export function formatCentralizedExecutionStage( + executionStage: string, + labels: CentralizedGuardrailsLabels +): string { + switch (executionStage) { + case 'Pre': + return labels.stagePre; + case 'Post': + return labels.stagePost; + case 'Both': + return labels.stageBoth; + default: + return executionStage; + } +} + +/** Localized scope label. */ +export function formatCentralizedScope( + scope: GuardrailScope, + labels: CentralizedGuardrailsLabels +): string { + switch (scope) { + case 'Agent': + return labels.scopeAgent; + case 'Llm': + return labels.scopeLlm; + case 'Tool': + return labels.scopeTool; + default: + return scope; + } +} + +/** Localized action label. */ +export function formatCentralizedAction( + action: CentralizedGuardrailActionType, + labels: CentralizedGuardrailsLabels +): string { + switch (action) { + case 'block': + return labels.actionBlock; + case 'escalate': + return labels.actionEscalate; + case 'filter': + return labels.actionFilter; + case 'log': + return labels.actionLog; + default: + return action; + } +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx new file mode 100644 index 000000000..6104e58e9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.test.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { ApI18nProvider } from '../../../i18n'; +import { CentralizedGuardrailsSection } from './centralized-guardrails-section'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from './centralized-types'; + +const guardrail = (overrides: Partial = {}): CentralizedGuardrail => ({ + validator: 'pii_detection', + executionStage: 'Pre', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: false, + scopes: ['Agent', 'Llm'], + action: 'block', + ...overrides, +}); + +const byoGuardrail = (overrides: Partial = {}): CentralizedGuardrail => + guardrail({ isByo: true, name: 'Acme PII', validator: 'pii_detection', ...overrides }); + +const BYO_DEFINITION: CentralizedGuardrailDefinition = { + validator: 'pii_detection', + byoValidatorName: 'Acme PII', + byoConnectorName: 'Acme Security', + description: 'Acme runs its own detector.', +}; + +describe('CentralizedGuardrailsSection', () => { + it('renders nothing at all when the policy enforces none, as both products do', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders an explicit empty state, including one the host deliberately set to null', () => { + const { container, rerender } = render( + Nothing enforced

} + /> + ); + expect(screen.getByText('Nothing enforced')).toBeInTheDocument(); + + rerender( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('names the policy in the caption, emphasized', () => { + render(); + + const emphasis = screen.getByText('Acme policy'); + expect(emphasis.tagName).toBe('EM'); + expect(emphasis.parentElement).toHaveTextContent( + 'Enforced by AI Trust Layer policy: Acme policy' + ); + }); + + it('names a built-in validator from the canonical copy table', () => { + render(); + + expect(screen.getByText('PII detection')).toBeInTheDocument(); + expect(screen.getByText('UiPath managed')).toBeInTheDocument(); + }); + + it('names a BYO guardrail from the policy and its connector from the definition', () => { + render( + + ); + + expect(screen.getByText('Acme PII')).toBeInTheDocument(); + expect(screen.getByText('BYO')).toBeInTheDocument(); + expect(screen.getByText('Provider: Acme Security')).toBeInTheDocument(); + expect(screen.getByText('Acme runs its own detector.')).toBeInTheDocument(); + }); + + it('localizes the scopes and the execution stage without a formatScope prop', () => { + render(); + + expect( + screen.getByText('Scopes: Agent, LLM calls · Execution stage: Pre-execution') + ).toBeInTheDocument(); + }); + + it('lets a host replace the scope names', () => { + render( + scope.toUpperCase()} + /> + ); + + expect( + screen.getByText('Scopes: AGENT, LLM · Execution stage: Pre-execution') + ).toBeInTheDocument(); + }); + + it('says nothing about a missing configuration while the catalog is still loading', () => { + render(); + + expect(screen.queryByText('Unavailable')).not.toBeInTheDocument(); + }); + + it('chips and explains a configuration the catalog no longer has', () => { + render( + + ); + + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + expect( + screen.getByText(/could not be found .* Contact your administrator/) + ).toBeInTheDocument(); + }); + + it('chips and explains a disabled configuration', () => { + render( + + ); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + expect( + screen.getByText(/configuration has been disabled\. Contact your administrator/) + ).toBeInTheDocument(); + }); + + it('reports the selected guardrail, and opening the details stays the host’s job', async () => { + const onSelect = vi.fn(); + const one = guardrail(); + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /View details for PII detection/ })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(one); + }); + + it('keeps the whole row in the accessible name, not just the "view details" phrase', () => { + render( + + ); + + // The remediation message is the reason this matters: an `aria-label` would hide it. + expect( + screen.getByRole('button', { name: /Contact your administrator to fix the AI Trust Layer/ }) + ).toBeInTheDocument(); + }); + + it('is not a control at all when there is nothing to open', () => { + render(); + + expect(screen.queryByRole('button', { name: /View details/ })).not.toBeInTheDocument(); + expect(screen.getByText('PII detection')).toBeInTheDocument(); + }); + + it('renders the documentation link inside the info popover, only when the host supplies one', async () => { + // The popover opens on focus as well as hover, which is what makes the link reachable + // from the keyboard at all. + const { rerender } = render( + + ); + await userEvent.tab(); + expect( + screen.queryByRole('link', { name: 'View centralized guardrails documentation' }) + ).not.toBeInTheDocument(); + + rerender( + + ); + expect( + await screen.findByRole('link', { name: 'View centralized guardrails documentation' }) + ).toHaveAttribute('href', 'https://docs.example.com/guardrails'); + }); + + it('drops its own chrome for a host that brings its own section header', () => { + render( + + ); + + expect(screen.queryByText('Centralized guardrails')).not.toBeInTheDocument(); + expect(screen.getByText('PII detection')).toBeInTheDocument(); + }); + + it('keeps one row per guardrail when a policy enforces a validator at two stages', () => { + render( + + ); + + expect(screen.getAllByRole('button', { name: /View details for PII detection/ })).toHaveLength( + 2 + ); + }); + + it('translates through the ambient catalog', () => { + render( + + + + ); + + // The row's metadata line, through the builder ids this block reuses. The section's own + // ids render English until `chore(l10n): sync from Localization` reaches them. + expect(screen.getByText(/スコープ/)).toBeInTheDocument(); + }); + + it('has no axe violations', async () => { + const { container } = render( + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx new file mode 100644 index 000000000..51962edc2 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails-section.tsx @@ -0,0 +1,146 @@ +import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger } from '@uipath/apollo-wind'; +import { Info, ShieldCheck } from 'lucide-react'; +import type * as React from 'react'; +import type { GuardrailScope } from './builder-types'; +import { + findCentralizedByoDefinition, + formatCentralizedScope, + getCentralizedGuardrailDisplay, + getCentralizedGuardrailItemId, + isCentralizedGuardrailConfigMissing, +} from './centralized-guardrail-utils'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from './centralized-types'; +import { CentralizedGuardrailRow } from './components/centralized-guardrail-row'; +import { useGuardrailDefinitionCopy } from './definitions-copy'; +import type { CentralizedGuardrailsLabels } from './i18n'; +import { useCentralizedGuardrailsLabels } from './i18n'; + +export interface CentralizedGuardrailsSectionProps< + TDefinition extends CentralizedGuardrailDefinition = CentralizedGuardrailDefinition, +> { + /** Already filtered by `getApplicableCentralizedGuardrails`. */ + guardrails: CentralizedGuardrail[]; + /** Name of the AI Trust Layer policy enforcing them; shown in the caption. */ + policyName: string; + /** For provider names and configuration status. `undefined` while the catalog loads. */ + definitions?: TDefinition[]; + /** The host opens the details (a dialog, a panel overlay). */ + onSelect?: (guardrail: CentralizedGuardrail) => void; + /** Documentation link inside the info popover. */ + docsHref?: string; + /** Replace the localized scope names. Defaults to the family's own scope labels. */ + formatScope?: (scope: GuardrailScope) => string; + /** Rendered when there are no guardrails. Defaults to nothing. */ + emptyState?: React.ReactNode; + /** Drop the card's border and padding, for a host nesting this in its own section chrome. */ + unstyled?: boolean; + /** Drop the heading, info popover and policy caption; the host supplies its own. */ + hideHeader?: boolean; + labels?: Partial; + className?: string; +} + +/** Splits the caption around its `{{policyName}}` token so the name can be emphasized. */ +function PolicyCaption({ template, policyName }: { template: string; policyName: string }) { + const [before, after] = template.split('{{policyName}}'); + return ( +

+ {before} + {policyName} + {/* A translation that dropped the token still renders the name, after the text. */} + {after} +

+ ); +} + +/** The read-only guardrails an AI Trust Layer policy enforces on an agent. */ +export function CentralizedGuardrailsSection< + TDefinition extends CentralizedGuardrailDefinition = CentralizedGuardrailDefinition, +>({ + guardrails, + policyName, + definitions, + onSelect, + docsHref, + formatScope, + emptyState, + unstyled = false, + hideHeader = false, + labels: labelOverrides, + className, +}: CentralizedGuardrailsSectionProps) { + const labels = useCentralizedGuardrailsLabels(labelOverrides); + const copy = useGuardrailDefinitionCopy(); + + if (guardrails.length === 0) { + return emptyState === undefined ? null : <>{emptyState}; + } + + return ( +
+ {!hideHeader && ( +
+
+
+ +
+ )} + +
+ {guardrails.map((guardrail) => { + const definition = findCentralizedByoDefinition(guardrail, definitions); + const { name, description } = getCentralizedGuardrailDisplay(guardrail, { + definition, + copy, + }); + return ( + formatScope?.(scope) ?? formatCentralizedScope(scope, labels) + )} + labels={labels} + onSelect={onSelect === undefined ? undefined : () => onSelect(guardrail)} + /> + ); + })} +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx new file mode 100644 index 000000000..3a243f92e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-guardrails.stories.tsx @@ -0,0 +1,305 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + TooltipProvider, +} from '@uipath/apollo-wind'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import { CentralizedGuardrailDetails } from './centralized-guardrail-details'; +import { CentralizedGuardrailsSection } from './centralized-guardrails-section'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from './centralized-types'; + +const meta = { + title: 'Components/UiPath/Centralized Guardrails', + component: CentralizedGuardrailsSection, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +The read-only list of guardrails an organization's AI Trust Layer governance policy enforces +on an agent, plus the details content behind a row. + +Nobody edits these in the product, so the section shows what is enforced and reports which row +the user picked. Opening the details is host orchestration: one product uses a dialog, the +other a panel overlay, each with its own header and dismissal, so this package ships the +content and the stories below show both shells. + +A centralized guardrail is its own record rather than a variant of a locally configured one: +no id, scopes at the top level, and a bare action discriminator. Pass the policy in as props. + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const DOCS_HREF = 'https://docs.example.com/centralized-guardrails'; + +const piiGuardrail: CentralizedGuardrail = { + validator: 'pii_detection', + executionStage: 'Pre', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: true, + scopes: ['Agent', 'Llm'], + action: 'block', + entities: ['Email', 'USSocialSecurityNumber', 'CreditCardNumber'], + entityThresholds: { Email: 0.8, USSocialSecurityNumber: 0.95 }, +}; + +const harmfulContentGuardrail: CentralizedGuardrail = { + validator: 'harmful_content', + executionStage: 'Both', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: true, + scopes: ['Llm'], + action: 'escalate', + entityThresholds: { Hate: 2, Violence: 4 }, +}; + +const byoGuardrail: CentralizedGuardrail = { + validator: 'pii_detection', + name: 'Acme strict PII', + isByo: true, + executionStage: 'Post', + appliesToAutonomousAgents: true, + appliesToConversationalAgents: false, + scopes: ['Tool'], + action: 'log', + parameters: [ + { id: 'mode', parameterType: 'enum', value: 'thorough' }, + { id: 'redact', parameterType: 'boolean', value: true }, + { id: 'entityScores', parameterType: 'map-enum', value: { Email: 0.6, IBAN: 0.9 } }, + ], +}; + +const byoDefinition: CentralizedGuardrailDefinition = { + validator: 'pii_detection', + byoValidatorName: 'Acme strict PII', + byoConnectorName: 'Acme Security', + description: 'Acme runs detection against its own corpus before anything leaves the tenant.', + parameters: [ + { + id: 'mode', + type: 'enum', + label: 'Detection mode', + optionLabels: { thorough: 'Thorough', fast: 'Fast' }, + }, + { id: 'redact', type: 'boolean', label: 'Redact matches' }, + { id: 'entityScores', type: 'map-enum', label: 'Confidence scores' }, + ], +}; + +const definitions: CentralizedGuardrailDefinition[] = [ + { + validator: 'pii_detection', + parameters: [ + { + id: 'entities', + type: 'enum-list', + label: 'Entities to detect', + optionLabels: { + Email: 'Email', + USSocialSecurityNumber: 'US Social Security Number (SSN)', + CreditCardNumber: 'Credit Card Number', + }, + }, + { + id: 'entityThresholds', + type: 'map-enum', + label: 'Detection thresholds', + keySource: 'entities', + }, + ], + }, + { + validator: 'harmful_content', + parameters: [ + { + id: 'harmfulContentEntities', + type: 'enum-list', + label: 'Content categories', + optionLabels: { Hate: 'Hate', Violence: 'Violence' }, + }, + { + id: 'harmfulContentEntityThresholds', + type: 'map-enum', + label: 'Severity thresholds', + keySource: 'harmfulContentEntities', + }, + ], + }, + byoDefinition, +]; + +const noop = () => {}; + +export const Default: Story = { + args: { + guardrails: [piiGuardrail, harmfulContentGuardrail, byoGuardrail], + definitions, + policyName: 'Acme production policy', + docsHref: DOCS_HREF, + onSelect: noop, + }, +}; + +export const WithoutDetails: Story = { + name: 'Nothing to open', + args: { ...Default.args, onSelect: undefined }, + parameters: { + docs: { + description: { + story: + 'Without `onSelect` a row is plain text rather than a disabled button, so it keeps its place in the reading order and out of the tab order.', + }, + }, + }, +}; + +export const ConfigurationProblems: Story = { + name: 'Broken configurations', + args: { + guardrails: [byoGuardrail, { ...byoGuardrail, name: 'Acme legacy PII' }], + // The second guardrail names a configuration this tenant no longer has at all. + definitions: [{ ...byoDefinition, status: 'Disabled' }], + policyName: 'Acme production policy', + docsHref: DOCS_HREF, + }, + parameters: { + docs: { + description: { + story: + 'A chip makes a broken row findable in a long policy; the sentence under it says what to do. Both are shown, since an admin scanning for trouble and an admin fixing it need different things.', + }, + }, + }, +}; + +export const StillLoading: Story = { + name: 'Catalog still loading', + args: { + guardrails: [byoGuardrail], + definitions: undefined, + policyName: 'Acme production policy', + }, + parameters: { + docs: { + description: { + story: + 'With `definitions` left undefined the section says nothing about a missing configuration. Passing an empty array means the catalog loaded and the configuration really is gone.', + }, + }, + }, +}; + +export const InsideHostChrome: Story = { + name: 'Inside a host section', + args: { + ...Default.args, + unstyled: true, + hideHeader: true, + }, + decorators: [ + (Story) => ( +
+
Guardrails (host accordion)
+
+ +
+
+ ), + ], +}; + +/** The details content in the dialog one product opens. */ +export const DetailsInADialog: Story = { + name: 'Details in a dialog', + args: Default.args, + render: (args) => { + const [selected, setSelected] = useState(null); + return ( + <> + + setSelected(null)}> + + + Guardrail details + + {selected && ( + + )} + + + + ); + }, + parameters: { + docs: { + description: { + story: + 'The dialog, its title and its dismissal are the host’s. Fifteen lines of shell is the reason this package ships no wrapper.', + }, + }, + }, +}; + +/** The same content in the panel overlay the other product pushes. */ +export const DetailsInAPanel: Story = { + name: 'Details in a panel overlay', + args: Default.args, + render: (args) => { + const [selected, setSelected] = useState(null); + return ( +
+ {selected === null ? ( +
+ +
+ ) : ( +
+
+ + Guardrail details +
+
+ +
+
+ )} +
+ ); + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts new file mode 100644 index 000000000..478fd9d10 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-parity.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { CENTRALIZED_GUARDRAILS_EN_LABELS, type CentralizedGuardrailsLabels } from './i18n'; + +/** + * Pins the centralized section's English against each product, by the rules of + * `definitions-parity.test.ts`. Transcribed from Agents `origin/main` + * (`frontend-sw/src/components/definition/CentralizedGuardrailsSection/*`) and Flow + * `origin/develop` (`packages/canvas/src/components/properties-panel/guardrails/Centralized*`). + */ + +type Host = 'agents' | 'flow'; + +interface HostCopy { + agents?: string; + flow?: string; +} + +/** What each product says today, keyed by our label. `undefined` means it has no equivalent. */ +const HOST_COPY: Partial> = { + title: { agents: 'Centralized guardrails', flow: 'Centralized guardrails' }, + info: { + agents: + "These guardrails are enforced by your organization's AI Trust Layer governance policy and cannot be edited here.", + flow: "Your organization's AI Trust Layer governance policy enforces these guardrails. You cannot edit them here.", + }, + docsLink: { + agents: 'Learn more about centralized guardrails', + flow: 'View centralized guardrails documentation', + }, + policyCaption: { + agents: 'Enforced by AI Trust Layer policy: {{policyName}}', + flow: 'Enforced by AI Trust Layer policy: {{policyName}}', + }, + viewDetails: { agents: 'View details for {{name}}', flow: 'View details for {{name}}' }, + guardrailType: { flow: 'Guardrail type' }, + policyField: { flow: 'AI Trust Layer policy' }, + provider: { agents: 'Provider', flow: 'Provider' }, + description: { agents: 'Description', flow: 'Guardrail description' }, + noDescription: { agents: 'No description available.', flow: 'No description available.' }, + executionStage: { agents: 'Execution stage', flow: 'Execution stage' }, + scopes: { agents: 'Scopes', flow: 'Scopes' }, + action: { agents: 'Action', flow: 'Action' }, + configuration: { agents: 'Configuration' }, + managedMessage: { + agents: + "This configuration is managed by your organization's AI Trust Layer governance policy and cannot be edited here.", + flow: "Your organization's AI Trust Layer governance policy manages this configuration. You cannot edit it here.", + }, + originByo: { agents: 'BYO', flow: 'BYO' }, + originUiPath: { agents: 'UiPath managed', flow: 'UiPath managed' }, + missingConfigMessage: { + agents: + "This guardrail's configuration could not be found — it may have been deleted. Contact your administrator to fix the AI Trust Layer policy.", + flow: "This guardrail's configuration could not be found — it may have been deleted. Contact your administrator to fix the AI Trust Layer policy.", + }, + disabledConfigMessage: { + agents: + "This guardrail's configuration has been disabled. Contact your administrator to re-enable it.", + flow: "This guardrail's configuration has been disabled. Contact your administrator to re-enable it.", + }, + stagePre: { agents: 'Pre-execution', flow: 'Pre-execution' }, + stagePost: { agents: 'Post-execution', flow: 'Post-execution' }, + stageBoth: { agents: 'Pre & post-execution', flow: 'Pre & post-execution' }, + scopeAgent: { agents: 'Agent', flow: 'Agent' }, + scopeLlm: { agents: 'LLM calls', flow: 'LLM calls' }, + scopeTool: { agents: 'Tools', flow: 'Tools' }, + actionBlock: { agents: 'Block', flow: 'Block' }, + actionEscalate: { agents: 'Escalate', flow: 'Escalate' }, + actionFilter: { agents: 'Filter', flow: 'Filter' }, + actionLog: { agents: 'Log', flow: 'Log' }, + parameterEnabled: { agents: 'Enabled', flow: 'Enabled' }, + parameterDisabled: { agents: 'Disabled', flow: 'Disabled' }, + entitiesFallback: { agents: 'Entities to detect', flow: 'Entities to detect' }, + thresholdsFallback: { agents: 'Detection threshold', flow: 'Detection thresholds' }, +}; + +interface CopyDivergence { + label: keyof CentralizedGuardrailsLabels; + chosen: Host; + reason: string; +} + +const EXPECTED_DIVERGENCES: CopyDivergence[] = [ + { + label: 'info', + chosen: 'flow', + reason: + 'Active voice and two short sentences; Agents buries the subject in a relative clause. Also the only one of the pair that is translated anywhere', + }, + { + label: 'docsLink', + chosen: 'flow', + reason: + 'Agents’ "Learn more about…" reads better, but it ships untranslated in all twelve locales while Flow’s is at 100%, and an untranslated link label is worse than a plainer one', + }, + { + label: 'description', + chosen: 'flow', + reason: + 'Matches the builder’s own field label, whose id this reuses, so one string covers both screens', + }, + { + label: 'managedMessage', + chosen: 'flow', + reason: 'Same voice as the section’s info text, which is also Flow’s', + }, + { + label: 'thresholdsFallback', + chosen: 'flow', + reason: + 'Plural: this labels a list of per-entity thresholds, not one column header as in Agents’ table', + }, +]; + +/** Labels only one product has; adopting it costs the other nothing. */ +const SINGLE_SOURCE: Array = [ + 'guardrailType', + 'policyField', + 'configuration', +]; + +/** Neither product's centralized section has these; they are `GuardrailList`'s status chips. */ +const OWN_ADDITIONS: Array = [ + 'statusUnavailable', + 'statusDisabled', +]; + +describe('centralized guardrails copy', () => { + const divergenceFor = (label: keyof CentralizedGuardrailsLabels) => + EXPECTED_DIVERGENCES.find((entry) => entry.label === label); + + it('says exactly what both products say wherever they already agree', () => { + const invented: string[] = []; + for (const [label, hosts] of Object.entries(HOST_COPY) as Array< + [keyof CentralizedGuardrailsLabels, HostCopy] + >) { + if (hosts.agents === undefined || hosts.flow === undefined) continue; + if (hosts.agents !== hosts.flow) continue; + const ours = CENTRALIZED_GUARDRAILS_EN_LABELS[label]; + if (ours !== hosts.agents) + invented.push(`${label}\n both: ${hosts.agents}\n ours: ${ours}`); + } + + expect(invented).toEqual([]); + }); + + it('matches the chosen product verbatim wherever they disagree', () => { + const wrong: string[] = []; + for (const divergence of EXPECTED_DIVERGENCES) { + const chosen = HOST_COPY[divergence.label]?.[divergence.chosen]; + const ours = CENTRALIZED_GUARDRAILS_EN_LABELS[divergence.label]; + if (chosen !== ours) { + wrong.push(`${divergence.label}\n ${divergence.chosen}: ${chosen}\n ours: ${ours}`); + } + } + + expect(wrong).toEqual([]); + }); + + it('declares every disagreement, so a silent third wording cannot slip in', () => { + const undeclared: string[] = []; + for (const [label, hosts] of Object.entries(HOST_COPY) as Array< + [keyof CentralizedGuardrailsLabels, HostCopy] + >) { + const disagree = + hosts.agents !== undefined && hosts.flow !== undefined && hosts.agents !== hosts.flow; + if (disagree && divergenceFor(label) === undefined) undeclared.push(label); + if (!disagree && divergenceFor(label) !== undefined) { + undeclared.push(`${label} (declared, but the products agree)`); + } + } + + expect(undeclared).toEqual([]); + }); + + it('accounts for every string this component owns', () => { + const unaccounted = ( + Object.keys(CENTRALIZED_GUARDRAILS_EN_LABELS) as Array + ).filter( + (label) => + HOST_COPY[label] === undefined && + !SINGLE_SOURCE.includes(label) && + !OWN_ADDITIONS.includes(label) + ); + + expect(unaccounted).toEqual([]); + }); + + it('adopts a single-source label verbatim from the product that has it', () => { + for (const label of SINGLE_SOURCE) { + const hosts = HOST_COPY[label]; + const only = hosts?.agents ?? hosts?.flow; + expect(CENTRALIZED_GUARDRAILS_EN_LABELS[label]).toBe(only); + } + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/centralized-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/centralized-types.ts new file mode 100644 index 000000000..3894933a9 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/centralized-types.ts @@ -0,0 +1,66 @@ +import type { GuardrailScope } from './builder-types'; + +// Structural mirrors of both products' policy schemas, so a host passes its own zod-inferred +// types unmapped. + +/** Agents' `ActionType` string enum assigns to this union as well as Flow's `z.enum`. */ +export type CentralizedGuardrailActionType = 'block' | 'escalate' | 'filter' | 'log'; + +/** A BYO guardrail's configured parameter; `parameterType` and `value` are unvalidated. */ +export interface CentralizedGuardrailParameter { + id: string; + parameterType?: string | null; + value?: unknown; +} + +/** One guardrail enforced by the organization's AI Trust Layer policy. */ +export interface CentralizedGuardrail { + validator: string; + /** Admin-given display name. Only set for BYO guardrails; it is also their identity. */ + name?: string | null; + /** Tells a BYO entry apart from a built-in validator sharing the same `validator` id. */ + isByo?: boolean | null; + /** `Pre` / `Post` / `Both`, typed open as in both products' schemas. */ + executionStage: string; + appliesToAutonomousAgents: boolean; + appliesToConversationalAgents: boolean; + scopes: GuardrailScope[]; + action: CentralizedGuardrailActionType; + /** Built-in validators only: the detected entities, and their per-entity thresholds. */ + entities?: string[] | null; + entityThresholds?: Record | null; + /** BYO only: connector-specific configuration, passed through from the policy. */ + parameters?: CentralizedGuardrailParameter[] | null; +} + +/** The parameter definition fields this component reads, so any host's shape satisfies it. */ +export interface CentralizedGuardrailParameterDefinition { + id: string; + type: string; + /** Pre-resolved display label; falls back to the parameter id. */ + label?: string; + /** Keyed by the raw wire value. */ + optionLabels?: Record; + /** For `map-enum`: id of the sibling `enum-list` whose selection provides the keys. */ + keySource?: string; +} + +/** The definition fields this component reads; the same array the palette and list take. */ +export interface CentralizedGuardrailDefinition { + validator: string; + status?: string; + description?: string; + byoValidatorName?: string; + byoConnectorName?: string; + parameters?: CentralizedGuardrailParameterDefinition[]; +} + +/** One resolved row of a centralized guardrail's configuration, ready to render. */ +export type CentralizedGuardrailParameterRow = + | { id: string; label: string; kind: 'value'; value: string } + | { + id: string; + label: string; + kind: 'thresholds'; + thresholds: Array<{ key: string; label: string; value: number | undefined }>; + }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-detail-field.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-detail-field.tsx new file mode 100644 index 000000000..027fb8e5a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-detail-field.tsx @@ -0,0 +1,22 @@ +import { cn } from '@uipath/apollo-wind'; +import type * as React from 'react'; + +export interface CentralizedDetailFieldProps { + label: string; + className?: string; + children: React.ReactNode; +} + +/** One labelled field of a read-only centralized guardrail, as a `
` group. */ +export function CentralizedDetailField({ + label, + className, + children, +}: CentralizedDetailFieldProps) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-origin-chip.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-origin-chip.tsx new file mode 100644 index 000000000..f881749be --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-origin-chip.tsx @@ -0,0 +1,23 @@ +import type { CentralizedGuardrailsLabels } from '../i18n'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +export interface CentralizedGuardrailOriginChipProps { + isByo?: boolean | null; + labels: CentralizedGuardrailsLabels; +} + +/** BYO or UiPath-managed: a connector can reuse a built-in's validator id, so rows look alike. */ +export function CentralizedGuardrailOriginChip({ + isByo, + labels, +}: CentralizedGuardrailOriginChipProps) { + return isByo ? ( + + {labels.originByo} + + ) : ( + + {labels.originUiPath} + + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-parameters.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-parameters.tsx new file mode 100644 index 000000000..c128402ed --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-parameters.tsx @@ -0,0 +1,53 @@ +import type { CentralizedGuardrailParameterRow } from '../centralized-types'; +import { CentralizedDetailField } from './centralized-detail-field'; + +export interface CentralizedGuardrailParametersProps { + rows: CentralizedGuardrailParameterRow[]; + /** Heading above the block; omitted when the caller supplies its own. */ + heading?: string; +} + +/** Placeholder for a key the policy selected but gave no threshold. */ +const UNSET_THRESHOLD = '—'; + +/** + * A centralized guardrail's configuration as text, not disabled inputs: the MetadataForm + * editors have no read-only mode, and a disabled input cannot be focused or copied. + */ +export function CentralizedGuardrailParameters({ + rows, + heading, +}: CentralizedGuardrailParametersProps) { + if (rows.length === 0) return null; + + return ( +
+ {heading !== undefined &&

{heading}

} +
+ {rows.map((row) => + row.kind === 'value' ? ( + + {row.value} + + ) : ( + +
    + {row.thresholds.map((threshold) => ( +
  • + {threshold.label} + + {threshold.value ?? UNSET_THRESHOLD} + +
  • + ))} +
+
+ ) + )} +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx new file mode 100644 index 000000000..e7cd5f55b --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/centralized-guardrail-row.tsx @@ -0,0 +1,124 @@ +import { cn } from '@uipath/apollo-wind'; +import { ChevronRight } from 'lucide-react'; +import { formatCentralizedExecutionStage } from '../centralized-guardrail-utils'; +import type { CentralizedGuardrail, CentralizedGuardrailDefinition } from '../centralized-types'; +import type { CentralizedGuardrailsLabels } from '../i18n'; +import { formatGuardrailFormMessage } from '../i18n'; +import { CentralizedGuardrailOriginChip } from './centralized-guardrail-origin-chip'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +export interface CentralizedGuardrailRowProps { + guardrail: CentralizedGuardrail; + name: string; + description?: string; + definition?: CentralizedGuardrailDefinition; + isConfigMissing: boolean; + /** Localized scope names, already resolved and ordered. */ + scopeNames: string[]; + labels: CentralizedGuardrailsLabels; + onSelect?: () => void; +} + +/** + * One centralized guardrail, styled like `GuardrailListRow`. With `onSelect` the whole row is + * a ` + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts index 7e67e1795..d4f176719 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/i18n.test.ts @@ -1,18 +1,47 @@ import { describe, expect, it } from 'vitest'; import { findCatalogDrift, findCatalogOrphans } from './__fixtures__/catalog-coverage'; import { + CENTRALIZED_GUARDRAILS_EN_LABELS, + CENTRALIZED_GUARDRAILS_EN_MESSAGES, formatGuardrailFormMessage, + GUARDRAIL_BUILDER_EN_LABELS, + GUARDRAIL_FORM_EN_LABELS, GUARDRAIL_LIST_EN_LABELS, GUARDRAIL_LIST_EN_MESSAGES, GUARDRAIL_PALETTE_EN_LABELS, GUARDRAIL_PALETTE_EN_MESSAGES, GUARDRAIL_REMOVE_DIALOG_EN_LABELS, GUARDRAIL_REMOVE_DIALOG_EN_MESSAGES, + resolveCentralizedGuardrailsLabels, + resolveGuardrailBuilderLabels, + resolveGuardrailFormLabels, resolveGuardrailListLabels, resolveGuardrailPaletteLabels, resolveGuardrailRemoveDialogLabels, } from './i18n'; +/** + * Ids resolved from the builder's and the list's message blocks rather than twinned here. They + * name the same thing with the same English, so a second id would reach translators twice and + * drift. + */ +const REUSED_IDS = [ + 'guardrails.builder.type-label', + 'guardrails.builder.description-label', + 'guardrails.builder.scopes-label', + 'guardrails.builder.scope-agent-label', + 'guardrails.builder.scope-llm-label', + 'guardrails.builder.scope-tool-label', + 'guardrails.builder.action-block-label', + 'guardrails.builder.action-escalate-label', + 'guardrails.builder.action-filter-label', + 'guardrails.builder.action-log-label', + 'guardrails.list.provider', + 'guardrails.list.byo', + 'guardrails.list.status-unavailable', + 'guardrails.list.status-disabled', +]; + describe('resolveGuardrailListLabels', () => { it('returns the English defaults when there is nothing to merge', () => { expect(resolveGuardrailListLabels()).toEqual(GUARDRAIL_LIST_EN_LABELS); @@ -119,6 +148,60 @@ describe('GUARDRAIL_REMOVE_DIALOG_EN_LABELS', () => { }); }); +describe('resolveCentralizedGuardrailsLabels', () => { + it('returns the English defaults when there is nothing to merge', () => { + expect(resolveCentralizedGuardrailsLabels()).toEqual(CENTRALIZED_GUARDRAILS_EN_LABELS); + }); + + it('layers the catalog over the defaults and the overrides over both', () => { + const labels = resolveCentralizedGuardrailsLabels( + { title: 'Zentralisierte Leitplanken', provider: 'Anbieter' }, + { provider: 'Provider (host)' } + ); + + expect(labels.title).toBe('Zentralisierte Leitplanken'); + expect(labels.provider).toBe('Provider (host)'); + expect(labels.scopes).toBe(CENTRALIZED_GUARDRAILS_EN_LABELS.scopes); + }); + + it('never lets an absent string blank a default', () => { + const labels = resolveCentralizedGuardrailsLabels( + { title: undefined }, + { originUiPath: undefined } + ); + + expect(labels.title).toBe('Centralized guardrails'); + expect(labels.originUiPath).toBe('UiPath managed'); + }); + + it('keeps the English templates in the `{{token}}` convention the component formats', () => { + expect(CENTRALIZED_GUARDRAILS_EN_LABELS.policyCaption).toContain('{{policyName}}'); + expect(CENTRALIZED_GUARDRAILS_EN_LABELS.viewDetails).toContain('{{name}}'); + // The catalogs store the ICU source instead, which is what translators receive. + expect(CENTRALIZED_GUARDRAILS_EN_MESSAGES['guardrails.centralized.policy-caption']).toContain( + '{policyName}' + ); + }); +}); + +describe('the other label sets still layer the same way', () => { + // Every resolver shares one `mergeLabels`, so one case per set is enough to catch a wiring + // mistake in the shared helper. + it('merges builder labels', () => { + const labels = resolveGuardrailBuilderLabels({ save: 'Speichern' }, { cancel: undefined }); + + expect(labels.save).toBe('Speichern'); + expect(labels.cancel).toBe(GUARDRAIL_BUILDER_EN_LABELS.cancel); + }); + + it('merges validator form labels', () => { + const labels = resolveGuardrailFormLabels({ addItem: 'Hinzufügen' }, { addItem: 'Add row' }); + + expect(labels.addItem).toBe('Add row'); + expect(labels.enumPlaceholder).toBe(GUARDRAIL_FORM_EN_LABELS.enumPlaceholder); + }); +}); + describe('the shared canvas catalog', () => { // Shared with every component's i18n test; `__fixtures__/catalog-coverage` says why these // two scans and not a translation-coverage one. @@ -150,4 +233,28 @@ describe('the shared canvas catalog', () => { findCatalogOrphans(GUARDRAIL_REMOVE_DIALOG_EN_MESSAGES, 'guardrails.remove-dialog.') ).toEqual([]); }); + + it('carries every centralized message with the same English', () => { + expect(findCatalogDrift(CENTRALIZED_GUARDRAILS_EN_MESSAGES)).toEqual({ + missing: [], + drifted: [], + }); + }); + + it('carries no centralized message the source no longer declares', () => { + expect( + findCatalogOrphans(CENTRALIZED_GUARDRAILS_EN_MESSAGES, 'guardrails.centralized.') + ).toEqual([]); + }); + + it('resolves the reused ids from the blocks that own them, with identical English', () => { + const reused: Record = {}; + for (const id of REUSED_IDS) { + const message = CENTRALIZED_GUARDRAILS_EN_MESSAGES[id]; + expect(message).toBeDefined(); + reused[id] = message as string; + } + + expect(findCatalogDrift(reused)).toEqual({ missing: [], drifted: [] }); + }); }); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts b/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts index c4d977da8..92eecc5f3 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/i18n.ts @@ -244,6 +244,7 @@ const TEMPLATE_TOKENS = { min: '{{min}}', max: '{{max}}', toolName: '{{toolName}}', + policyName: '{{policyName}}', }; /** Localized chrome strings of the validator form; per-string `overrides` always win. */ @@ -841,3 +842,200 @@ export function useGuardrailRemoveDialogLabels( [_, overrides] ); } + +/** + * Chrome strings of the centralized section and its details. Validator names and descriptions + * come from `definitions-copy.ts` or the connector. + */ +export interface CentralizedGuardrailsLabels { + /** Section heading. */ + title: string; + /** Body of the section's info popover. */ + info: string; + /** Link text inside the info popover; rendered only when the host passes `docsHref`. */ + docsLink: string; + /** Caption under the heading; `{{policyName}}` is emphasized where it lands. */ + policyCaption: string; + /** Accessible name of a row; `{{name}}` is the guardrail's display name. */ + viewDetails: string; + // Row and detail fields + guardrailType: string; + policyField: string; + provider: string; + description: string; + noDescription: string; + executionStage: string; + scopes: string; + action: string; + configuration: string; + /** Advisory shown above the read-only details. */ + managedMessage: string; + // Origin chips + originByo: string; + originUiPath: string; + // Configuration problems + statusUnavailable: string; + statusDisabled: string; + missingConfigMessage: string; + disabledConfigMessage: string; + // Scopes and actions + scopeAgent: string; + scopeLlm: string; + scopeTool: string; + actionBlock: string; + actionEscalate: string; + actionFilter: string; + actionLog: string; + // Execution stages + stagePre: string; + stagePost: string; + stageBoth: string; + // Configuration values + parameterEnabled: string; + parameterDisabled: string; + /** Fallback label for the entity list when no definition names it. */ + entitiesFallback: string; + /** Fallback label for the threshold map when no definition names it. */ + thresholdsFallback: string; +} + +/** The subset of `useSafeLingui`'s translator the centralized labels need. */ +type CentralizedTranslate = (descriptor: { + id: string; + message: string; + values?: Record; +}) => string; + +// One builder holds every `_({ id, message })` call, so the English defaults, the flat record +// the catalog test diffs and the runtime lingui path cannot drift. Same shape as +// `definitions-copy.ts`. +// Labels the builder or the list already declare reuse their ids, so the two cannot drift. +function buildCentralizedGuardrailsLabels(_: CentralizedTranslate): CentralizedGuardrailsLabels { + return { + title: _({ id: 'guardrails.centralized.title', message: 'Centralized guardrails' }), + info: _({ + id: 'guardrails.centralized.info', + message: + "Your organization's AI Trust Layer governance policy enforces these guardrails. You cannot edit them here.", + }), + docsLink: _({ + id: 'guardrails.centralized.docs-link', + message: 'View centralized guardrails documentation', + }), + policyCaption: _({ + id: 'guardrails.centralized.policy-caption', + message: 'Enforced by AI Trust Layer policy: {policyName}', + values: TEMPLATE_TOKENS, + }), + viewDetails: _({ + id: 'guardrails.centralized.view-details', + message: 'View details for {name}', + values: TEMPLATE_TOKENS, + }), + guardrailType: _({ id: 'guardrails.builder.type-label', message: 'Guardrail type' }), + policyField: _({ + id: 'guardrails.centralized.policy-field', + message: 'AI Trust Layer policy', + }), + provider: _({ id: 'guardrails.list.provider', message: 'Provider' }), + description: _({ + id: 'guardrails.builder.description-label', + message: 'Guardrail description', + }), + noDescription: _({ + id: 'guardrails.centralized.no-description', + message: 'No description available.', + }), + executionStage: _({ + id: 'guardrails.centralized.execution-stage', + message: 'Execution stage', + }), + scopes: _({ id: 'guardrails.builder.scopes-label', message: 'Scopes' }), + action: _({ id: 'guardrails.centralized.action', message: 'Action' }), + configuration: _({ id: 'guardrails.centralized.configuration', message: 'Configuration' }), + managedMessage: _({ + id: 'guardrails.centralized.managed-message', + message: + "Your organization's AI Trust Layer governance policy manages this configuration. You cannot edit it here.", + }), + originByo: _({ id: 'guardrails.list.byo', message: 'BYO' }), + originUiPath: _({ id: 'guardrails.centralized.origin-uipath', message: 'UiPath managed' }), + statusUnavailable: _({ id: 'guardrails.list.status-unavailable', message: 'Unavailable' }), + statusDisabled: _({ id: 'guardrails.list.status-disabled', message: 'Disabled' }), + missingConfigMessage: _({ + id: 'guardrails.centralized.missing-config-message', + message: + "This guardrail's configuration could not be found — it may have been deleted. Contact your administrator to fix the AI Trust Layer policy.", + }), + disabledConfigMessage: _({ + id: 'guardrails.centralized.disabled-config-message', + message: + "This guardrail's configuration has been disabled. Contact your administrator to re-enable it.", + }), + scopeAgent: _({ id: 'guardrails.builder.scope-agent-label', message: 'Agent' }), + scopeLlm: _({ id: 'guardrails.builder.scope-llm-label', message: 'LLM calls' }), + scopeTool: _({ id: 'guardrails.builder.scope-tool-label', message: 'Tools' }), + actionBlock: _({ id: 'guardrails.builder.action-block-label', message: 'Block' }), + actionEscalate: _({ id: 'guardrails.builder.action-escalate-label', message: 'Escalate' }), + actionFilter: _({ id: 'guardrails.builder.action-filter-label', message: 'Filter' }), + actionLog: _({ id: 'guardrails.builder.action-log-label', message: 'Log' }), + stagePre: _({ id: 'guardrails.centralized.stage-pre', message: 'Pre-execution' }), + stagePost: _({ id: 'guardrails.centralized.stage-post', message: 'Post-execution' }), + stageBoth: _({ id: 'guardrails.centralized.stage-both', message: 'Pre & post-execution' }), + parameterEnabled: _({ id: 'guardrails.centralized.parameter-enabled', message: 'Enabled' }), + parameterDisabled: _({ id: 'guardrails.centralized.parameter-disabled', message: 'Disabled' }), + entitiesFallback: _({ + id: 'guardrails.centralized.entities-fallback', + message: 'Entities to detect', + }), + thresholdsFallback: _({ + id: 'guardrails.centralized.thresholds-fallback', + message: 'Detection thresholds', + }), + }; +} + +// Resolves a descriptor the way lingui does with `values: TEMPLATE_TOKENS`, so the English +// defaults carry the same `{{token}}` convention as a translated catalog entry. +const englishCentralizedTranslate: CentralizedTranslate = ({ message, values }) => + values + ? message.replace(/\{(\w+)\}/g, (match, token: string) => values[token] ?? match) + : message; + +/** The English chrome strings, resolved without a lingui provider. */ +export const CENTRALIZED_GUARDRAILS_EN_LABELS: CentralizedGuardrailsLabels = + buildCentralizedGuardrailsLabels(englishCentralizedTranslate); + +/** + * The same strings flattened to message id to ICU source message, the form the catalogs store: + * the i18n test compares these against `locales/en.json` verbatim. + */ +export const CENTRALIZED_GUARDRAILS_EN_MESSAGES: Readonly> = Object.freeze( + (() => { + const messages: Record = {}; + buildCentralizedGuardrailsLabels((descriptor) => { + messages[descriptor.id] = descriptor.message; + return descriptor.message; + }); + return messages; + })() +); + +/** Merge English defaults, a loaded catalog, and per-string overrides (undefined skipped). */ +export function resolveCentralizedGuardrailsLabels( + catalog?: Partial, + overrides?: Partial +): CentralizedGuardrailsLabels { + return mergeLabels(CENTRALIZED_GUARDRAILS_EN_LABELS, catalog, overrides); +} + +/** Localized chrome strings of the centralized section; per-string `overrides` always win. */ +export function useCentralizedGuardrailsLabels( + overrides?: Partial +): CentralizedGuardrailsLabels { + const { _ } = useSafeLingui(); + return useMemo( + () => resolveCentralizedGuardrailsLabels(buildCentralizedGuardrailsLabels(_), overrides), + [_, overrides] + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/index.ts b/packages/apollo-react/src/canvas/components/Guardrails/index.ts index ce86ab95d..d3709f045 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/index.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/index.ts @@ -31,6 +31,34 @@ export { getGuardrailSelectorErrorFields, initGuardrailBuilderFormData, } from './builder-utils'; +export type { CentralizedGuardrailDetailsProps } from './centralized-guardrail-details'; +export { CentralizedGuardrailDetails } from './centralized-guardrail-details'; +export type { + CentralizedGuardrailIdentity, + CentralizedParameterFallbackLabels, +} from './centralized-guardrail-utils'; +export { + findCentralizedBuiltInDefinition, + findCentralizedByoDefinition, + formatCentralizedAction, + formatCentralizedExecutionStage, + formatCentralizedScope, + getApplicableCentralizedGuardrails, + getCentralizedGuardrailDisplay, + getCentralizedGuardrailItemId, + isCentralizedGuardrailConfigMissing, + resolveCentralizedGuardrailParameters, +} from './centralized-guardrail-utils'; +export type { CentralizedGuardrailsSectionProps } from './centralized-guardrails-section'; +export { CentralizedGuardrailsSection } from './centralized-guardrails-section'; +export type { + CentralizedGuardrail, + CentralizedGuardrailActionType, + CentralizedGuardrailDefinition, + CentralizedGuardrailParameter, + CentralizedGuardrailParameterDefinition, + CentralizedGuardrailParameterRow, +} from './centralized-types'; export type { GuardrailChipProps } from './components/guardrail-chip'; export { GuardrailChip, guardrailChipVariants } from './components/guardrail-chip'; export type { GuardrailStatusBannerProps } from './components/guardrail-status-banner'; @@ -89,6 +117,7 @@ export type { GuardrailRemoveDialogProps } from './guardrail-remove-dialog'; export { GuardrailRemoveDialog } from './guardrail-remove-dialog'; export { GuardrailValidatorForm } from './guardrail-validator-form'; export type { + CentralizedGuardrailsLabels, GuardrailBuilderLabels, GuardrailListLabels, GuardrailPaletteLabels, @@ -96,6 +125,8 @@ export type { GuardrailValidatorFormLabels, } from './i18n'; export { + CENTRALIZED_GUARDRAILS_EN_LABELS, + CENTRALIZED_GUARDRAILS_EN_MESSAGES, formatGuardrailFormMessage, GUARDRAIL_BUILDER_EN_LABELS, GUARDRAIL_FORM_EN_LABELS, @@ -105,11 +136,13 @@ export { GUARDRAIL_PALETTE_EN_MESSAGES, GUARDRAIL_REMOVE_DIALOG_EN_LABELS, GUARDRAIL_REMOVE_DIALOG_EN_MESSAGES, + resolveCentralizedGuardrailsLabels, resolveGuardrailBuilderLabels, resolveGuardrailFormLabels, resolveGuardrailListLabels, resolveGuardrailPaletteLabels, resolveGuardrailRemoveDialogLabels, + useCentralizedGuardrailsLabels, useGuardrailBuilderLabels, useGuardrailFormLabels, useGuardrailListLabels, diff --git a/packages/apollo-react/src/canvas/locales/en.json b/packages/apollo-react/src/canvas/locales/en.json index b43c1a4bb..c13ec5999 100644 --- a/packages/apollo-react/src/canvas/locales/en.json +++ b/packages/apollo-react/src/canvas/locales/en.json @@ -318,5 +318,26 @@ "guardrails.remove-dialog.still-applicable": "It will still be applicable to:", "guardrails.remove-dialog.close": "Close", "guardrails.remove-dialog.cancel": "Cancel", - "guardrails.remove-dialog.remove": "Remove" + "guardrails.remove-dialog.remove": "Remove", + "guardrails.centralized.title": "Centralized guardrails", + "guardrails.centralized.info": "Your organization's AI Trust Layer governance policy enforces these guardrails. You cannot edit them here.", + "guardrails.centralized.docs-link": "View centralized guardrails documentation", + "guardrails.centralized.policy-caption": "Enforced by AI Trust Layer policy: {policyName}", + "guardrails.centralized.view-details": "View details for {name}", + "guardrails.centralized.policy-field": "AI Trust Layer policy", + "guardrails.centralized.no-description": "No description available.", + "guardrails.centralized.execution-stage": "Execution stage", + "guardrails.centralized.action": "Action", + "guardrails.centralized.configuration": "Configuration", + "guardrails.centralized.managed-message": "Your organization's AI Trust Layer governance policy manages this configuration. You cannot edit it here.", + "guardrails.centralized.origin-uipath": "UiPath managed", + "guardrails.centralized.missing-config-message": "This guardrail's configuration could not be found — it may have been deleted. Contact your administrator to fix the AI Trust Layer policy.", + "guardrails.centralized.disabled-config-message": "This guardrail's configuration has been disabled. Contact your administrator to re-enable it.", + "guardrails.centralized.stage-pre": "Pre-execution", + "guardrails.centralized.stage-post": "Post-execution", + "guardrails.centralized.stage-both": "Pre & post-execution", + "guardrails.centralized.parameter-enabled": "Enabled", + "guardrails.centralized.parameter-disabled": "Disabled", + "guardrails.centralized.entities-fallback": "Entities to detect", + "guardrails.centralized.thresholds-fallback": "Detection thresholds" }