From 179bc222f82bd2f3b9917eb9bcc54062647b1bd7 Mon Sep 17 00:00:00 2001 From: Anis Snoussi Date: Sun, 27 Jul 2025 22:36:21 +0200 Subject: [PATCH] feat(all implementation): implemented the fail-safe sdk for promptcage.com --- README.md | 133 +++++++++++++++++++++++++--- package-lock.json | 69 +++++++++++++++ package.json | 4 + src/index.ts | 205 ++++++++++++++++++++++++++++++++++++++++++- test/index.spec.ts | 212 +++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 604 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index af07420..fc79cb0 100644 --- a/README.md +++ b/README.md @@ -17,29 +17,140 @@ npm install promptcage ## Usage +### Basic Usage + +```ts +import { PromptCage } from 'promptcage'; + +// Initialize with API key from PROMPTCAGE_API_KEY environment variable +const promptCage = new PromptCage(); + +// Or initialize with API key directly +const promptCage = new PromptCage('your-api-key-here'); + +// Or initialize with options object +const promptCage = new PromptCage({ + apiKey: 'your-api-key-here', + maxWaitTime: 1000 // 1 second max wait time +}); + +// Detect prompt injection +const result = await promptCage.detectInjection('Your user input here'); + +console.log(result); +//=> { safe: true, detectionId: 'det_123456', error: undefined } +``` + +### Advanced Usage with Metadata + ```ts -import { myPackage } from 'promptcage'; +import { PromptCage } from 'promptcage'; + +const promptCage = new PromptCage({ + apiKey: 'your-api-key', + maxWaitTime: 3000 // 3 seconds max wait time (custom) +}); + +const result = await promptCage.detectInjection( + 'Your user input here', + 'user-123', // optional anonymous user ID + { + source: 'web-app', + version: '1.0', + sessionId: 'sess_456' + } // optional metadata +); + +if (result.safe) { + console.log('Prompt is safe to use'); +} else { + console.log('Potential prompt injection detected!'); + console.log('Detection ID:', result.detectionId); + if (result.error) { + console.log('Error:', result.error); + } +} +``` -myPackage('hello world'); -//=> 'hello world from my package' +## Environment Variables + +Set your API key as an environment variable: + +```bash +export PROMPTCAGE_API_KEY=your-api-key-here ``` ## API -### myPackage(input, options?) +### PromptCage -#### input +The main class for interacting with the PromptCage API. -Type: `string` +#### constructor(options?) -#### options +- `options` (optional): Configuration object or API key string + - `apiKey` (optional): Your PromptCage API key. If not provided, will use `PROMPTCAGE_API_KEY` environment variable + - `maxWaitTime` (optional): Maximum wait time in milliseconds before treating request as safe (default: 1000ms) -Type: `object` +#### detectInjection(prompt, userAnonId?, metadata?) -##### postfix +Detects potential prompt injection in the given text. -Type: `string` -Default: `rainbows` +- `prompt` (required): The text to analyze for prompt injection +- `userAnonId` (optional): Anonymous user identifier for tracking +- `metadata` (optional): Additional metadata object + +Returns a `Promise` with: +- `safe`: Boolean indicating if the prompt is safe +- `detectionId`: Unique identifier for this detection +- `error`: Error message if something went wrong (optional) + +### Fail-Safe Behavior + +The package is designed to be **fail-safe** and will never block your application. The SDK **fails open** in all error scenarios (Network errors, Rate limit exceeded, Quota exceeded ...). + +**Important**: In all error cases, `safe` will be `true` and `error` will contain the error message. This ensures your application continues to work even when the PromptCage API is down, slow, or experiencing issues. + +### Error Handling + +The SDK always returns `safe: true` in error cases, but you can still check for errors: + +```ts +const result = await promptCage.detectInjection('Your user input here'); + +if (result.safe) { + if (result.error) { + // API had an issue, but we're treating it as safe + console.log('Warning:', result.error); + // You might want to log this for monitoring + } else { + // API confirmed the prompt is safe + console.log('Prompt is safe'); + } +} else { + // API detected prompt injection + console.log('Prompt injection detected!'); + console.log('Detection ID:', result.detectionId); +} +``` + +### Performance Considerations + +The `maxWaitTime` option helps prevent performance impact on your application: + +```ts +// Fast response for performance-critical apps +const promptCage = new PromptCage({ + apiKey: 'your-key', + maxWaitTime: 100 // 100ms max wait +}); + +// Longer wait for slower networks +const promptCage = new PromptCage({ + apiKey: 'your-key', + maxWaitTime: 10000 // 10 seconds max wait +}); +``` [build-img]:https://github.com/devndeploy/promptcage/actions/workflows/release.yml/badge.svg [build-url]:https://github.com/devndeploy/promptcage/actions/workflows/release.yml diff --git a/package-lock.json b/package-lock.json index f09b247..1bd892e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "promptcage", "version": "0.0.0-development", "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.7" + }, "devDependencies": { "@ryansonshine/commitizen": "^4.2.8", "@ryansonshine/cz-conventional-changelog": "^3.3.4", "@types/jest": "^27.5.2", "@types/node": "^12.20.11", + "@types/node-fetch": "^2.6.4", "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.22.0", "conventional-changelog-conventionalcommits": "^5.0.0", @@ -2349,6 +2353,32 @@ "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -5760,6 +5790,7 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "dev": true, + "optional": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -9180,6 +9211,44 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index 7a2a13d..cb31c45 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "engines": { "node": ">=12.0" }, + "dependencies": { + "node-fetch": "^2.6.7" + }, "keywords": [ "llm", "prompt injection", @@ -46,6 +49,7 @@ "@ryansonshine/cz-conventional-changelog": "^3.3.4", "@types/jest": "^27.5.2", "@types/node": "^12.20.11", + "@types/node-fetch": "^2.6.4", "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.22.0", "conventional-changelog-conventionalcommits": "^5.0.0", diff --git a/src/index.ts b/src/index.ts index 0349cbc..f16e178 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,204 @@ -export const myPackage = (taco = ''): string => `${taco} from my package`; +import fetch from 'node-fetch'; + +/** + * Response from the PromptCage API detection endpoint + */ +export interface DetectionResponse { + /** Whether the prompt is considered safe from injection attacks */ + safe: boolean; + /** Unique identifier for this detection request */ + detectionId: string; + /** Error message if the request failed (optional) */ + error?: string; +} + +/** + * Request payload sent to the PromptCage API + */ +export interface DetectionRequest { + /** The text prompt to analyze for injection attacks */ + prompt: string; + /** Anonymous user identifier for tracking and analytics */ + userAnonId?: string; + /** Additional metadata for the detection request */ + metadata?: Record; +} + +/** + * Configuration options for the PromptCage client + */ +export interface PromptCageOptions { + /** Your PromptCage API key. If not provided, will use PROMPTCAGE_API_KEY environment variable */ + apiKey?: string; + /** Maximum wait time in milliseconds before treating request as safe (default: 1000ms) */ + maxWaitTime?: number; +} + +/** + * PromptCage client for detecting prompt injection attacks + * + * This class provides a fail-safe interface to the PromptCage API for detecting + * potential prompt injection attacks in user input. The client is designed to + * never block your application - it fails open in all error scenarios. + * + * @example + * ```ts + * // Basic usage with environment variable + * const promptCage = new PromptCage(); + * + * // With API key directly + * const promptCage = new PromptCage('your-api-key'); + * + * // With configuration options + * const promptCage = new PromptCage({ + * apiKey: 'your-api-key', + * maxWaitTime: 3000 + * }); + * ``` + */ +export class PromptCage { + /** The API key for authentication with PromptCage */ + private apiKey: string; + /** Base URL for the PromptCage API */ + private baseUrl = 'https://promptcage.com/api/v1'; + /** Maximum wait time in milliseconds before aborting requests */ + private maxWaitTime: number; + + /** + * Creates a new PromptCage client instance + * + * @param options - Configuration options or API key string + * @throws {Error} When no API key is provided (neither in options nor environment variable) + * + * @example + * ```ts + * // Using environment variable + * const promptCage = new PromptCage(); + * + * // Using API key string + * const promptCage = new PromptCage('your-api-key'); + * + * // Using options object + * const promptCage = new PromptCage({ + * apiKey: 'your-api-key', + * maxWaitTime: 2000 + * }); + * ``` + */ + constructor(options?: PromptCageOptions | string) { + if (typeof options === 'string') { + this.apiKey = options; + this.maxWaitTime = 1000; + } else { + this.apiKey = options?.apiKey || process.env.PROMPTCAGE_API_KEY || ''; + this.maxWaitTime = options?.maxWaitTime || 1000; + } + + if (!this.apiKey) { + throw new Error( + 'API key is required. Set PROMPTCAGE_API_KEY environment variable or pass it to the constructor.' + ); + } + } + + /** + * Detects potential prompt injection attacks in the given text + * + * This method sends the prompt to the PromptCage API for analysis. The method + * is designed to be fail-safe - if the API is unavailable, slow, or returns + * an error, the method will return `safe: true` with an error message. + * + * @param prompt - The text to analyze for prompt injection attacks + * @param userAnonId - Optional anonymous user identifier for tracking + * @param metadata - Optional metadata object for additional context + * @returns Promise that resolves to detection results + * @throws {Error} When prompt is empty or not a string + * + * @example + * ```ts + * const result = await promptCage.detectInjection('User input here'); + * + * if (result.safe) { + * console.log('Prompt is safe'); + * } else { + * console.log('Injection detected!'); + * console.log('Detection ID:', result.detectionId); + * } + * + * // With user tracking and metadata + * const result = await promptCage.detectInjection( + * 'User input here', + * 'user-123', + * { source: 'web-app', sessionId: 'sess_456' } + * ); + * ``` + */ + async detectInjection( + prompt: string, + userAnonId?: string, + metadata?: Record + ): Promise { + if (!prompt || typeof prompt !== 'string') { + throw new Error('Prompt must be a non-empty string'); + } + + const requestBody: DetectionRequest = { + prompt, + ...(userAnonId && { userAnonId }), + ...(metadata && { metadata }), + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.maxWaitTime); + + try { + const response = await fetch(`${this.baseUrl}/detect`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + return { + safe: true, + detectionId: '', + error: `API request failed with status ${response.status}: ${errorText}`, + }; + } + + const data = (await response.json()) as DetectionResponse; + + return { + safe: data.safe || false, + detectionId: data.detectionId || '', + error: data.error, + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + return { + safe: true, + detectionId: '', + error: `Request exceeded max wait time of ${this.maxWaitTime}ms`, + }; + } + + return { + safe: true, + detectionId: '', + error: + error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } +} + +export default PromptCage; diff --git a/test/index.spec.ts b/test/index.spec.ts index d305240..baa551c 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,13 +1,211 @@ -import { myPackage } from '../src'; +const mockFetch = jest.fn(); +jest.mock('node-fetch', () => ({ + __esModule: true, + default: mockFetch, +})); -describe('index', () => { - describe('myPackage', () => { - it('should return a string containing the message', () => { - const message = 'Hello'; +import { PromptCage } from '../src'; - const result = myPackage(message); +describe('PromptCage', () => { + const mockApiKey = 'test-api-key'; + const originalEnv = process.env; - expect(result).toMatch(message); + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockClear(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + it('should initialize with provided API key (string)', () => { + const promptCage = new PromptCage(mockApiKey); + expect(promptCage).toBeInstanceOf(PromptCage); + }); + + it('should initialize with options object', () => { + const promptCage = new PromptCage({ apiKey: mockApiKey }); + expect(promptCage).toBeInstanceOf(PromptCage); + }); + + it('should initialize with custom max wait time', () => { + const promptCage = new PromptCage({ + apiKey: mockApiKey, + maxWaitTime: 10000, + }); + expect(promptCage).toBeInstanceOf(PromptCage); + }); + + it('should initialize with API key from environment variable', () => { + process.env.PROMPTCAGE_API_KEY = mockApiKey; + const promptCage = new PromptCage(); + expect(promptCage).toBeInstanceOf(PromptCage); + }); + + it('should throw error when no API key is provided', () => { + delete process.env.PROMPTCAGE_API_KEY; + expect(() => new PromptCage()).toThrow('API key is required'); + }); + }); + + describe('detectInjection', () => { + let promptCage: PromptCage; + + beforeEach(() => { + promptCage = new PromptCage(mockApiKey); + }); + + it('should make successful API call and return safe result', async () => { + const mockResponse = { + safe: true, + detectionId: 'det_123456', + error: undefined, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as any); + + const result = await promptCage.detectInjection('test prompt'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://promptcage.com/api/v1/detect', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'test prompt', + }), + signal: expect.any(AbortSignal), + }) + ); + + expect(result).toEqual(mockResponse); + }); + + it('should return safe false when API detects injection', async () => { + const mockResponse = { + safe: false, + detectionId: 'det_789', + error: 'Prompt injection detected', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as any); + + const result = await promptCage.detectInjection('test prompt'); + + expect(result).toEqual({ + safe: false, + detectionId: 'det_789', + error: 'Prompt injection detected', + }); + }); + + it('should include userAnonId and metadata in request', async () => { + const mockResponse = { + safe: false, + detectionId: 'det_789', + error: undefined, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as any); + + const result = await promptCage.detectInjection( + 'test prompt', + 'user-123', + { source: 'test', version: '1.0' } + ); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://promptcage.com/api/v1/detect', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'test prompt', + userAnonId: 'user-123', + metadata: { source: 'test', version: '1.0' }, + }), + signal: expect.any(AbortSignal), + }) + ); + + expect(result).toEqual(mockResponse); + }); + + it('should handle API errors gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + } as any); + + const result = await promptCage.detectInjection('test prompt'); + + expect(result).toEqual({ + safe: true, + detectionId: '', + error: 'API request failed with status 401: Unauthorized', + }); + }); + + it('should handle network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error') as any); + + const result = await promptCage.detectInjection('test prompt'); + + expect(result).toEqual({ + safe: true, + detectionId: '', + error: 'Network error', + }); + }); + + it('should throw error for invalid prompt input', async () => { + await expect(promptCage.detectInjection('')).rejects.toThrow( + 'Prompt must be a non-empty string' + ); + await expect(promptCage.detectInjection(null as any)).rejects.toThrow( + 'Prompt must be a non-empty string' + ); + await expect( + promptCage.detectInjection(undefined as any) + ).rejects.toThrow('Prompt must be a non-empty string'); + }); + + it('should handle max wait time gracefully', async () => { + const promptCageWithTimeout = new PromptCage({ + apiKey: mockApiKey, + maxWaitTime: 10, + }); + + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValueOnce(abortError); + + const result = await promptCageWithTimeout.detectInjection('test prompt'); + + expect(result).toEqual({ + safe: true, + detectionId: '', + error: 'Request exceeded max wait time of 10ms', + }); }); }); });