@priventai/core
v0.16.0
Published
AI agent runtime security: context-preserving tokenization, risk scoring, and policy enforcement
Downloads
749
Readme
@priventai/core
Early Access. Privent is currently in private rollout. API keys are issued through our access process. Request access →
Runtime security for AI agents. Tokenize PII, secrets, and sensitive data before they reach LLMs; restore the originals only at trusted egress points.
"Customer: [email protected], Card: 4111-1111-1111-1111"
↓ tokenize
"Customer: [EMAIL_001], Card: [CREDIT_CARD_001]"
↓ LLM call
"Reply to [EMAIL_001] about [CREDIT_CARD_001]"
↓ detokenize at trusted sink
"Reply to [email protected] about 4111-1111-1111-1111"The LLM never sees raw secrets. Tokens are reversible, session-scoped, and deterministic within a session.
Features
- Hybrid detection — regex patterns for EMAIL, PHONE, CREDIT_CARD, IBAN, SSN, API_KEY, JWT, AWS_KEY, IP, URL out of the box; pluggable ML extractor.
- Session-scoped vault — same value maps to the same token within a session; cross-session tokens are randomized to block correlation attacks.
- Deep detokenization — walks nested objects, arrays, and strings; cycle-safe.
- Typed error taxonomy —
PriventErrorbase class plus 9 subclasses, each with aretryableflag and JSON serialization that scrubs sensitive fields. - Resilient HTTP client — exponential backoff with jitter, idempotency keys, AbortController-based timeouts.
- Fail-open by default — if Privent Cloud is unreachable, falls back to local regex detection rather than blocking traffic.
- Dual ESM + CJS build with full TypeScript types.
Installation
npm install @priventai/core
# or
pnpm add @priventai/core
# or
yarn add @priventai/coreRequires Node.js 20+.
Quick Start
import { PriventClient } from '@priventai/core';
const client = new PriventClient({
apiKey: process.env.PRIVENT_API_KEY, // request via https://www.privent.ai/request-access — without it, runs in regex-only mode
});
await client.withSession(async ({ vault }) => {
const { tokenizedText } = await client.tokenizer.tokenize(
'Customer: [email protected], Card: 4111111111111111',
vault,
{ kinds: ['EMAIL', 'CREDIT_CARD'] },
);
// tokenizedText === "Customer: [EMAIL_001], Card: [CREDIT_CARD_001]"
const llmResponse = await myLLM.invoke(tokenizedText);
const restored = await client.tokenizer.detokenize(llmResponse, vault);
return restored;
});
// vault.destroy() runs automatically in a finally blockAPI
PriventClient
Main entry point. Wires together a vault, tokenizer, risk scorer, policy engine, and audit logger.
const client = new PriventClient({
apiKey?: string; // defaults to PRIVENT_API_KEY env var
baseUrl?: string; // default: https://api.privent.ai
vaultFactory?: VaultFactory; // default: in-memory
tokenizer?: Tokenizer; // default: HybridTokenizer (regex)
riskScorer?: RiskScorer;
policyEngine?: PolicyEngine;
auditLogger?: AuditLogger;
maxRetries?: number; // default: 2
timeout?: number; // default: 30_000 ms
failPolicy?: 'open' | 'closed'; // default: 'open'
});client.withSession(fn)
Opens a session, gives fn a vault and IDs, and destroys the vault when fn resolves or throws.
const result = await client.withSession(async ({ vault, sessionId, traceId }) => {
// vault: TokenVault — store/retrieve tokens
// sessionId: per-session UUID
// traceId: correlation ID for audit logs
return doWork(vault);
});Tokenizer
const { tokenizedText, entities } = await client.tokenizer.tokenize(text, vault, {
kinds: ['EMAIL', 'PHONE'],
allowList: ['[email protected]'], // never tokenize
denyList: ['internal-project-x'], // always tokenize
customPatterns: [
{ kind: 'PROJECT_CODE', regex: /PROJ-\d{4}/g, confidence: 0.95 },
],
});Token format: [KIND_NNN] — e.g. [EMAIL_001], [CREDIT_CARD_003]
Built-in entity types:
| Kind | Confidence | Notes |
|---|---|---|
| EMAIL | 0.95 | RFC-pragmatic match |
| PHONE | 0.80 | International + national formats |
| CREDIT_CARD | 0.98 | Validated with Luhn checksum |
| IBAN | 0.97 | Country-aware length check |
| SSN | 0.90 | US format |
| API_KEY | 0.88 | Common provider prefixes (sk-, ghp_, xoxb-, …) |
| JWT | 0.98 | Three-segment base64url structure |
| AWS_KEY | 0.99 | AKIA… access key IDs |
| IP_ADDRESS | 0.85 | IPv4 |
| URL | 0.90 | http/https |
detokenizeDeep(value, vault)
Walks any value (string, object, array, Map, Set) and replaces tokens with their originals.
import { detokenizeDeep } from '@priventai/core';
const restored = await detokenizeDeep(
{ email: '[EMAIL_001]', items: ['[PHONE_001]', 'plain text'] },
vault,
);Cycle-safe (uses WeakSet), depth-limited to 64, skips binary buffers, fast-paths strings without [.
Vault
import { InMemoryTokenVault } from '@priventai/core/vault/memory';
const vault = new InMemoryTokenVault('session-1');
await vault.store({ token: '[EMAIL_001]', value: '[email protected]', kind: 'EMAIL', ... });
const entry = await vault.retrieve('[EMAIL_001]');
const same = await vault.findByValue('[email protected]', 'EMAIL'); // determinism
await vault.destroy();Normalization for determinism: EMAIL → lowercase + trim; PHONE, CREDIT_CARD, IBAN → digits only; others → trim.
Errors
import {
PriventError,
PriventConfigError,
PriventAuthError,
PriventRateLimitError,
PriventAPIError,
PriventNetworkError,
PriventTimeoutError,
PriventValidationError,
PriventVaultFullError,
PriventVaultDestroyedError,
} from '@priventai/core';
try {
await client.tokenizer.tokenize(text, vault);
} catch (err) {
if (err instanceof PriventError) {
console.log(err.code, err.retryable, err.toJSON());
}
}toJSON() scrubs sensitive fields so errors are safe to log.
Audit Events
The AuditEvent.type union covers eight kinds: session_open | tokenize | detokenize | risk_check | llm_call | policy_decision | egress | error. 'llm_call' is emitted by the external @priventai/n8n-hook package; the in-tree n8n-nodes-privent package emits the first four (plus error on failure paths).
Wire contract: every event is serialized to the v1 schema (POST /v1/audit/events) at flush time. The TypeScript AuditEvent interface is camelCase (traceId, sessionId, workflowId, nodeId, numeric timestamp, optional framework: 'manual'); the wire payload is snake_case (trace_id, session_id, workflow_id, node_id, ISO8601 timestamp). A UUID event_id is generated per event for backend idempotency, and framework: 'manual' is silently coerced to 'sdk' on the wire (the value is deprecated).
AuditEvent.metadata is a free-form Record<string, unknown> on the wire — adapters populate snake_case keys (agent_name, workflow_name, execution_id, node_name) using the NodeAuditContext typing aid, plus event-type-specific extras (sink_id / sink_url_host / sink_trusted on detokenize, risk_score / risk_level / categories on risk_check, prompt_tokens / completion_tokens / latency_ms / provider / model on llm_call, etc.).
The v1 wire schema also accepts optional top-level scalars and config blocks that the backend denormalizes into indexed columns and auto-discovered resource tables, so dashboards can read them without JSON-extract at query time:
| Top-level field | Purpose |
|---|---|
| latency_ms | Per-event latency (int, ms). Surfaced as the node's P50/P95/P99 in the inspector. |
| error_type | Short error classifier (≤64 chars) used for the error-breakdown panel. |
| node_name | Mirror of metadata.node_name; promoted for indexed filtering. |
| http_status | Response code for sink/egress events; powers the 4xx/5xx KPI cells. |
| webhook_config | { url, method?, auth_scheme?, content_type? } — backend upserts a Webhook row by (org, url, method). |
| sink_config | { sink_key, host, trusted?, tls_version?, cert_fingerprint?, added_by_user_id? } — upserts a Sink row by (org, sink_key). |
| vault_config | { name, region?, retention_days?, detection_mode?, detection_version? } — upserts a Vault row by (org, name). |
| cron_config | { cron_expression, timezone? } — upserts a CronSchedule row by (org, cron_expression, workflow_id). |
All seven fields are optional. Existing callers that don't set them continue to work — the backend treats every event without the new fields exactly as it did before. The AuditEvent interface exposes the same fields in camelCase (latencyMs, errorType, nodeName, httpStatus, webhookConfig, sinkConfig, vaultConfig, cronConfig); serializeForWire handles the snake_case translation.
Critical events flush immediately rather than waiting for the buffer interval: any error event, and policy_decision events whose metadata.decision === 'BLOCK'.
Sample wire payload for a sink egress event using both metadata and the new top-level shape:
{
"event_id": "8a3f…",
"type": "egress",
"trace_id": "…",
"session_id": "…",
"timestamp": "2026-05-23T12:00:00.000Z",
"framework": "n8n",
"workflow_id": "abc123",
"node_id": "<node uuid>",
"node_name": "Notify Slack",
"latency_ms": 412,
"http_status": 200,
"sink_config": {
"sink_key": "https://hooks.slack.com/services/T0/B0/x",
"host": "hooks.slack.com",
"trusted": true,
"tls_version": "TLS1.3",
"cert_fingerprint": "sha256/AAA…"
},
"metadata": {
"agent_name": "support-bot",
"workflow_name": "Inbound Triage",
"execution_id": "exec-42",
"node_name": "Notify Slack",
"framework": "n8n"
}
}Security Properties
- No persistence by default. The in-memory vault holds entries for the session lifetime only.
- Cross-session randomization. Token IDs are not derived from input value — observing tokens across sessions reveals nothing.
- Bounded vaults. Default cap of 10,000 entries per session prevents unbounded growth.
- Sensitive fields scrubbed in error JSON. API keys, tokens, and entity values are stripped before serialization.
- Idempotency keys on every Cloud request to make retries safe.
Documentation
- Privent Documentation
- Request access — required to obtain a production API key
License
Apache-2.0 © Privent AI
Questions? Contact us at [email protected].
Verifying this package
@priventai/core is reproducible: a clean checkout of a release tag rebuilds the
published tarball's contents byte for byte. Two things will make a correct
verification look like a mismatch, so both are stated here rather than left for
you to discover.
Use pnpm pack, not npm pack
This is a pnpm workspace and its manifest uses the catalog: protocol.
npm pack does not resolve it; the publish path does. Verify with npm pack
and you will see a real content difference in package/package.json —
published "zod": "^3.23.0" "tsup": "^8.3.0"
npm pack "zod": "catalog:" "tsup": "catalog:"— and conclude the artifact does not match its source. It does. The packer was wrong.
Compare the inner tar, not the .tgz
A gzip frame carries an OS byte written by whoever packs: 0x03 from Linux,
0x13 from macOS. Two byte-identical builds therefore produce different .tgz
checksums on different machines. The tar is the content-bearing unit; the frame
is not.
The commands
git checkout '@priventai/core@<version>'
pnpm install --frozen-lockfile
pnpm --filter @priventai/core build
cd packages/core && pnpm pack --pack-destination /tmp/built
npm pack "@priventai/core@<version>" --pack-destination /tmp/published
# compare contents, ignoring the gzip frame
diff <(gunzip -c /tmp/built/*.tgz | shasum -a 256) \
<(gunzip -c /tmp/published/*.tgz | shasum -a 256)Or run the checker this repository uses, which does both comparisons and prints what each one does not answer:
node scripts/verify-artifact.mjs --against <version>Upgrading to 0.16.0 — overlapping spans mask the union
Behavioural. Your output can differ. When two detections overlap — a custom pattern and a built-in, or an ML span and a regex match — they used to compete and one was discarded. They now produce one masked unit covering the union, and the candidate with the widest region supplies the label.
If you depend on exact token identity across overlaps, expect a wider token, fewer
tokens, or a different kind. contact [email protected] today with a
/jane\.doe/ pattern produced contact [PROJECT_001]@acme.com today in 0.15.1
and produces contact [EMAIL_001] today now.
Nothing is lost: every contributing candidate is on TokenizedEntity.candidates.
What it fixes, both live in 0.14.2 and 0.15.1: a custom pattern reduced
masking (17 characters → 8, mail domain in the clear, nothing recorded), and an ML
span was discarded for a shorter regex match — the second needs no custom pattern,
so every ML-enabled deployment was affected.
No published coverage number changes. Our eval corpus contains neither custom patterns nor overlapping ML spans, so masked characters are identical before and after. This release repairs paths that corpus does not contain.
0.15.0 is uninstallable — use 0.15.1
0.15.0 published with zod: "catalog:", a pnpm workspace protocol that leaked
into the registry manifest. Neither npm nor pnpm can install it. 0.15.1 is the
same code, published correctly — everything in the section below applies to it
unchanged.
Upgrading to 0.15.0 — a masking floor, and a type change that finds an existing bug
Read the type change first, because it is the one that will stop your build.
ExtractionMetaV1.latencyMs and buildSignature are now optional. They were
typed as required and the ML service has never emitted either, so the types said
number and string while the runtime value was undefined. meta.latencyMs.toFixed(1)
compiled and threw. Nothing about that risk is new in 0.15.0 — the compiler
just stopped hiding it. The sites TypeScript now flags are the crash sites you
already had.
BEHAVIOURAL, and the one to test before you ship it: less text is masked.
HybridTokenizer now applies a confidence floor to what it SUBSTITUTES.
DEFAULT_TRANSFORM_FLOOR is 0.6. Findings below it are left in your text
and reported on the new TokenizeResult.observed, with their confidence, the
floor they were measured against, and why they were not substituted. Nothing is
dropped for being below the floor.
observed covers every exclusion at the ML admission gate, not only the
floor — declining by source is still declining. reason is one of
below-transform-floor (carries the floor), source-not-admitted (the
producer's source is not one the tokenizer substitutes from — regex, hint,
or none declared — carried on the record), or allow-listed (your own
allowList). Each carries a detail. What it does not cover: overlap
resolution runs after this gate, so a finding discarded by removeOverlaps does
not appear — a different stage, unchanged here.
source-not-admitted closes a silent drop that predates this release: a
regex- or hint-sourced finding no local detector covered was excluded from
transformation and absent from every output field. It is still not
substituted; it is now reported.
If your integration depends on the previous behaviour — every ML finding
substituted regardless of confidence — pass a lower floor as the fourth argument
to the HybridTokenizer constructor. An overridden value logs once at
construction, on purpose: a threshold that decides what gets masked is a security
control, and an operator has to be able to see its effective value.
Measured end to end over 257 annotated texts, counting characters substituted that no label calls sensitive: 78 spans / 1256 chars before, 31 spans / 913 chars after. The 47 spans / 343 chars closed are all model-sourced person names at confidence exactly 0.55 — the ML service's spaCy bridge, whose score is a fixed uncalibrated constant and which measured 0.4554 standalone precision.
Stated plainly, because the good half alone would mislead: this closes the model-source corruption completely and by construction; it does not close SDK corruption. 913 characters still get substituted, and the mechanisms that would close them are an allowList and a detector-side regex fix, both outside this release. Anyone reading only the good half would be reading a blended number.
The rejection this removes was never selective. A single unreadable field did
not cost you that field — it threw out the whole ML pass, including every
well-formed entity in the same response. On a response carrying one unreadable
entity and one good one, 0.14.2 masks 0 of 2; 0.15.0 masks 1 of 2 and reports
the other. This release is not only a floor; it is the end of all-or-nothing
response validation.
An incomplete extraction now says so. When the ML service reports that it
could not finish, TokenizeResult.extractionDegraded is present — so a short
entity list means "the detector could not look", not "nothing was found". It is a
separate field from mlDegradation on purpose: extractionDegraded.origin is
'server' (the call succeeded, the server reported a gap), while mlDegradation
is this client's own pass failing. A degraded pass still masks everything it did
manage to find.
Not breaking, but worth knowing: response schemas are now forward-compatible,
so a field the server adds can no longer disable this client — previously
ExtractionMetaSchema rejected every shape the ML service produces, and the
rejection surfaced as "ML service unavailable" with a silent fall back to
regex-only detection. A degraded ML pass now names its reason on
TokenizeResult.mlDegradation. Request schemas are unchanged and still strict.
The cloud request also declares client_capabilities, so a server can extend
extraction_meta without breaking clients on 0.14.2 and earlier; if the server
is too old to know the field, TokenizeResult.capabilitiesDeclined says so.
Upgrading to 0.14.0 — /v1/risk/score responses are validated
If you are already on
0.14.0, you have this behaviour and did not get this notice.0.14.0shipped without a changelog in its tarball and without this section in its README, so nothing on the registry page told you. The change is in0.14.0, not in0.14.1—0.14.1adds only this documentation and changes no code.
BREAKING. A malformed /v1/risk/score response now throws instead of
scoring. CloudRiskScorer.score() and scoreBatch() raise
PriventValidationError, naming the field and what was expected, when the
response has a declared field of the wrong JSON type or a required field
missing.
Previously they returned a synthetic LOW-risk score — a risk score that was never computed. If your application branched on a LOW result, it may have been branching on a body this SDK could not parse.
What deliberately does not throw
- Unknown or extra fields — ignored. A strict schema on a response is a promise the server will never add a field, and nobody made that promise.
- Format mismatches on a declared field of the right type.
- Transport failures — still fail open to
model: 'fallback'. - ML enrichment — still degrades to regex-only detection. No score is fabricated there, so nothing is masked.
There is no flag to disable validation. If you need to continue on an unparseable
body, catch PriventValidationError and decide in your own code, where the
decision is visible.
What is NOT validated
Validation is opt-in per call site. These five have none, so you can check whether the endpoint you use is covered:
POST /v1/risk/batch (a schema exists but no captured response has verified it),
POST /v1/vault/find-or-create-batch, POST /v1/vault/retrieve-batch,
POST /v1/vault/destroy, and privent-ml's POST /classify.
Also in 0.14.0
ScoreResponseSchema now describes what the endpoint actually emits, verified
against a captured response body rather than a DTO: undeclared fields are no
longer rejected, model is a string with no format constraint, and risk_level
accepts null.
Upgrading to 0.13.0 — riskScore.categories
riskScore.categories is a list of category names, not a map of scores.
categories.pii was always undefined.
// before — compiled, returned undefined
const score = riskScore.categories.pii;
// after — compiles, and works
const hasPii = riskScore.categories.includes('pii');Nothing changes at runtime. The value was always an array; only the type
lied. If your code read categories.pii it was already getting undefined — the
type system was telling you it was fine. If your code stopped compiling on this
upgrade, that is the fix reaching you.
Measured against privent-backend at origin/dev 191ccc1: POST /v1/risk/score
declares categories: string[], all five of its exits are array-shaped, and its
own end-to-end test asserts toContain('financial') on a real HTTP response
body — which would not pass on an object.
Why this is 0.13.0 and not 1.0.0
1.0.0 is a claim about the whole surface, and two findings on that surface are
still open: ScoreResponseSchema is published but never enforced against a real
response (SDK-Q), and the contract parity test cannot disagree with the schema
it validates, because both have the same author (SDK-R). A package whose
published contract is never checked against a real response is not one to call
stable. 1.0.0 becomes sayable when those two close.
