Skip to content
Open
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
7 changes: 7 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
146 changes: 146 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/lib/comprehend/comprehend.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (prompt: string) => Promise<T>;

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<string, string>): {
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<ComprehendEntity[]> {
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<string, string> = {
"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<string> {
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<T>(prompt: string, endpoint: PromptEndpoint<T>, options?: RedactOptions): Promise<T> {
return endpoint(await this.redact(prompt, options));
}
}
58 changes: 58 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/tests/comprehend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ComprehendRedactor } from "../lib/comprehend/comprehend.js";

describe("ComprehendRedactor", () => {
const mockFetch = jest.fn<typeof fetch>();

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<string, string>).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]");
});
});
9 changes: 9 additions & 0 deletions JS/edgechains/examples/comprehend-redaction/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions JS/edgechains/examples/comprehend-redaction/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ComprehendRedactor } from "@arakoodev/edgechains.js/ai";

const redactor = new ComprehendRedactor();
const downstreamEndpoint = async (prompt: string): Promise<string> => {
console.log("Protected prompt:", prompt);
return prompt;
};

await redactor.protect(
"Please send the result to customer@example.com.",
downstreamEndpoint,
{ types: ["EMAIL"] }
);
Loading