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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW = {
id: ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID,
management: ALERTZERO_WORKER_MANAGEMENT,
pluginId: ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID,
version: 1,
version: 2,
yamlTemplate: (values: CommonWorkerTemplateValues): string =>
renderCommonWorkerYaml(FLOOR_ALERT_TRIAGE_YAML, values),
} as const satisfies ManagedWorkflowDefinition<CommonWorkerTemplateValues>;
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ steps:
configuration_overrides:
skill_ids:
- alert-analysis
tools:
- tool_ids:
- security.alertzero.actions.list
message: >
Triage the security alert context below. Prefer
recall over precision when unsure. Return a structured
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function createContentFingerprint(content: string): string {
}

it.each([
[ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID, FLOOR_ALERT_TRIAGE_YAML, '1:d6a82eff'],
[ALERTZERO_WORKER_FLOOR_ALERT_TRIAGE_WORKFLOW_ID, FLOOR_ALERT_TRIAGE_YAML, '2:275b444e'],
[ALERTZERO_WORKER_FLOOR_ATTACK_DISCOVERY_WORKFLOW_ID, FLOOR_ATTACK_DISCOVERY_YAML, '2:d13818a0'],
[
ALERTZERO_WORKER_DARK_CONTINUOUS_THREAT_HUNT_WORKFLOW_ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const AGENT_BUILDER_BUILTIN_TOOLS = [
`${internalNamespaces.ml}.query_anomalies`,

// Security Solution
`${internalNamespaces.security}.alertzero.actions.list`,
`${internalNamespaces.security}.entity_risk_score`,
`${internalNamespaces.security}.create_detection_rule`,
`${internalNamespaces.security}.run_rule_preview`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { ActionApprovalPolicy, ActionCategory, ActionImpact } 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.
*/
export interface ActionCatalogEntry {
workflowId: string;
name: string;
description?: string;
category?: ActionCategory;
impact?: ActionImpact;
approvalPolicy?: ActionApprovalPolicy;
}

/** Response of `GET /internal/alertzero/actions`. */
export interface ListActionsResponse {
actions: ActionCatalogEntry[];
total: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export const buildInvestigationUrl = (id: string) =>
/** Proposals grouped by category — AlertZero landing page. */
export const ALERTZERO_PROPOSALS_URL = `${ALERTZERO_INTERNAL_URL}/proposals` as const;

/** Action catalog — category-scoped discovery of installed action workflows. */
export const ALERTZERO_ACTIONS_URL = `${ALERTZERO_INTERNAL_URL}/actions` as const;

/** Agent Builder builtin tool wrapping the action catalog API. */
export const ALERTZERO_ACTIONS_LIST_TOOL_ID = 'security.alertzero.actions.list' as const;

/**
* Shared thin AlertZero agent for all Worker `ai.agent` steps.
* Can expand this to multiple scoped thin agents in the future if needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export {
ALERTZERO_INVESTIGATIONS_URL,
ALERTZERO_INVESTIGATION_URL_TEMPLATE,
ALERTZERO_PLUGIN_NAME,
ALERTZERO_ACTIONS_URL,
ALERTZERO_ACTIONS_LIST_TOOL_ID,
ALERTZERO_PROPOSALS_URL,
ALERTZERO_THIN_AGENT_ID,
ALERTZERO_SKILLS_URL,
Expand Down Expand Up @@ -64,6 +66,13 @@ export {
buildWorkerUrl,
} from './constants';

export type {
ActionApprovalPolicy,
ActionCategory,
ActionCatalogEntry,
ActionImpact,
ListActionsResponse,
} from './action_catalog_types';
export { CONVERSATION_QUEUE_CATEGORIES, CONVERSATION_QUEUE_LABELS } from './translations';

export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependsOn:
- '@kbn/zod'
- '@kbn/i18n'
- '@kbn/deeplinks-security'
- '@kbn/workflows'
tags:
- shared-common
- package
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
"@kbn/zod",
"@kbn/i18n",
"@kbn/deeplinks-security",
"@kbn/workflows",
]
}
2 changes: 2 additions & 0 deletions x-pack/solutions/security/plugins/alertzero/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ dependsOn:
- '@kbn/core-http-server-mocks'
- '@kbn/std'
- '@kbn/core-security-server'
- '@kbn/core-logging-server-mocks'
- '@kbn/core-http-server'
- '@kbn/charts-theme'
tags:
- plugin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { listActionsTool } from './list_actions_tool';
import type { ActionsService } from '../services/actions/actions_service';
import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result';

const logger = () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() });

const serviceWith = (list: jest.Mock) => ({ list } as Pick<ActionsService, 'list'>);

const run = async (
service: Pick<ActionsService, 'list'>,
input: { categories?: string[] } = {}
) => {
const tool = listActionsTool(() => service);
const result = await tool.handler(input, { logger: logger() } as never);
if (!('results' in result)) {
throw new Error('expected a standard tool result');
}
return result;
};

const ACTION = (over: Partial<Record<string, unknown>> = {}) => ({
workflowId: 'system-alertzero-action-create-rule',
name: 'Create detection rule',
category: 'tune',
...over,
});

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' })],
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 });
});

it('forwards categories to the service and reports empty results explicitly', async () => {
const list = jest.fn().mockResolvedValue({ actions: [], total: 0 });
const result = await run(serviceWith(list), { categories: ['escalate'] });
expect(list).toHaveBeenCalledWith('default', ['escalate']);
expect(result.results[0].data).toMatchObject({
total: 0,
message: 'No actions found in categories: escalate.',
});
});

it('returns an error result instead of throwing when the service fails', async () => {
const list = jest.fn().mockRejectedValue(new Error('workflows management down'));
const result = await run(serviceWith(list));
expect(result.results[0].type).not.toBe(ToolResultType.other);
expect(JSON.stringify(result.results[0])).toContain('workflows management down');
});

it('declares the documented tool id and read-only annotations', () => {
const tool = listActionsTool(() => serviceWith(jest.fn()));
expect(tool.id).toBe('security.alertzero.actions.list');
expect(tool.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
});
expect(tool.type).toBe('builtin');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { z } from '@kbn/zod/v4';
import { createErrorResult } from '@kbn/agent-builder-server';
import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result';
import type { BuiltinToolDefinition } from '@kbn/agent-builder-server/tools';
import { ToolType } from '@kbn/agent-builder-common';
import { actionCategorySchema } from '@kbn/workflows/managed';
import { ALERTZERO_ACTIONS_LIST_TOOL_ID } from '@kbn/alertzero-common';
import type { ActionsService } from '../services/actions/actions_service';

const listByCategorySchema = z.object({
categories: z
.array(actionCategorySchema)
.max(20)
.optional()
.describe(
'Category keywords to filter on (e.g. ["contain", "escalate"]). An action is returned when its declared category matches ANY of these. Omit to list every available action.'
),
});

/**
* `security.alertzero.actions.list` — lets an agent discover the
* installed action workflows at runtime instead of hard-coding workflow ids.
*
* Registered by the AlertZero plugin (setup), reads the catalog through
* {@link ActionsService} — the same service backing the HTTP API — so the tool
* and the API can never drift.
*/
export const listActionsTool = (
getActionsService: () => Pick<ActionsService, 'list'>
): BuiltinToolDefinition<typeof listByCategorySchema> => ({
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.',
annotations: {
title: 'List AlertZero Actions',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
schema: listByCategorySchema,
tags: ['alertzero'],
handler: async ({ categories }, { logger }) => {
try {
const { actions, total } = await getActionsService().list('default', categories);
const message =
total === 0
? categories
? `No actions found in categories: ${categories.join(', ')}.`
: 'No actions are installed.'
: undefined;
return {
results: [
{
type: ToolResultType.other,
data: {
total,
actions,
...(message && { message }),
},
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`[List Actions Tool] Error listing actions: ${errorMessage}`);
return {
results: [createErrorResult(`Error listing actions: ${errorMessage}`)],
};
}
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('AlertZeroPlugin feature-flag gating', () => {
features,
workflowsExtensions,
workflowsManagement: { management: {} },
agentBuilder: { tools: { register: jest.fn() } },
} as never
);

Expand Down Expand Up @@ -130,7 +131,7 @@ describe('AlertZeroPlugin feature-flag gating', () => {
const coreSetup = coreMock.createSetup();
const features = { registerKibanaFeature: jest.fn() };
const workflowsExtensions = { registerManagedWorkflowOwner: jest.fn() };
const agentBuilder = { agents: { registerType: jest.fn() } };
const agentBuilder = { agents: { registerType: jest.fn() }, tools: { register: jest.fn() } };

plugin.setup(
coreSetup as never,
Expand Down
22 changes: 22 additions & 0 deletions x-pack/solutions/security/plugins/alertzero/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { WatchesService } from './services/watches/watches_service';
import { WorkersService } from './services/workers/workers_service';
import { ConversationProposalsService } from './services/conversation_proposals/conversation_proposals_service';
import { WatchWorkflowsManagementClientImpl } from './services/watches/watch_workflows_management_client';
import { ActionsService } from './services/actions/actions_service';
import { listActionsTool } from './agent_builder_tools/list_actions_tool';
import { agentType, ensureAgent, ensureAgentSafe, registerAgentType } from './agent';

export class AlertZeroPlugin
Expand All @@ -54,6 +56,7 @@ export class AlertZeroPlugin

/** Created during `start`; routes resolve them lazily after managed-workflow initialization. */
private watchesService?: WatchesService;
private actionsService?: ActionsService;
private workersService?: WorkersService;
private conversationProposalsService?: ConversationProposalsService;

Expand Down Expand Up @@ -83,6 +86,11 @@ export class AlertZeroPlugin

registerOwner({ workflowsExtensions });
registerAgentType(agentBuilder);
// Registered in setup so the builtin tool is available to Agent Builder before
// the first agent run; the handler resolves the service lazily like the routes do.
agentBuilder.tools.register({
...listActionsTool(() => this.requireActionsService()),
});

features.registerKibanaFeature({
id: ALERTZERO_FEATURE_ID,
Expand Down Expand Up @@ -116,6 +124,7 @@ export class AlertZeroPlugin
getWatchesService: () => this.requireWatchesService(),
getWorkersService: () => this.requireWorkersService(),
getConversationProposalsService: () => this.requireConversationProposalsService(),
getActionsService: () => this.requireActionsService(),
});

return {};
Expand Down Expand Up @@ -160,6 +169,13 @@ export class AlertZeroPlugin

// Mock mode changes presentation data only; durable Worker settings and enablement still use Workflows.
this.watchesService = new WatchesService();
this.actionsService = new ActionsService(
() =>
this.workflowsManagementApi
? new WatchWorkflowsManagementClientImpl(this.workflowsManagementApi)
: undefined,
this.logger
);
this.workersService = new WorkersService(management, managedWorkflows, this.logger, {
ensureAgentForSpace: plugins.agentBuilder
? (spaceId) =>
Expand All @@ -179,6 +195,12 @@ export class AlertZeroPlugin
return this.watchesService;
}

private requireActionsService(): ActionsService {
if (!this.actionsService) {
throw new Error('Actions service is not available until the AlertZero plugin has started');
}
return this.actionsService;
}
private requireWorkersService(): WorkersService {
if (!this.workersService) {
throw new Error('Workers service is not available until the AlertZero plugin has started');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

/** Max number of values accepted in the `categories` query param (each also length-capped upstream by actionCategorySchema). */
export const ACTION_CATEGORIES_QUERY_PARAM_MAX_ITEMS = 20;
Loading
Loading