diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc451861c5..f30f068463 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Add skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322) + +### 🔄 Changed + - Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333) - Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) - Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317) diff --git a/ui/components/lighthouse/chat-utils.ts b/ui/components/lighthouse/chat-utils.ts index 8af28b83cb..70ac9e0b05 100644 --- a/ui/components/lighthouse/chat-utils.ts +++ b/ui/components/lighthouse/chat-utils.ts @@ -9,6 +9,7 @@ import { MESSAGE_ROLES, MESSAGE_STATUS, META_TOOLS, + SKILL_PREFIX, } from "@/lib/lighthouse/constants"; import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types"; @@ -70,17 +71,28 @@ export function getChainOfThoughtStepLabel( return `Executing ${tool}`; } + if (metaTool === META_TOOLS.LOAD_SKILL && tool) { + const skillId = tool.startsWith(SKILL_PREFIX) + ? tool.slice(SKILL_PREFIX.length) + : tool; + return `Loading ${skillId} skill`; + } + return tool || "Completed"; } /** - * Determines if a meta-tool is a wrapper tool (describe_tool or execute_tool) + * Determines if a tool name is a meta-tool (describe_tool, execute_tool, or load_skill) * * @param metaTool - The meta-tool name to check * @returns True if it's a meta-tool, false otherwise */ export function isMetaTool(metaTool: string): boolean { - return metaTool === META_TOOLS.DESCRIBE || metaTool === META_TOOLS.EXECUTE; + return ( + metaTool === META_TOOLS.DESCRIBE || + metaTool === META_TOOLS.EXECUTE || + metaTool === META_TOOLS.LOAD_SKILL + ); } /** diff --git a/ui/lib/lighthouse/analyst-stream.ts b/ui/lib/lighthouse/analyst-stream.ts index 95050b1001..13d5648ba0 100644 --- a/ui/lib/lighthouse/analyst-stream.ts +++ b/ui/lib/lighthouse/analyst-stream.ts @@ -9,6 +9,7 @@ import { ERROR_PREFIX, LIGHTHOUSE_AGENT_TAG, META_TOOLS, + SKILL_PREFIX, STREAM_MESSAGE_ID, } from "@/lib/lighthouse/constants"; import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types"; @@ -16,10 +17,35 @@ import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types"; // Re-export for convenience export { CHAIN_OF_THOUGHT_ACTIONS, ERROR_PREFIX, STREAM_MESSAGE_ID }; +/** + * Safely parses the JSON string nested inside a meta-tool's input wrapper. + * In tool stream events, meta-tools receive their arguments as `{ input: "" }`. + * Note: In chat_model_end events, args are pre-parsed by LangChain (see handleChatModelEndEvent). + * + * @returns The parsed object, or null if parsing fails + */ +function parseMetaToolInput( + toolInput: unknown, +): Record | null { + try { + if ( + toolInput && + typeof toolInput === "object" && + "input" in toolInput && + typeof toolInput.input === "string" + ) { + return JSON.parse(toolInput.input) as Record; + } + } catch { + // Failed to parse + } + return null; +} + /** * Extracts the actual tool name from meta-tool input. * - * Meta-tools (describe_tool, execute_tool) wrap actual tool calls. + * Meta-tools (describe_tool, execute_tool, load_skill) wrap actual tool calls. * This function parses the input to extract the real tool name. * * @param metaToolName - The name of the meta-tool or actual tool @@ -30,26 +56,19 @@ export function extractActualToolName( metaToolName: string, toolInput: unknown, ): string | null { - // Check if this is a meta-tool if ( metaToolName === META_TOOLS.DESCRIBE || metaToolName === META_TOOLS.EXECUTE ) { - // Meta-tool: Parse the JSON string in input.input - try { - if ( - toolInput && - typeof toolInput === "object" && - "input" in toolInput && - typeof toolInput.input === "string" - ) { - const parsedInput = JSON.parse(toolInput.input); - return parsedInput.toolName || null; - } - } catch { - // Failed to parse, return null - return null; - } + const parsed = parseMetaToolInput(toolInput); + return (parsed?.toolName as string) || null; + } + + if (metaToolName === META_TOOLS.LOAD_SKILL) { + const parsed = parseMetaToolInput(toolInput); + return parsed?.skillId + ? `${SKILL_PREFIX}${parsed.skillId as string}` + : null; } // Actual tool execution: use the name directly @@ -172,11 +191,18 @@ export function handleChatModelEndEvent( const metaToolName = toolCall.name; const toolArgs = toolCall.args; - // Extract actual tool name from toolArgs.toolName (camelCase) - const actualToolName = - toolArgs && typeof toolArgs === "object" && "toolName" in toolArgs - ? (toolArgs.toolName as string) - : null; + // Extract actual tool name from toolArgs + let actualToolName: string | null = null; + if (toolArgs && typeof toolArgs === "object") { + if ("toolName" in toolArgs) { + actualToolName = toolArgs.toolName as string; + } else if ( + metaToolName === META_TOOLS.LOAD_SKILL && + "skillId" in toolArgs + ) { + actualToolName = `${SKILL_PREFIX}${toolArgs.skillId as string}`; + } + } controller.enqueue( createChainOfThoughtEvent({ diff --git a/ui/lib/lighthouse/constants.ts b/ui/lib/lighthouse/constants.ts index 6fbb30947f..cea67eabb4 100644 --- a/ui/lib/lighthouse/constants.ts +++ b/ui/lib/lighthouse/constants.ts @@ -6,6 +6,7 @@ export const META_TOOLS = { DESCRIBE: "describe_tool", EXECUTE: "execute_tool", + LOAD_SKILL: "load_skill", } as const; export type MetaTool = (typeof META_TOOLS)[keyof typeof META_TOOLS]; @@ -68,5 +69,7 @@ export const STREAM_MESSAGE_ID = "msg-1"; export const ERROR_PREFIX = "[LIGHTHOUSE_ANALYST_ERROR]:"; +export const SKILL_PREFIX = "skill:"; + export const TOOLS_UNAVAILABLE_MESSAGE = "\nProwler tools are unavailable. You cannot access cloud accounts or security scan data. If asked about security status or scan results, inform the user that this data is currently inaccessible.\n"; diff --git a/ui/lib/lighthouse/skills/index.ts b/ui/lib/lighthouse/skills/index.ts new file mode 100644 index 0000000000..2ec8d586d7 --- /dev/null +++ b/ui/lib/lighthouse/skills/index.ts @@ -0,0 +1,8 @@ +// Re-export registry functions and types +export { + getAllSkillMetadata, + getRegisteredSkillIds, + getSkillById, + registerSkill, +} from "./registry"; +export type { SkillDefinition, SkillMetadata } from "./types"; diff --git a/ui/lib/lighthouse/skills/registry.ts b/ui/lib/lighthouse/skills/registry.ts new file mode 100644 index 0000000000..bd23c3f61c --- /dev/null +++ b/ui/lib/lighthouse/skills/registry.ts @@ -0,0 +1,21 @@ +import "server-only"; + +import type { SkillDefinition, SkillMetadata } from "./types"; + +const skillRegistry = new Map(); + +export function registerSkill(skill: SkillDefinition): void { + skillRegistry.set(skill.metadata.id, skill); +} + +export function getAllSkillMetadata(): SkillMetadata[] { + return Array.from(skillRegistry.values()).map((skill) => skill.metadata); +} + +export function getSkillById(id: string): SkillDefinition | undefined { + return skillRegistry.get(id); +} + +export function getRegisteredSkillIds(): string[] { + return Array.from(skillRegistry.keys()); +} diff --git a/ui/lib/lighthouse/skills/types.ts b/ui/lib/lighthouse/skills/types.ts new file mode 100644 index 0000000000..dfb7104be6 --- /dev/null +++ b/ui/lib/lighthouse/skills/types.ts @@ -0,0 +1,10 @@ +export interface SkillMetadata { + id: string; + name: string; + description: string; +} + +export interface SkillDefinition { + metadata: SkillMetadata; + instructions: string; +} diff --git a/ui/lib/lighthouse/system-prompt.ts b/ui/lib/lighthouse/system-prompt.ts index c91ec56d9c..be3ec099e3 100644 --- a/ui/lib/lighthouse/system-prompt.ts +++ b/ui/lib/lighthouse/system-prompt.ts @@ -3,6 +3,8 @@ * * {{TOOL_LISTING}} placeholder will be replaced with dynamically generated tool list */ +import type { SkillMetadata } from "@/lib/lighthouse/skills/types"; + export const LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE = ` ## Introduction @@ -45,7 +47,7 @@ You have access to tools from multiple sources: ## Tool Usage -You have access to TWO meta-tools to interact with the available tools: +You have access to THREE meta-tools to interact with the available tools and skills: 1. **describe_tool** - Get detailed schema for a specific tool - Use exact tool name from the list above @@ -59,6 +61,13 @@ You have access to TWO meta-tools to interact with the available tools: - Example: execute_tool({ "toolName": "prowler_hub_list_providers", "toolInput": {} }) - Example: execute_tool({ "toolName": "prowler_app_search_security_findings", "toolInput": { "severity": ["critical", "high"], "status": ["FAIL"] } }) +3. **load_skill** - Load specialized instructions for a complex task + - Use when you identify a matching skill from the skill catalog below + - Returns detailed workflows, schema knowledge, and examples + - Example: load_skill({ "skillId": "" }) + +{{SKILL_CATALOG}} + ## General Instructions - **DON'T ASSUME**. Base your answers on the system prompt or tool outputs before responding to the user. @@ -229,6 +238,26 @@ When providing proactive recommendations to secure users' cloud accounts, follow - Prowler Documentation: https://docs.prowler.com/ `; +/** + * Generates the skill catalog section for the system prompt. + * Lists all registered skills with their metadata so the LLM can match user requests. + */ +export function generateSkillCatalog(skills: SkillMetadata[]): string { + if (skills.length === 0) { + return ""; + } + + let catalog = "## Skill Catalog\n\n"; + catalog += + "When a user request matches a skill below, use load_skill to get detailed instructions before proceeding.\n\n"; + + for (const skill of skills) { + catalog += `- **${skill.id}**: ${skill.name} - ${skill.description}\n`; + } + + return catalog; +} + /** * Generates the user-provided data section with security boundary */ diff --git a/ui/lib/lighthouse/tools/load-skill.ts b/ui/lib/lighthouse/tools/load-skill.ts new file mode 100644 index 0000000000..6c57d03ede --- /dev/null +++ b/ui/lib/lighthouse/tools/load-skill.ts @@ -0,0 +1,82 @@ +import "server-only"; + +import { tool } from "@langchain/core/tools"; +import { addBreadcrumb } from "@sentry/nextjs"; +import { z } from "zod"; + +import { + getRegisteredSkillIds, + getSkillById, +} from "@/lib/lighthouse/skills/index"; + +interface SkillLoadedResult { + found: true; + skillId: string; + name: string; + instructions: string; +} + +interface SkillNotFoundResult { + found: false; + skillId: string; + message: string; + availableSkills: string[]; +} + +type LoadSkillResult = SkillLoadedResult | SkillNotFoundResult; + +export const loadSkill = tool( + async ({ skillId }: { skillId: string }): Promise => { + addBreadcrumb({ + category: "skill", + message: `load_skill called for: ${skillId}`, + level: "info", + data: { skillId }, + }); + + const skill = getSkillById(skillId); + + if (!skill) { + const availableSkills = getRegisteredSkillIds(); + + addBreadcrumb({ + category: "skill", + message: `Skill not found: ${skillId}`, + level: "warning", + data: { skillId, availableSkills }, + }); + + return { + found: false, + skillId, + message: `Skill '${skillId}' not found.`, + availableSkills, + }; + } + + return { + found: true, + skillId: skill.metadata.id, + name: skill.metadata.name, + instructions: skill.instructions, + }; + }, + { + name: "load_skill", + description: `Load detailed instructions for a specialized skill. + +Skills provide domain-specific guidance, workflows, and schema knowledge for complex tasks. +Use this when you identify a relevant skill from the skill catalog in your system prompt. + +Returns: +- Skill metadata (id, name) +- Full skill instructions with workflows and examples`, + schema: z.object({ + skillId: z + .string() + .describe( + "The ID of the skill to load (from the skill catalog in your system prompt)", + ), + }), + }, +); diff --git a/ui/lib/lighthouse/workflow.ts b/ui/lib/lighthouse/workflow.ts index bc27b7fe4d..c94adaf8db 100644 --- a/ui/lib/lighthouse/workflow.ts +++ b/ui/lib/lighthouse/workflow.ts @@ -12,10 +12,13 @@ import { initializeMCPClient, isMCPAvailable, } from "@/lib/lighthouse/mcp-client"; +import { getAllSkillMetadata } from "@/lib/lighthouse/skills/index"; import { + generateSkillCatalog, generateUserDataSection, LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE, } from "@/lib/lighthouse/system-prompt"; +import { loadSkill } from "@/lib/lighthouse/tools/load-skill"; import { describeTool, executeTool } from "@/lib/lighthouse/tools/meta-tool"; import { getModelParams } from "@/lib/lighthouse/utils"; @@ -136,6 +139,10 @@ export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) { toolListing, ); + // Generate and inject skill catalog + const skillCatalog = generateSkillCatalog(getAllSkillMetadata()); + systemPrompt = systemPrompt.replace("{{SKILL_CATALOG}}", skillCatalog); + // Add user-provided data section if available const userDataSection = generateUserDataSection( runtimeConfig?.businessContext, @@ -177,7 +184,7 @@ export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) { const agent = createAgent({ model: llm, - tools: [describeTool, executeTool], + tools: [describeTool, executeTool, loadSkill], systemPrompt, });