@redact-secret/adapter-ai-context
v0.1.6
Published
A framework-neutral AI-context boundary over the Redact Secret core: sanitize user input, tool results, constructed context, and streamed text before it reaches a model.
Readme
@redact-secret/adapter-ai-context
Remove secrets from what you send to a model. Sanitize user input, tool results, a whole prompt context, or streamed text before any of it reaches a model, a tool, a log line, or storage.
It works with any model vendor or agent framework, because you call it yourself at the point where your code builds the context. Built on the Redact Secret core, which does the detection.
Install
npm install @redact-secret/adapter-ai-context @redact-secret/coreNeeds Node.js 22 or 24. ESM only. The core is a required peer.
Quick start
import { createAiContextBoundary } from "@redact-secret/adapter-ai-context";
// Conservative documented default limits. Override any of them below.
const boundary = await createAiContextBoundary({
onFinding: (finding, { boundary }) => console.error("finding", boundary, finding.type, finding.action),
});
// Synthetic values only.
const userText = "deploy with API_KEY=ghp_SYNTHETICREVOKED00000000000000000000";
const toolResult = { content: [{ type: "text", text: "build ok" }], exitCode: 0 };
const context = boundary.buildContext([
{ role: "user", boundary: "user-input", text: userText },
{ role: "tool", boundary: "tool-result", value: toolResult },
]);
if (context.outcome !== "ok") {
// `reason` and `code` are fixed labels: safe to log. There is no value.
throw new Error(`context refused: ${context.outcome} ${context.reason ?? ""}`);
}
console.log(JSON.stringify(context.value));
// [{"role":"user","content":"deploy with API_KEY=<SECRET_1>"},{"role":"tool","content":{"content":[{"type":"text","text":"build ok"}],"exitCode":0}}]CI runs this block verbatim from a clean install outside the repository
(npm run smoke-test).
The one rule: only use ok.value
Every operation ends in exactly one of three frozen shapes:
{ outcome: "ok", value, findings }
{ outcome: "blocked", reason, code? }
{ outcome: "aborted" }ok.value is the only thing that may go to a model, a tool, a log, or
storage. A blocked or aborted outcome never carries a value, findings,
partial text, an excerpt, or a count derived from input. There is no partial
result and no fallback to the input.
What you can call
| Operation | Use it for |
| --- | --- |
| sanitizeText(text, { boundary, signal }) | one string |
| sanitizeValue(value, { boundary, signal }) | a JSON-shaped value: every string and every object key is scanned |
| sanitizeToolResult(result, { signal }) | a tool's result, before it joins context |
| buildContext(parts, { signal }) | ordered { role, text } / { role, value } parts; returns [{ role, content }] |
| openStream({ boundary, signal }) | chunks of one text: append, then finalize, or abort |
boundary is a label for telemetry: "user-input", "tool-result",
"tool-arguments", "resource", or "context" (the default). It never
changes an outcome. signal is an AbortSignal (or anything with an aborted
flag).
Things that surprise people
- Non-JSON values are refused, not converted. A
Date, aMap, a class instance, anError,undefined, binary or base64 content, and encoded text areblocked/unsupported_value. Convert values to JSON shapes yourself, so what is scanned is exactly what you send. - Limits are always on. An oversized input is
blocked/limit_exceeded, never truncated. See Limits. - A stream releases nothing until
finalize. Text released earlier could not be recalled after a laterblock. - A secret in an object key blocks the value. A key cannot be rewritten without changing the value's shape.
okwith no findings is not proof that no secret was present. Detection is not complete.- Model output is not covered. This is for what goes into context.
Options
await createAiContextBoundary({
onFinding, pii, policy, placeholderFormatter,
wholeInputLimits, incrementalLimits, traversalLimits,
});Limits
// Every set is optional and defaults to AI_CONTEXT_DEFAULT_LIMITS; a set you
// pass is used exactly as given, not merged field by field with the preset.
const boundary = await createAiContextBoundary({
wholeInputLimits: { maxInputBytes: 65536, maxFindings: 256 },
incrementalLimits: {
maxInputCodeUnits: 1048576,
maxBufferedCodeUnits: 65536,
maxTokenCodeUnits: 8192,
maxMultilineCodeUnits: 32768,
},
traversalLimits: { maxDepth: 16, maxNodes: 4096 },
});Those values are AI_CONTEXT_DEFAULT_LIMITS, exported so you can read, log
or extend them. They are conservative on purpose: the first failure a new
integration meets should be a fixed blocked / limit_exceeded outcome on an
oversized input, not an unbounded scan.
| Limit | Default | Bounds |
| --- | --- | --- |
| maxInputBytes | 65536 | one whole-input scan |
| maxFindings | 256 | findings collected for one scan |
| maxInputCodeUnits | 1048576 | one streamed session, total |
| maxBufferedCodeUnits | 65536 | what a session holds while a candidate is open |
| maxTokenCodeUnits | 8192 | one candidate token |
| maxMultilineCodeUnits | 32768 | one multi-line candidate |
| maxDepth | 16 | nested containers, the root counting as 1 |
| maxNodes | 4096 | values visited in one sanitizeValue |
There is no unbounded mode. The preset names the limits so you do not have to invent them, and nothing switches them off.
Two things to know:
- Omitted is defaulted; present is used as given. A key that is present but
undefined(howtraversalLimits: config.limitslooks whenconfigis missing) is still aTypeError, not silently the preset. A set that is given is used whole, never merged field by field with the preset. sanitizeValuealso scans a leaf inside its key-context view{"<key>":"<leaf>"}, so a leaf's usable budget ismaxInputBytesminus that key and its JSON punctuation. With the default 64 KiB that is noise; with a small override it is what binds first.
actionPolicy
The core's declarative action policy (an object, UTF-8 JSON text or bytes; see
Declarative actionPolicy),
instead of a callback policy. It is validated and snapshotted once when the
boundary is created and passed to the core intact on every whole-input scan
and every incremental session: sanitizeText, sanitizeValue,
sanitizeToolResult, buildContext (key-context views included) and
openStream. Giving it with a policy is a TypeError at construction. The
live factory (and createMcpBoundary) rejects a core older than 0.1.0-beta.14,
or a document the core refuses, with a fixed CoreOptionsError instead of
running on the default policy. The injected createAiContextBoundaryWith takes
the core you give it and cannot check its version.
It changes what the core decides, never what the boundary does with it: block
is blocked / policy with no value, warn and allow keep the text, and
redaction is the core's. See Action semantics.
Options it does not take
ruleset and scanLimits are rejected by name (a TypeError), never ignored. The
core has no ruleset for an incremental session, so one boundary cannot offer it on
sanitizeText and not on openStream; and the boundary's whole-input limits are
wholeInputLimits, beside incrementalLimits. placeholderFormatter, policy and
actionPolicy reach both the whole-input and the incremental path. The logging and tracing
adapters take ruleset, scanLimits and placeholderFormatter on whole-input scans
(Core scan options).
Operation limits
const boundary = await createAiContextBoundary({
operationLimits: { maxBytes: 4_194_304, maxScans: 2000, maxLeaves: 1000, maxNodes: 8000, maxKeys: 8000, maxFindings: 1000 },
});The three limit sets bound one scan, one stream and one value. One
operation, a sanitizeText, a sanitizeValue or a buildContext (every part
together), also has an aggregate budget, so many parts that each fit cannot
multiply the work. It counts every value visited, every object key, every string
leaf, every scanAndRedact call and its UTF-8 bytes (key-context views and key
scans included; a memoized repeat is not a call), and every finding summed over
occurrences. Every key is optional and defaults to DEFAULT_OPERATION_LIMITS
(see @redact-secret/adapter),
except that maxBytes is never below four times wholeInputLimits.maxInputBytes,
so one text the core accepts always fits.
A bound reached is a blocked / limit_exceeded outcome with no value and no
findings, never a partly approved context. The per-operation memo holds at most
1024 results. An open stream is bounded by incrementalLimits, and by this
budget's maxFindings alone. Like every bound here it is checked between scans:
a work counter, not a wall-clock timeout; use the AbortSignal for cancellation.
createAiContextBoundaryWith, the injected API, requires all three sets
explicitly; pass withDefaultLimits() to hand it the preset.
test/defaults.test.ts asserts against the real core that each preset bound
is enforced, that a streamed text agrees with sanitizeText at every chunk
partition under it, and that an override replaces rather than widens.
PII detection is opt-in
The core detects credentials out of the box. PII detection is a separate activation, process-wide and one-shot:
const boundary = await createAiContextBoundary({ pii: ["pii:global"] });With pii omitted, an activation the application already made is accepted.
With pii given, the factory checks the core afterwards and refuses when the
active selection is not the one you asked for. This factory still never
rejects: that refusal is an initialization failure like any other, so every
operation fails closed as blocked / core_error (with no code) instead of
quietly building context with PII off.
Activation is not masking. Under the core's default policy only
High-confidence PII is redacted; Medium and Low resolve to warn, which
leaves the text alone. An ok whose findings is non-empty but whose value
equals the input is exactly this case. Pass your own policy if you need
those masked.
Full rules: PII guide.
Is it ready? An explicit readiness check
createAiContextBoundary never rejects for a core load or initialization
failure, so a resolved promise does not mean the core is usable. Ask directly,
at startup or before a retry:
import { checkAiContextReady, createAiContextBoundary } from "@redact-secret/adapter-ai-context";
const pii = ["pii:global"]; // omit to accept the application's own activation
let readiness = await checkAiContextReady({ pii });
if (!readiness.ready) {
// `readiness.status` is a fixed code, safe to log or expose on a health endpoint.
console.error("redaction not ready:", readiness.status);
}
// The boundary fails closed regardless; the check only tells you why.
const boundary = await createAiContextBoundary({ pii });
// Retry path: call it again (for example from your readiness probe's next
// tick). A failed check holds no state, so there is nothing to reset. A
// boundary created while the core was unavailable stays failed closed; create
// a new one once the check reports `ready`.
readiness = await checkAiContextReady({ pii });The result is { ready, status, core, pii, probe, activation? }:
| status | Meaning |
| --- | --- |
| ready | core loaded and initialized, the explicit pii selection (if any) is active, and a fixed synthetic probe was redacted |
| invalid_options | pii was not an array of strings |
| core_unavailable | @redact-secret/core could not be loaded |
| initialization_failed | the core's initialize() failed |
| pii_activation_unsupported | pii was given and this core cannot report an activation |
| pii_activation_not_active | pii was given and the active selection does not reflect it |
| malformed_response | the core, or its probe result, was not shaped as documented |
| probe_failed | the core threw while scanning the probe |
| probe_not_redacted | the probe was scanned but not redacted |
core, pii and probe are each ok, failed or skipped. activation is
the core's own public PII activation identity when it reports one.
What the check guarantees: it takes no input beyond pii, so there is no way to
supply a probe, limit, policy, placeholderFormatter or onFinding, and it
calls none of them. It never rejects and never returns an exception, message,
path or scanned value. It does no network, file, environment or log work and
changes no policy. It runs the same initialize() step the factory does, so it
is as safe to repeat as a second factory call. A failed check cannot make any
operation pass plaintext; boundaries are separate objects and still fail closed.
What ready is not: readiness at that moment. It is not proof of detection
completeness, that a boundary sits on every path to a model, that anything is
delivered, or that a later request will succeed.
It is implemented against the released core's initialize(), piiActivation()
and scanAndRedact(). The other live factories (pino, otel-trace,
otel-logs, the Python package) have no equivalent yet; logging and tracing
factories deliberately keep their own non-breaking shapes, so this is not a
shared interface.
Findings and telemetry
onFinding(finding, { boundary }) is called once per finding, in scan order,
including for a scan that ends up blocked. It is observational: an exception
it throws is swallowed, never read, and never changes an outcome. Nothing else
is emitted.
A finding in ok.findings, or passed to onFinding, is a frozen copy holding
exactly id, type, detector, confidence, action, obfuscation,
start, and end, copied by allowlist, so a field the core adds later cannot
reach a host. Offsets are UTF-16 code units, relative to the string that was
scanned (for sanitizeValue, each leaf is its own string). They reveal where
a secret sat and how long it was, never what it was.
Where a finding came from: occurrences
start and end index into one scanned string, and a flattened ok.findings
holds findings from many scans, each with the core's per-scan id (finding-1,
finding-1, ...). To place a finding, ask for its occurrence, additive
provenance that never reinterprets start and end:
import { findingOccurrences } from "@redact-secret/adapter-ai-context";
const outcome = boundary.buildContext([
{ role: "user", text: `use ${token}` },
{ role: "tool", value: { a: token, list: ["x", token] } },
]);
if (outcome.outcome === "ok") {
const occurrences = findingOccurrences(outcome); // same length and order as outcome.findings
outcome.findings.forEach((finding, index) => {
const where = occurrences[index];
// where.rangeScope: "text" | "leaf" | "stream" (what start/end index into)
// where.partIndex, and where.leafOrdinal for a leaf
});
}| rangeScope | start/end index into | Other fields |
| --- | --- | --- |
| "text" | the whole string given to sanitizeText, or a text part of buildContext | partIndex |
| "leaf" | one string leaf of a structured value (sanitizeValue, a value part). A key-context finding is already mapped back to the leaf; the key is never in the range | partIndex, leafOrdinal |
| "stream" | the logical text of an open stream: absolute offsets over all chunks, not per chunk | partIndex (0) |
| "key" | one object key scanned on its own. onFinding only: a redacted or blocked key blocks the value, so none is in ok.findings | partIndex, keyOrdinal |
Every occurrence also carries rangeUnit: "utf16-code-units". Nothing in an
occurrence is sensitive: no key, no field path, no value, no identifier derived
from a secret, no score. The fields are FINDING_OCCURRENCE_FIELDS.
partIndexis the index inpartsforbuildContextand0for every other operation.leafOrdinalandkeyOrdinalare zero-based and count in document order within a part.leafOrdinalcounts every string leaf visited, with or without a finding; a value reached by two paths is a leaf at each; object keys are not leaves. They advance per visit, so a memoized repeat of a string (scanned once) still has its own ordinal.- Uniqueness.
finding.idis unique within one scan only. Within one operation, (partIndex,rangeScope,leafOrdinalorkeyOrdinal,finding.id) is unique; across operations nothing is. Do not use any of it as a stable identifier of a secret. onFinding(finding, context, occurrence)receives the same occurrence as a third argument. For every finding inok.findingsit is called with that finding and the occurrence at the same index, in the same order; key scans add events withrangeScope: "key".contextis still exactly{ boundary }.- Compatibility. It is additive: the outcome's JSON is still
{ outcome, value, findings }(the occurrences are kept beside the outcome, so a serialized outcome does not carry them),start/endare unchanged, andSAFE_FINDING_FIELDSis the same eight fields.findingOccurrencesreturnsundefinedfor an outcome that is notokor was not produced by this package. New fields would be added, never changed;adapter-mcpforwards them for the findings it carries.
Reference
Outcomes
What to check and the smallest safe fix for each row: troubleshooting.
| Cause | Outcome |
| --- | --- |
| Any finding whose resolved action is block | blocked / policy |
| An object key with a redact or block finding | blocked / policy |
| INPUT_LIMIT_EXCEEDED, FINDING_LIMIT_EXCEEDED, BUFFER_LIMIT_EXCEEDED, TOKEN_LIMIT_EXCEEDED, MULTILINE_LIMIT_EXCEEDED | blocked / limit_exceeded + code |
| traversalLimits.maxDepth or maxNodes exceeded | blocked / limit_exceeded, no code |
| Anything other than a string, finite number, boolean, null, array, or plain object (undefined, NaN, a Date, a Map, a class instance, an Error, an array hole, a throwing getter), or a cycle | blocked / unsupported_value |
| INVALID_STATE, or a second finalize | blocked / lifecycle |
| Any other core error (NOT_INITIALIZED, INITIALIZATION_FAILED, POLICY_FAILURE, PLACEHOLDER_FAILURE, UNPAIRED_SURROGATE, …), or a malformed core result | blocked / core_error + code when the core gave one |
| Signal aborted, or abort() before a successful finalize | aborted |
code is forwarded only when it is in the core's fixed error-code registry.
An error message is never read or forwarded, not even the core's fixed one,
and a thrown value that is not the core's own maps to core_error with no
code.
How each operation scans
| Operation | Core path |
| --- | --- |
| sanitizeText | one whole-input scanAndRedact |
| sanitizeValue | one whole-input scan per string leaf and per object key, plus one key-context scan for a leaf under an object key that its own scan does not redact; a text already scanned in the same call (up to 1,024 code units) reuses that result, and onFinding still fires per occurrence |
| sanitizeToolResult | sanitizeText for a string, sanitizeValue otherwise, labelled tool-result |
| buildContext | the above per part |
| openStream | one incremental session, staged |
createAiContextBoundary(options) loads the core, awaits initialize(), and
returns the boundary. createAiContextBoundaryWith(core, options) takes the
core injected ({ scanAndRedact, createIncrementalSanitizer }, i.e.
@redact-secret/core itself after initialize(), or a fake in tests).
Key-aware sanitizeValue
A string leaf is scanned with the object key it sits directly under
(redact-secret/redact-secret#842), so { "api_key": "<value>" } is redacted at
that leaf even when the value does not identify itself, the same way the pair
is redacted in text. The leaf is scanned alone first. If that redacts nothing,
it is scanned again, through the same scanAndRedact, in its key-context view
{"<key>":"<leaf>"}, and a finding there is reported with offsets into the
leaf.
The core's contextual detection decides whether the pair is a secret: this
package holds no key pattern or list of credential names. Only the immediate
key counts. Array elements, parent keys, and sibling keys give no context, and
numbers, booleans, and null are unchanged. A view over maxInputBytes
blocks the value as limit_exceeded. The false positives are the core's own:
a non-secret under a credential name ({ "password": "Welcome to the password
reset flow" }) is redacted too, while names like token_count or
secret_name and placeholders stay clean.
Migration. A leaf that used to pass in plaintext, because only its key
identified it, is now replaced by a placeholder and reported in ok.findings
and telemetry. Nothing that used to be redacted or blocked passes now.
Lifecycle rules
- Limits are mandatory.
wholeInputLimits,incrementalLimitsandtraversalLimitsare all in force at all times. The live factory fills in any set you leave out fromAI_CONTEXT_DEFAULT_LIMITS(since0.1.0-alpha.2);createAiContextBoundaryWithrequires all three. The core enforces the first two before the detection work they exist to prevent; this package enforces traversal limits. Exceeding any limit fails the whole operation. Nothing is truncated or marked and passed on. A malformed limit set is aTypeErrorat construction; an invalid core limit iscore_error/INVALID_LIMITSon first use. - Initialization. An operation before the core is initialized is
core_error/NOT_INITIALIZED, from the core's own error. IfcreateAiContextBoundarycannot load or initialize the core, it still resolves, and every operation fails closed with the mapped outcome (core_error/INITIALIZATION_FAILED). Call it again to retry. Nothing ever falls back to returning input. - Cancellation. An already-aborted signal ends the operation as
abortedbefore any scan or session is created. The signal is checked again after scanning, between context parts, and on everyappendandfinalize. A realAbortSignalalso aborts an open stream's core session the moment it fires. - Staging. A stream releases nothing before a successful
finalize, even when the core emits sanitized text fromappend. Ablockfinding, a limit failure, or a callback failure mid-stream aborts the core session at once, and later appends are discarded unscanned. - Early failure.
stream.acceptingistrueuntil the stream fails (ablockfinding, a limit, a lifecycle or core failure), is aborted, or is finalized. Read it after everyappend: once it isfalse, stop pulling from the producer and close it, since later chunks would be discarded unscanned. It is input-free and never says why; the reason arrives atfinalize. - Single use. The first
finalizereturns the outcome; every laterfinalizeisblocked/lifecycle. Anappendafterfinalizeis discarded unscanned, andabort()after a successfulfinalizedoes nothing. - All or nothing. If any part of
buildContextis notok, the whole context is that outcome; no partial context exists. - Callbacks. A throwing
policyorplaceholderFormatterbecomes the core'sPOLICY_FAILURE/PLACEHOLDER_FAILURE, so the operation fails closed ascore_error.
For text within both the whole-input and incremental limits, a staged stream's
outcome equals sanitizeText's for the same text at every chunk partition.
The conformance replay checks this directly.
Security boundaries
- Server-side is authoritative. A client-side boundary is preventive UX. Apply this boundary again on the server, even when the client already did.
- Detection is not complete. An
okoutcome with no findings is not proof that no secret was present. - Callbacks are trusted code.
policy,placeholderFormatterandonFindingreceive safe metadata only, but a closure can still capture raw input. This package limits what it hands over, not what your code does. - Plaintext exists in process memory. Discarding staged text and aborting the core session does not zeroize memory or erase your own copy of the input.
roleis not scanned. It is a host-chosen label and must be a string; never put untrusted text in it.- This package detects nothing and decides no policy. It hands text to the core and maps what comes back.
What it does not do
- No vendor or framework wiring. No OpenAI, Anthropic, LangChain, or
LangGraph client wrapping or monkey-patching, and no MCP transport handling
(for MCP tool calls, use
@redact-secret/adapter-mcp). Call the boundary yourself where your framework builds context or receives a tool result. - No model output. This covers what goes into context. Scanning a model's response is a different boundary.
- No progressive stream release. Stream output is released only at
finalize. - No decoding. Non-text content (images, audio, binary) and encoded values (base64, percent-encoding) are neither decoded nor scanned.
- No cross-value joins. A secret split across separate values, object keys, or context parts is not reassembled; each is scanned on its own. Only a split across chunks of one stream is handled.
- No non-JSON values.
Date,Map, class instances,Errors andtoJSON()objects are refused, not serialized. - No secret restoration, prompt-injection detection, or tool authorization.
How it is verified
It implements the core's
AI-context boundary contract
(redact-secret/redact-secret#610) and qualifies by replaying the core's own
conformance fixture, vendored byte-for-byte at a pinned core commit
(fixtures/core/pins.json),
through this package's public API.
Package size and initialization time: npm run footprint. Per-event traversal
and scan overhead: node scripts/measure-overhead.mjs --host ai-context-js.
See
docs/performance.md.
Contributing
Issues and pull requests are welcome:
CONTRIBUTING.md.
Changes are listed in this package's CHANGELOG.md.
License
MIT
