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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions packages/apollo-react/src/canvas/components/Guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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';

<CentralizedGuardrailsSection
guardrails={getApplicableCentralizedGuardrails(policy.centralizedGuardrails, {
isConversational,
})}
policyName={policy.policyName}
definitions={definitions} // undefined while the catalog is loading
docsHref={CENTRALIZED_GUARDRAILS_DOCS}
onSelect={openDetails} // the host opens its own dialog or panel
/>;
```

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
<CentralizedGuardrailDetails
guardrail={selected}
policyName={policy.policyName}
definitions={definitions}
/>;
```

- **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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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 `<dt>`, 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(<CentralizedGuardrailDetails guardrail={guardrail()} policyName="Acme policy" />);

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(<CentralizedGuardrailDetails guardrail={guardrail()} policyName="Acme policy" />);

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(
<CentralizedGuardrailDetails
guardrail={guardrail({ validator: 'acme_unknown' })}
policyName="Acme policy"
/>
);

expect(valueFor('Guardrail description')).toBe('No description available.');
});

it('shows the connector and its description for a BYO guardrail', () => {
render(
<CentralizedGuardrailDetails
guardrail={guardrail({ isByo: true, name: 'Acme PII' })}
definitions={[BYO_DEFINITION]}
policyName="Acme policy"
/>
);

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(
<CentralizedGuardrailDetails
guardrail={guardrail({ isByo: true, name: 'Acme PII' })}
definitions={[]}
policyName="Acme policy"
/>
);
expect(screen.getByText(/could not be found/)).toBeInTheDocument();

rerender(
<CentralizedGuardrailDetails
guardrail={guardrail({ isByo: true, name: 'Acme PII' })}
definitions={[{ ...BYO_DEFINITION, status: 'Disabled' }]}
policyName="Acme policy"
/>
);
expect(screen.getByText(/has been disabled/)).toBeInTheDocument();
});

it('names the entities the way the guardrail editor names them', () => {
render(
<CentralizedGuardrailDetails
guardrail={guardrail({
entities: ['Email', 'USSocialSecurityNumber'],
entityThresholds: { Email: 0.8 },
})}
definitions={[PII_DEFINITION]}
policyName="Acme policy"
/>
);

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(
<CentralizedGuardrailDetails
guardrail={guardrail({
isByo: true,
name: 'Acme PII',
parameters: [
{ id: 'strict', parameterType: 'boolean', value: true },
{ id: 'mode', parameterType: 'enum', value: 'fast' },
],
})}
definitions={[BYO_DEFINITION]}
policyName="Acme policy"
/>
);

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(
<CentralizedGuardrailDetails
guardrail={guardrail()}
definitions={[PII_DEFINITION]}
policyName="Acme policy"
/>
);

expect(screen.queryByText('Configuration')).not.toBeInTheDocument();
});

it('lets a host replace the scope and action names', () => {
render(
<CentralizedGuardrailDetails
guardrail={guardrail()}
policyName="Acme policy"
formatScope={(scope) => `<${scope}>`}
formatAction={(action) => action.toUpperCase()}
/>
);

expect(valueFor('Scopes')).toBe('<Agent>, <Tool>');
expect(valueFor('Action')).toBe('ESCALATE');
});

it('translates through the ambient catalog', () => {
render(
<ApI18nProvider component="canvas" locale="ja">
<CentralizedGuardrailDetails guardrail={guardrail()} policyName="Acme policy" />
</ApI18nProvider>
);

// 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(
<CentralizedGuardrailDetails
guardrail={guardrail({
entities: ['Email'],
entityThresholds: { Email: 0.8 },
})}
definitions={[PII_DEFINITION]}
policyName="Acme policy"
/>
);

expect(await axe(container)).toHaveNoViolations();
});
});
Loading
Loading