diff --git a/JS/edgechains/arakoodev/src/ai/src/index.ts b/JS/edgechains/arakoodev/src/ai/src/index.ts index 2c98f37dc..1bf2ef98c 100644 --- a/JS/edgechains/arakoodev/src/ai/src/index.ts +++ b/JS/edgechains/arakoodev/src/ai/src/index.ts @@ -3,3 +3,10 @@ export { GeminiAI } from "./lib/gemini/gemini.js"; export { LlamaAI } from "./lib/llama/llama.js"; export { RetellAI } from "./lib/retell-ai/retell.js"; export { RetellWebClient } from "./lib/retell-ai/retellWebClient.js"; +export { ComprehendRedactor } from "./lib/comprehend/comprehend.js"; +export type { + ComprehendEntity, + ComprehendRedactorOptions, + PromptEndpoint, + RedactOptions, +} from "./lib/comprehend/comprehend.js"; diff --git a/JS/edgechains/arakoodev/src/ai/src/lib/comprehend/comprehend.ts b/JS/edgechains/arakoodev/src/ai/src/lib/comprehend/comprehend.ts new file mode 100644 index 000000000..f23fc050e --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/lib/comprehend/comprehend.ts @@ -0,0 +1,146 @@ +import { createHash, createHmac } from "node:crypto"; + +export interface ComprehendEntity { + BeginOffset: number; + EndOffset: number; + Score?: number; + Type: string; +} + +export interface ComprehendRedactorOptions { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + endpoint?: string; + fetch?: typeof globalThis.fetch; + clock?: () => Date; +} + +export interface RedactOptions { + minimumScore?: number; + types?: string[]; + replacement?: string; +} + +export type PromptEndpoint = (prompt: string) => Promise; + +const target = "Comprehend_20171127.DetectPiiEntities"; + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function hmac(key: Buffer | string, value: string): Buffer { + return createHmac("sha256", key).update(value, "utf8").digest(); +} + +function amzDate(date: Date): { short: string; full: string } { + const iso = date.toISOString().replace(/[-:]|\.\d{3}/g, ""); + return { short: iso.slice(0, 8), full: iso.slice(0, 15) + "Z" }; +} + +function canonicalHeaders(headers: Record): { + value: string; + signed: string; +} { + const names = Object.keys(headers).map((name) => name.toLowerCase()).sort(); + return { + value: names.map((name) => `${name}:${headers[name].trim().replace(/\s+/g, " ")}\n`).join(""), + signed: names.join(";"), + }; +} + +function signingKey(secret: string, date: string, region: string): Buffer { + const dateKey = hmac(`AWS4${secret}`, date); + const regionKey = hmac(dateKey, region); + const serviceKey = hmac(regionKey, "comprehend"); + return hmac(serviceKey, "aws4_request"); +} + +/** Redacts PII from prompts through the AWS Comprehend DetectPiiEntities API. */ +export class ComprehendRedactor { + private readonly region: string; + private readonly accessKeyId: string; + private readonly secretAccessKey: string; + private readonly sessionToken?: string; + private readonly endpoint: string; + private readonly fetchFn: typeof globalThis.fetch; + private readonly clock: () => Date; + + constructor(options: ComprehendRedactorOptions = {}) { + this.region = options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"; + this.accessKeyId = options.accessKeyId || process.env.AWS_ACCESS_KEY_ID || ""; + this.secretAccessKey = options.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY || ""; + this.sessionToken = options.sessionToken || process.env.AWS_SESSION_TOKEN; + this.endpoint = options.endpoint || `https://comprehend.${this.region}.amazonaws.com/`; + this.fetchFn = options.fetch || globalThis.fetch; + this.clock = options.clock || (() => new Date()); + if (!this.fetchFn) throw new Error("A fetch implementation is required"); + } + + /** Detect PII entities without exposing the source text in logs. */ + async detectPiiEntities(text: string, languageCode = "en"): Promise { + if (!this.accessKeyId || !this.secretAccessKey) { + throw new Error("AWS credentials are required for Comprehend redaction"); + } + const url = new URL(this.endpoint); + const body = JSON.stringify({ Text: text, LanguageCode: languageCode }); + const { short, full } = amzDate(this.clock()); + const headers: Record = { + "content-type": "application/x-amz-json-1.1", + host: url.host, + "x-amz-date": full, + "x-amz-target": target, + }; + if (this.sessionToken) headers["x-amz-security-token"] = this.sessionToken; + const canonical = canonicalHeaders(headers); + const request = [ + "POST", + url.pathname || "/", + url.search.slice(1), + canonical.value, + canonical.signed, + sha256(body), + ].join("\n"); + const scope = `${short}/${this.region}/comprehend/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", full, scope, sha256(request)].join("\n"); + const signature = createHmac("sha256", signingKey(this.secretAccessKey, short, this.region)) + .update(stringToSign, "utf8") + .digest("hex"); + headers.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${canonical.signed}, Signature=${signature}`; + + const response = await this.fetchFn(url, { method: "POST", headers, body }); + const responseText = await response.text(); + if (!response.ok) throw new Error(`Comprehend request failed (${response.status}): ${responseText}`); + const payload = JSON.parse(responseText) as { Entities?: ComprehendEntity[] }; + return payload.Entities || []; + } + + /** Replace detected PII spans, preserving the original prompt shape. */ + async redact(text: string, options: RedactOptions = {}): Promise { + const entities = await this.detectPiiEntities(text); + const minimumScore = options.minimumScore ?? 0; + const types = options.types ? new Set(options.types) : undefined; + const selected = entities + .filter((entity) => (entity.Score ?? 1) >= minimumScore && (!types || types.has(entity.Type))) + .filter((entity) => entity.BeginOffset >= 0 && entity.EndOffset > entity.BeginOffset) + .sort((left, right) => left.BeginOffset - right.BeginOffset); + const nonOverlapping: ComprehendEntity[] = []; + for (const entity of selected) { + const previous = nonOverlapping[nonOverlapping.length - 1]; + if (!previous || entity.BeginOffset >= previous.EndOffset) nonOverlapping.push(entity); + } + const codePoints = Array.from(text); + const replacement = options.replacement ?? "[REDACTED]"; + for (const entity of [...nonOverlapping].reverse()) { + codePoints.splice(entity.BeginOffset, entity.EndOffset - entity.BeginOffset, replacement); + } + return codePoints.join(""); + } + + /** Redact a prompt before passing it to any existing endpoint function. */ + async protect(prompt: string, endpoint: PromptEndpoint, options?: RedactOptions): Promise { + return endpoint(await this.redact(prompt, options)); + } +} diff --git a/JS/edgechains/arakoodev/src/ai/src/tests/comprehend.test.ts b/JS/edgechains/arakoodev/src/ai/src/tests/comprehend.test.ts new file mode 100644 index 000000000..ba49bfe51 --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/tests/comprehend.test.ts @@ -0,0 +1,58 @@ +import { ComprehendRedactor } from "../lib/comprehend/comprehend.js"; + +describe("ComprehendRedactor", () => { + const mockFetch = jest.fn(); + + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + Entities: [ + { BeginOffset: 6, EndOffset: 22, Score: 0.99, Type: "EMAIL" }, + ], + }), + { status: 200 } + ) + ); + }); + + it("signs the request and redacts detected PII", async () => { + const redactor = new ComprehendRedactor({ + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "secret", + endpoint: "https://comprehend.us-east-1.amazonaws.com/", + fetch: mockFetch, + clock: () => new Date("2026-08-02T15:00:00.000Z"), + }); + + await expect(redactor.redact("email test@example.com")).resolves.toBe("email [REDACTED]"); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toBe("https://comprehend.us-east-1.amazonaws.com/"); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ + "x-amz-target": "Comprehend_20171127.DetectPiiEntities", + }); + expect(String(init?.headers && (init.headers as Record).authorization)).toContain( + "AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260802/us-east-1/comprehend/aws4_request" + ); + }); + + it("fails closed when credentials are missing", async () => { + const redactor = new ComprehendRedactor({ fetch: mockFetch }); + await expect(redactor.detectPiiEntities("private text")).rejects.toThrow("AWS credentials"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("redacts before invoking a downstream endpoint", async () => { + const redactor = new ComprehendRedactor({ + accessKeyId: "key", + secretAccessKey: "secret", + fetch: mockFetch, + }); + const endpoint = jest.fn(async (prompt: string) => prompt.length); + await expect(redactor.protect("email test@example.com", endpoint)).resolves.toBe(16); + expect(endpoint).toHaveBeenCalledWith("email [REDACTED]"); + }); +}); diff --git a/JS/edgechains/examples/comprehend-redaction/README.md b/JS/edgechains/examples/comprehend-redaction/README.md new file mode 100644 index 000000000..5950f8044 --- /dev/null +++ b/JS/edgechains/examples/comprehend-redaction/README.md @@ -0,0 +1,9 @@ +# AWS Comprehend prompt redaction + +This example detects personally identifiable information with AWS Comprehend +and redacts it before a downstream endpoint receives the prompt. It uses the +public `ComprehendRedactor` middleware and does not log the original prompt. + +Set `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` in the +environment, then run the example through the repository's TypeScript runner. +No credentials are stored in this repository. diff --git a/JS/edgechains/examples/comprehend-redaction/src/index.ts b/JS/edgechains/examples/comprehend-redaction/src/index.ts new file mode 100644 index 000000000..a179d35da --- /dev/null +++ b/JS/edgechains/examples/comprehend-redaction/src/index.ts @@ -0,0 +1,13 @@ +import { ComprehendRedactor } from "@arakoodev/edgechains.js/ai"; + +const redactor = new ComprehendRedactor(); +const downstreamEndpoint = async (prompt: string): Promise => { + console.log("Protected prompt:", prompt); + return prompt; +}; + +await redactor.protect( + "Please send the result to customer@example.com.", + downstreamEndpoint, + { types: ["EMAIL"] } +);