@anzenna/prompt-dlp
v0.2.1
Published
Gate AI prompt submissions through the Anzenna browser extension's DLP policy.
Readme
@anzenna/prompt-dlp
This package lets your web app run a prompt past the Anzenna browser extension before you send that prompt to a model provider. The extension answers allow, warn or block, sometimes with a masked rewrite of the prompt. It draws whatever the employee needs to see inside your page, and it records what happened. Your app asks before it submits, and honors the answer.
The extension, your app, and this package
Your employees' browsers already run the Anzenna extension, and it already gates prompt submissions on ChatGPT, Claude and Gemini. It does that by recognizing those sites: it knows where their composers are, watches them itself, and applies your DLP policy with nobody integrating anything. Your app is not one of those sites and cannot be recognized from the outside, so the direction reverses. Your app asks the extension.
That split decides what each side is responsible for.
- The extension classifies the prompt on the device, evaluates your organization's DLP policy against the result, draws every message the employee sees in your page, and writes the audit record. It owns the debounce interval, the minimum prompt length worth classifying, and every deadline.
- This package is transport. It holds no policy, no thresholds, no copy, no styling and no timer you would want to tune. It calls the extension and hands you the answer.
- Your app supplies the composer, the submit action, the call to your model provider, and any UI of your own. Two jobs: ask before submitting, and honor the answer.
Everything the extension cannot answer resolves to allow. This package has no
error channel at all, so read Failing open before you design
anything around a failure case.
Requirements
Three things must be true before any call in this package does anything.
- Anzenna has onboarded your app's origin. The extension only accepts
messages from origins listed in its own published manifest, so onboarding is
an extension release, not a setting someone flips. Tell your Anzenna contact
the exact origin you will call from (
https://chat.example.com) and wait for the release that carries it. Until then everycheck()resolves toallowand nothing is recorded. - The employee has the extension installed. It is normally force-installed
by your IT organization. Someone without it, or on a browser without
extension support, gets
allowfor everything. - You have the extension ID. Anzenna gives you this string; it identifies
the extension build your fleet runs. A wrong or empty ID produces no error,
just
allowfor everything.
The extension is reached over Chromium's external-messaging API, so this works
in Chrome and Edge. Where that API is absent, every check resolves to allow
and the client reports the no-browser-extension-api diagnostic.
Install
npm install @anzenna/prompt-dlpIntegration
Everything reached through app below is yours, and this package neither
supplies nor calls any of it. app.composer stands in for your prompt input and
app.sendToModel for whatever posts the prompt to your provider.
import { AnzennaPromptClient } from "@anzenna/prompt-dlp";
declare const app: {
composer: HTMLTextAreaElement;
sendToModel(text: string): Promise<void>;
};
// Anzenna gives you this. A wrong one allows everything.
declare const ANZENNA_EXTENSION_ID: string;
const anzenna = new AnzennaPromptClient({
extensionId: ANZENNA_EXTENSION_ID,
// Runs when the employee answers the extension's banner, which is always
// after check() has already returned `warn`.
onWarnResolved: ({ choice, text }) => {
if (choice === "dismiss") {
return;
}
void app.sendToModel(text);
},
});
// Every keystroke. The debounce and the length floor are the SDK's, and this is
// what lets the later check() answer from an already-computed verdict.
app.composer.addEventListener("input", () => {
anzenna.onTextEntry(app.composer.value);
});
// Your submit handler: the Send button, the Enter key, whatever you use.
async function onSend(): Promise<void> {
const prompt = app.composer.value;
const decision = await anzenna.check(prompt);
switch (decision.action) {
case "block":
// Nothing to draw: the extension already showed the employee why.
return;
case "warn":
// Nothing to draw and nothing to remember. The extension is asking the
// employee, and the answer arrives on onWarnResolved with the prompt
// attached.
return;
default:
// Only allow lands here: an action the SDK does not recognize becomes one.
await app.sendToModel(decision.redacted ? decision.text : prompt);
}
}Construct one client and keep it for the life of the composer, then dispose()
it when that composer goes away. Each new client pings the extension again, and
a warning answer is delivered to the client whose check() raised it.
One submit, end to end
- The employee types. Every keystroke goes to
onTextEntry. The SDK debounces on the extension's interval, and once the text clears the extension's minimum length it sends the text off to be classified. The verdict lands in the extension's cache. - The employee submits. Your handler awaits
check(prompt)before it calls your provider. Most of the time the verdict from step 1 is already computed, socheck()returns without waiting on a classification pass. - The verdict comes back in one of four shapes, and the extension has usually already spoken to the employee by the time you see it.
| Decision | What the extension already did | What your app does |
| ----------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- |
| action: "allow" | Nothing the employee saw | Submit what the employee typed |
| action: "allow", redacted: true | Told the employee the prompt was masked | Submit decision.text, the rewrite, instead of what they typed |
| action: "warn" | Showed a banner asking whether to send anyway | Do not submit. Do nothing else. onWarnResolved fires when they answer |
| action: "block" | Showed the employee the reason | Do not submit. Do nothing else |
Nothing in that column is a call back into this package. Anzenna writes the
audit record itself, at the moment the extension answers check(): it already
knows the verdict it just gave. A warning is the one outcome still open at that
point. It is recorded when the employee answers, and the extension takes that
answer from its own banner rather than from your page.
If you have integrated another DLP SDK you are probably looking for the
report() or track() call that closes the loop. There is none here, on
purpose. An audit trail that depends on every customer app calling a reporting
method correctly is an audit trail with holes in it, so the side that already
knows the answer writes it down.
Warnings and the second event
check() resolves on the verdict and never waits on a person. A warn comes
back from check() straight away, the extension draws its own banner in your
page, and the employee's answer arrives later, on onWarnResolved. So a warn
does not block your submit call: it splits one submit into two events, and the
second one is where the prompt actually goes to your provider.
It works that way because a verdict and a person answering a question are different events, and only the first is worth holding a submit on. Holding the promise until somebody clicked a button would pin your submit handler to human reaction time, and a page that stops responding while it waits on a person is worse than one that resumes when the answer arrives.
onWarnResolved receives one object, with three fields and nothing else.
choiceis"send"or"dismiss". On"send", submit. On"dismiss", do nothing at all: the employee has withdrawn the prompt, and Anzenna has already recorded that they did.promptis the string you passed tocheck(), so you do not have to keep track of which prompt is waiting on an answer.textis what to submit: the masked rewrite where policy redacted it, otherwise the same string asprompt.
A "send" goes down your normal path as an ordinary submit, not as a late
return from the old check(). Do not call check() again for that prompt. The
employee has already answered for it, and the extension has already recorded
their answer.
Two consequences worth knowing:
- One warning is answerable at a time. The extension shows one banner per tab and replaces it, so an answer that arrives for a prompt the client is no longer holding is dropped rather than delivered late.
- A warning requires a banner the employee can answer. Where the extension
cannot reach your page to draw one it allows instead of warning, because
nobody can answer a question that was never asked. Where you pass no
onWarnResolved, the answer has nowhere to go and a warned prompt is never submitted.
Your app needs no dialog, no copy and no styling for a warning.
Redaction
redacted is a second discriminant on a decision, independent of action.
const toSubmit = decision.redacted ? decision.text : prompt;When redacted is true, decision.text holds a masked rewrite of the prompt
and that rewrite is what you send to your provider, not what the employee typed.
When it is false, decision.text is undefined. TypeScript enforces the
pairing, so there is no case where you read text from a decision that has
none.
Failing open
check() always resolves and never rejects. Every failure resolves to
{ action: "allow" }:
- no Anzenna extension installed,
- a browser with no extension support at all,
- an extension older than the SDK version you shipped,
- an origin Anzenna has not onboarded,
- a wrong or empty
extensionId, - a service worker that crashed or never woke up,
- a reply that is malformed, or an action this SDK does not recognize,
- the extension's own classification or policy lookup running out of time.
onTextEntry() never throws either. There is no error channel on the calls
themselves, so a decision cannot tell you "allowed by policy" apart from
"nothing answered". Treat allow as the ordinary case and never show an Anzenna
failure to an employee. A DLP control that breaks the product it protects gets
uninstalled.
Where the SDK can say why a check did not reach Anzenna, it says so out of band,
through Diagnostics. The last item above is the exception: a
policy lookup that ran out of time is not a diagnostic, because the extension
did answer and allow was the answer.
Diagnostics
The client writes to console.warn the first time it hits each kind of problem,
prefixed with [anzenna/prompt-dlp]. That is how you tell a healthy install from
a broken one, and it is the whole of the mechanism: there is deliberately no
callback to register, because every one of these still allows the prompt through
and none of them is your app's to handle.
[anzenna/prompt-dlp] extension-unreachable: The browser would not deliver the message...| reason | What it means |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| no-extension-id | The client was constructed without an extensionId. Anzenna supplies that value. |
| no-browser-extension-api | This browser exposes no extension messaging API. Chrome and Edge do. |
| extension-unreachable | The browser would not deliver the message: the extension is missing or disabled, or Anzenna has not onboarded this origin. |
| no-answer | The extension accepted a request and never answered it. |
| send-threw | The call into the browser's messaging API threw. |
| unrecognized-reply | The extension answered in a shape this version of the package does not know. Upgrade @anzenna/prompt-dlp. |
| no-window | The client was constructed where there is no window, so it can never hear a warning answer. Construct it in the browser. |
Three things to rely on:
- One line per reason per client.
check()runs on every submit andonTextEntryon every keystroke, so a broken install would otherwise repeat itself thousands of times. - No prompt text, ever. A diagnostic carries only the reason and a message, so the console line is safe wherever your console output goes.
- Nothing about behavior changes. Every diagnostic accompanies a prompt that was allowed through. A diagnostic is never a reason to stop a submit.
Timing the extension owns
Do not put your own timeout around check(), and do not debounce onTextEntry
yourself.
- The extension owns the deadline on a verdict and returns
allowwhen it decides to stop waiting. A slowcheck()is therefore already the extension's decision rather than a hang, and a timeout of yours would only race an answer that is coming. - The extension supplies the debounce interval and the minimum length over its
ping, and
onTextEntryapplies both. They can change without you shipping a new version of your app. Calling it on every keystroke is correct, and it is what makes the latercheck()fast. Debouncing it yourself delays the classificationcheck()is waiting for. - The SDK's only timer is a 90-second backstop for an extension that disappears
mid-call, set clear of every deadline the extension itself can spend. It
bounds the reply to a
check(), and it reports theno-answerdiagnostic when it fires. It does not bound the wait on a warning banner, whichcheck()has already returned before.
API
new AnzennaPromptClient(options)
| Option | Type | Notes |
| ---------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ |
| extensionId | string, required | Supplied by Anzenna. |
| onWarnResolved | (resolved: WarnResolved) => void | Optional. Called when the employee answers a warning banner. Without it, answers are lost. |
The constructor pings the extension once, to pick up the scoring hints before the first keystroke needs them, and starts listening for warning answers.
onTextEntry(text: string): void
Tell the client the composer's text changed. Call it on every keystroke.
Fire and forget: it returns immediately and there is no result to wait on. The
debounce and the minimum length are applied inside, and this is what warms the
verdict that check() later reads.
check(text: string): Promise<PromptDecision>
Ask what to do with a prompt the employee just submitted. Await it before you
send anything to your provider, then follow the answer: submit on allow, stop
on block, and stop and wait for onWarnResolved on warn. It always
resolves, and a warn resolves immediately (see
Warnings and the second event).
probe()
probe(): Promise<{
version: number;
extensionVersion: string;
scoring?: { debounceMs: number; minLength: number };
} | null>Resolves to the extension's protocol version and release version, or null when
no extension answered. Use it for a health check or a diagnostic screen. You do
not need to call it before check(), which handles an absent extension on its
own, and you should not make your submit path conditional on it.
It retries a missing answer on a fixed internal schedule, six attempts over roughly eight seconds, because a browser that is still starting the extension up can miss the first ping. A negative result therefore takes about that long to arrive, which matters if you render something while waiting. A success is cached, so later calls return it immediately.
dispose(): void
Release everything the client holds: a pending classification, an unanswered warning, and its page event listener. Call it when you tear down the composer. A disposed client no longer delivers warning answers, so create a new one for a new composer rather than reusing it.
Exported types
type PromptDecision = (
{ redacted: false; text?: undefined } | { redacted: true; text: string }
) &
(
| { action: "allow" }
| { action: "warn"; message: string }
| { action: "block"; message: string }
);
type WarnResolved = { choice: WarnChoice; prompt: string; text: string };
type WarnChoice = "send" | "dismiss";The package also exports AnzennaPromptOptions, the constructor's options type.
