Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@
* 2.0.
*/

import type { ActionApprovalPolicy, ActionCategory, ActionImpact } from '@kbn/workflows';
import type {
ActionApprovalPolicy,
ActionCategory,
ActionImpact,
Comment thread
patrykkopycinski marked this conversation as resolved.
JsonSchema,
} from '@kbn/workflows';

export type { ActionApprovalPolicy, ActionCategory, ActionImpact };

/**
* One entry of the action catalog: the lightweight, agent-facing projection of
* an installed action workflow. Mirrors `consts.actionMetadata` on the
* workflow definition plus the workflow id, so an agent can propose the action
* without reading the full YAML.
* workflow definition plus the workflow id and the JSON Schema of the inputs
* the workflow accepts, so an agent can propose the action — and fill in its
* inputs — without reading the full YAML.
*/
export interface ActionCatalogEntry {
workflowId: string;
Expand All @@ -22,6 +28,12 @@ export interface ActionCatalogEntry {
category?: ActionCategory;
impact?: ActionImpact;
approvalPolicy?: ActionApprovalPolicy;
/**
* JSON Schema of the workflow's inputs, as declared on its manual trigger
* (`triggers[type=manual].inputs`). Describes the single `actionInput`
* object every action workflow takes.
*/
inputSchema?: JsonSchema;
}

/** Response of `GET /internal/alertzero/actions`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,31 @@ const ACTION = (over: Partial<Record<string, unknown>> = {}) => ({
describe('listActionsTool', () => {
it('lists all actions when called without categories', async () => {
const list = jest.fn().mockResolvedValue({
actions: [ACTION(), ACTION({ workflowId: 'a2', name: 'Isolate host', category: 'contain' })],
actions: [
ACTION({
inputSchema: {
properties: {
actionInput: { type: 'object', properties: { name: { type: 'string' } } },
},
required: ['actionInput'],
},
}),
ACTION({ workflowId: 'a2', name: 'Isolate host', category: 'contain' }),
],
total: 2,
});
const result = await run(serviceWith(list));
expect(list).toHaveBeenCalledWith('default', undefined);
expect(result.results[0].type).toBe(ToolResultType.other);
expect(result.results[0].data).toMatchObject({ total: 2 });
expect(result.results[0].data).toMatchObject({
total: 2,
actions: [
expect.objectContaining({
inputSchema: expect.objectContaining({ required: ['actionInput'] }),
}),
expect.objectContaining({ workflowId: 'a2' }),
],
});
});

it('forwards categories to the service and reports empty results explicitly', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const listActionsTool = (
id: ALERTZERO_ACTIONS_LIST_TOOL_ID,
type: ToolType.builtin,
description:
'List available AlertZero actions, optionally filtered by category. Each result includes the workflowId to reference when proposing the action, plus its name, description, category, impact (low/medium/high/critical) and approvalPolicy (always-gate/autonomy-dependent). Call this before proposing an action so the proposal references a real, installed workflow.',
'List available AlertZero actions, optionally filtered by category. Each result includes the workflowId to reference when proposing the action, plus its name, description, category, impact (low/medium/high/critical), approvalPolicy (always-gate/autonomy-dependent) and inputSchema — the JSON Schema of the inputs the action accepts (its single `actionInput` object; fill its properties when proposing the action). Call this before proposing an action so the proposal references a real, installed workflow.',
annotations: {
title: 'List AlertZero Actions',
readOnlyHint: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ const makeManagement = (
};
};

const workflowItem = (id: string, actionMetadata: unknown, tags: string[] = ['action']) => ({
const workflowItem = (
id: string,
actionMetadata: unknown,
tags: string[] = ['action'],
extraDefinition: Record<string, unknown> = {}
) => ({
id,
name: id,
description: '',
Expand All @@ -38,7 +43,7 @@ const workflowItem = (id: string, actionMetadata: unknown, tags: string[] = ['ac
managedBy: 'alertzero',
definition: (actionMetadata === null
? null
: { consts: { actionMetadata } }) as WorkflowListItemDto['definition'],
: { consts: { actionMetadata }, ...extraDefinition }) as WorkflowListItemDto['definition'],
createdAt: '2026-01-01T00:00:00.000Z',
tags,
valid: true,
Expand Down Expand Up @@ -181,4 +186,78 @@ describe('ActionsService', () => {
const result = await service.list('default');
expect(result.actions.map((a) => a.name)).toEqual(['Alpha action', 'Zeta action']);
});

it('projects the manual trigger inputs JSON Schema verbatim as inputSchema', async () => {
const inputSchema = {
properties: {
actionInput: {
type: 'object',
properties: { name: { type: 'string' }, query: { type: 'string' } },
required: ['name', 'query'],
},
},
required: ['actionInput'],
additionalProperties: false,
// x- prefixed annotations are legal JSON Schema but unknown to the
// workflow's zod schema, which would strip them from its parsed copy —
// the catalog must publish the original object, verbatim.
'x-es-validation': { message: 'Action input' },
};
const { client } = makeManagement([
page([
workflowItem('action-create-rule', { name: 'Create detection rule' }, ['action'], {
triggers: [{ type: 'manual', inputs: inputSchema }],
}),
]),
]);
const service = new ActionsService(() => client, logger);
const result = await service.list('default');
expect(result.actions[0].inputSchema).toEqual(inputSchema);
});

it('omits inputSchema when the definition has no manual-trigger inputs schema', async () => {
const { client } = makeManagement([
page([
// No triggers at all.
workflowItem('no-triggers', { name: 'No triggers' }),
// A manual trigger with no inputs.
workflowItem('manual-no-inputs', { name: 'Manual no inputs' }, ['action'], {
triggers: [{ type: 'manual' }],
}),
// A non-manual trigger carrying inputs — must not be read.
workflowItem('alert-trigger', { name: 'Alert trigger' }, ['action'], {
triggers: [{ type: 'alert', inputs: { properties: {} } }],
}),
// Schema-shaped but malformed values the manual-trigger schema rejects.
workflowItem('properties-array', { name: 'Properties array' }, ['action'], {
triggers: [{ type: 'manual', inputs: { properties: [] } }],
}),
workflowItem('properties-null', { name: 'Properties null' }, ['action'], {
triggers: [{ type: 'manual', inputs: { properties: null } }],
}),
]),
]);
const service = new ActionsService(() => client, logger);
const result = await service.list('default');
expect(result.actions).toEqual([
expect.not.objectContaining({ inputSchema: expect.anything() }),
expect.not.objectContaining({ inputSchema: expect.anything() }),
expect.not.objectContaining({ inputSchema: expect.anything() }),
expect.not.objectContaining({ inputSchema: expect.anything() }),
expect.not.objectContaining({ inputSchema: expect.anything() }),
]);
});

it('omits inputSchema for legacy array-format trigger inputs', async () => {
const { client } = makeManagement([
page([
workflowItem('legacy-inputs', { name: 'Legacy inputs' }, ['action'], {
triggers: [{ type: 'manual', inputs: [{ name: 'actionInput', type: 'string' }] }],
}),
]),
]);
const service = new ActionsService(() => client, logger);
const result = await service.list('default');
expect(result.actions[0]).not.toHaveProperty('inputSchema');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
*/

import { ACTION_WORKFLOW_TAG, actionMetadataSchema } from '@kbn/workflows/managed';
import type { WorkflowListDto } from '@kbn/workflows';
import { ManualTriggerSchema } from '@kbn/workflows';
import type { JsonSchema, WorkflowListDto } from '@kbn/workflows';
import type { Logger } from '@kbn/logging';
import type { ActionCatalogEntry, ListActionsResponse } from '@kbn/alertzero-common';
import type { WatchWorkflowsManagementClient } from '../watches/watch_workflows_management_client';

/** Structural subset of WorkflowListItemDto.definition the catalog reads. */
interface ActionWorkflowDefinition {
consts?: { actionMetadata?: unknown };
triggers?: Array<{
type?: string;
inputs?: unknown;
}>;
}

const PAGE_SIZE = 100;
Expand Down Expand Up @@ -98,13 +103,40 @@ export class ActionsService {
return undefined;
}
const { name, description, category, impact, approvalPolicy } = parsed.data;
const inputSchema = this.readInputSchema(definition);
return {
workflowId,
name,
...(description !== undefined && { description }),
...(category !== undefined && { category }),
...(impact !== undefined && { impact }),
...(approvalPolicy !== undefined && { approvalPolicy }),
...(inputSchema !== undefined && { inputSchema }),
};
}

/**
* Reads the JSON Schema the workflow declares on its manual trigger
* (`triggers[type=manual].inputs`) and returns it verbatim. The trigger is
* parsed with the workflow's own {@link ManualTriggerSchema}: definitions are
* validated and normalized by the workflow schema on the way in, and this
* guards the defensive path where an unnormalized or stale definition still
* carries legacy array-format inputs or a malformed schema — anything that
* does not parse yields `undefined` and the entry is returned without
* `inputSchema`, so a malformed schema is never published.
*/
private readInputSchema(definition: ActionWorkflowDefinition | null): JsonSchema | undefined {
const manualTrigger = definition?.triggers?.find((t) => t.type === 'manual');
if (!manualTrigger) {
return undefined;
Comment thread
patrykkopycinski marked this conversation as resolved.
}
// Parse as a gate only: a successful parse means `inputs` is a well-formed
// JSON Schema (or the legacy array format), but the parsed copy strips
// unknown keys — the entry publishes the original value, verbatim.
if (!ManualTriggerSchema.safeParse(manualTrigger).success) {
return undefined;
}
const { inputs } = manualTrigger;
return Array.isArray(inputs) ? undefined : (inputs as JsonSchema);
}
}
Loading