diff --git a/plugins/f5-guardrails/manifest.json b/plugins/f5-guardrails/manifest.json new file mode 100644 index 000000000..4d8ba95f6 --- /dev/null +++ b/plugins/f5-guardrails/manifest.json @@ -0,0 +1,74 @@ +{ + "id": "f5-guardrails", + "description": "F5 Guardrails Plugin - Partner guardrail powered by F5 Guardrails", + "credentials": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "label": "API Key", + "description": "Your F5 Guardrails API key for authentication", + "encrypted": true + }, + "calypsoUrl": { + "type": "string", + "label": "F5 Guardrails URL", + "description": "The base URL for F5 Guardrails API. Defaults to https://us1.calypsoai.app", + "default": "https://us1.calypsoai.app" + } + }, + "required": ["apiKey"] + }, + "functions": [ + { + "name": "F5 Guardrails", + "id": "scan", + "supportedHooks": ["beforeRequestHook", "afterRequestHook"], + "type": "guardrail", + "description": [ + { + "type": "subHeading", + "text": "F5 Guardrails powered by F5 Guardrails provides advanced content moderation and PII detection capabilities for your LLM inputs and outputs." + } + ], + "parameters": { + "type": "object", + "properties": { + "redact": { + "type": "boolean", + "label": "Redact", + "description": [ + { + "type": "subHeading", + "text": "Whether to redact PII data detected by the F5 guardrail. When enabled, detected PII will be masked in the content." + } + ], + "default": false + }, + "projectId": { + "type": "string", + "label": "Project-Id", + "description": [ + { + "type": "subHeading", + "text": "Your F5 Guardrails project identifier" + } + ] + }, + "timeout": { + "type": "number", + "label": "Timeout", + "description": [ + { + "type": "subHeading", + "text": "The timeout in milliseconds for the F5 guardrail scan. Defaults to 5000." + } + ], + "default": 5000 + } + } + }, + "required": ["projectId"] + } + ] +} diff --git a/plugins/f5-guardrails/scan.test.ts b/plugins/f5-guardrails/scan.test.ts new file mode 100644 index 000000000..0cfce6cd6 --- /dev/null +++ b/plugins/f5-guardrails/scan.test.ts @@ -0,0 +1,71 @@ +import { handler } from './scan'; +import testCreds from './.creds.json'; +import { PluginContext } from '../types'; + +describe('f5GuardrailsScan', () => { + it('Should mask the NRIC if it is detected', async () => { + const context = { + request: { + text: 'My NRIC is S1234567A', + json: { + messages: [ + { + role: 'user', + content: 'My NRIC is S1234567A', + }, + ], + }, + }, + requestType: 'chatComplete', + }; + const result = await handler( + context as PluginContext, + { + credentials: { + apiKey: testCreds.apiKey, + }, + projectId: testCreds.projectId, + redact: false, + }, + 'beforeRequestHook' + ); + expect(result).toBeDefined(); + expect(result.verdict).toBe(false); + expect(result.error).toBeNull(); + expect(result.data).toBeDefined(); + expect(result.data?.[0].redactedInput).toBe('My NRIC is *********'); + }); + + it('Should return verdict true if redact is true', async () => { + const context = { + request: { + text: 'My NRIC is S1234567A', + json: { + messages: [ + { + role: 'user', + content: 'My NRIC is S1234567A', + }, + ], + }, + }, + requestType: 'chatComplete', + }; + const result = await handler( + context as PluginContext, + { + credentials: { + apiKey: testCreds.apiKey, + }, + projectId: testCreds.projectId, + redact: true, + }, + 'beforeRequestHook' + ); + expect(result).toBeDefined(); + expect(result.verdict).toBe(true); + expect(result.error).toBeNull(); + expect(result.data).toBeDefined(); + expect(result.data?.[0].redactedInput).toBe('My NRIC is *********'); + }); +}); diff --git a/plugins/f5-guardrails/scan.ts b/plugins/f5-guardrails/scan.ts new file mode 100644 index 000000000..aa07e15bc --- /dev/null +++ b/plugins/f5-guardrails/scan.ts @@ -0,0 +1,154 @@ +import { + HookEventType, + PluginContext, + PluginHandler, + PluginParameters, +} from '../types'; +import { + post, + getCurrentContentPart, + setCurrentContentPart, + HttpError, +} from '../utils'; + +interface F5GuardrailsCredentials { + projectId: string; + apiKey: string; + calypsoUrl?: string; +} + +interface F5GuardrailsResponse { + id: string; + redactedInput: string; + result: { + scannerResults: Array<{ + scannerId: string; + outcome: 'passed' | 'failed'; + data: unknown; + }>; + outcome: 'cleared' | 'flagged' | 'redacted' | 'blocked'; + }; +} + +export const handler: PluginHandler = async ( + context: PluginContext, + parameters: PluginParameters, + eventType: HookEventType +) => { + let error = null; + let verdict = true; + let data = null; + const transformedData: Record = { + request: { + json: null, + }, + response: { + json: null, + }, + }; + let transformed = false; + + const credentials = parameters.credentials as + | F5GuardrailsCredentials + | undefined; + + if (!parameters?.projectId || !credentials?.apiKey) { + return { + error: new Error(`Missing required credentials`), + verdict: true, + data, + transformedData, + transformed, + }; + } + + const { content, textArray } = getCurrentContentPart(context, eventType); + if (!content) { + return { + error: { message: 'request or response json is empty' }, + verdict: true, + data: null, + transformedData, + transformed, + }; + } + + const calypsoUrl = credentials?.calypsoUrl || 'https://us1.calypsoai.app'; + const redact = parameters.redact as boolean | undefined; + + const apiUrl = `${calypsoUrl}/backend/v1/scans`; + + try { + // Process each text segment + const results = await Promise.all( + textArray.map(async (text) => { + if (!text) return null; + + const requestBody = { + input: text, + project: parameters.projectId, + }; + + const requestOptions = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credentials.apiKey}`, + 'User-Agent': 'portkey-ai-plugin/1.0.0', + }, + }; + + const response = await post( + apiUrl, + requestBody, + requestOptions, + parameters.timeout + ); + + return { + outcome: response.result.outcome, + redactedInput: response.redactedInput, + result: response.result, + }; + }) + ); + + let hasRedacted = false; + // Apply redaction only if the parameter is true + if (redact) { + const redactedTexts = results.map( + (result) => result?.redactedInput ?? null + ); + hasRedacted = redactedTexts.some((text) => text !== null); + setCurrentContentPart(context, eventType, transformedData, redactedTexts); + transformed = true; + } + data = results; + const isRequestFlagged = !results.every( + (result) => result?.outcome === 'cleared' + ); + if (isRequestFlagged && !hasRedacted) { + verdict = false; + } + } catch (e) { + if (e instanceof HttpError) { + error = { + message: e.response.body || e.message, + status: e.response.status, + }; + } else { + error = e instanceof Error ? e.message : String(e); + } + + // On error, default to allowing the request (fail open) + verdict = true; + data = null; + } + + return { + error, + verdict, + data, + transformedData, + transformed, + }; +}; diff --git a/plugins/index.ts b/plugins/index.ts index 602e5e7b8..8d4dcb1b8 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -66,7 +66,7 @@ import { handler as walledaiguardrails } from './walledai/walledprotect'; import { handler as defaultregexReplace } from './default/regexReplace'; import { handler as defaultallowedRequestTypes } from './default/allowedRequestTypes'; import { handler as javelinguardrails } from './javelin/guardrails'; - +import { handler as f5GuardrailsScan } from './f5-guardrails/scan'; export const plugins = { default: { regexMatch: defaultregexMatch, @@ -174,4 +174,7 @@ export const plugins = { javelin: { guardrails: javelinguardrails, }, + 'f5-guardrails': { + scan: f5GuardrailsScan, + }, };