taint-tracked-tool-broker
v1.4.0
Published
Provenance labeling for agent inputs, enforced at the tool-call boundary. Blocks untrusted data from reaching shell, filesystem, and network sinks. Ships with an injection corpus and a published list of known gaps.
Maintainers
Readme
Taint-Tracked Tool Broker
Provenance labeling for agent inputs, enforced at the tool-call boundary. Blocks untrusted data from reaching shell, filesystem, and network sinks — including when it arrives paraphrased, translated, re-encoded, or laundered through a boolean decision instead of copied verbatim. Ships with an injection corpus and a published list of known gaps.
Contents
- The problem
- Install
- Quick start
- Core model
- Examples
- Doctor CLI
- Observability
- Injection corpus
- Known gaps
- Versioning
- Language-neutral specification
- Development
- License
The problem
An LLM agent with tool-calling access is dangerous the moment it reads content it did not originate — a fetched page, an email, a file, another tool's output — because at the token level there is no boundary between data and instructions once both are in context. This is indirect prompt injection. Prompting the model to "be careful" doesn't fix it; enforcement has to sit at the boundary where the model's decisions become real side effects: the tool call.
Most naive defenses try to track content — does this argument contain text from that untrusted source? That approach is trivially defeated by "please summarize this page, then act on the summary": the paraphrase shares no literal substring with the source, so content-matching finds nothing.
This library gates on exposure instead of content. The moment untrusted content is read into an agent's context, a scope watermark is raised — atomically, before the model ever sees the result, so it can't be avoided by rewriting. Every privileged call in that scope is then checked against the watermark, not against what its own arguments say. See DESIGN.md for the full architecture and rationale, and GAPS.md for what this does not catch.
Install
npm install taint-tracked-tool-brokerOr, to work on the library itself (or install straight from a specific commit):
git clone https://github.com/NovaVey/Taint-Tracked-Tool-Broker.git
cd Taint-Tracked-Tool-Broker
npm install && npm run builddist/ is then importable directly (import { createBroker } from './Taint-Tracked-Tool-Broker/dist/index.js'), or link it into another project with npm link.
ESM only — this package ships as native ESM ("type": "module", no CommonJS build). import it from an ESM project as normal. A CommonJS project on a Node version without require(esm) support can't require() it directly (ERR_REQUIRE_ESM) — use a dynamic await import('taint-tracked-tool-broker') instead. This is a deliberate, permanent design choice, not a gap to be filled later: ESM-only keeps the codebase simpler and matches the target audience of modern Node agent frameworks, which are themselves ESM-first.
Requires Node.js >= 20 (see engines in package.json).
Quick start
import { createBroker, ToolCallBlockedError } from 'taint-tracked-tool-broker';
const broker = createBroker();
const fetchUrl = broker.wrap({
name: 'fetch_url',
capabilities: { capabilities: [] }, // not a sink itself
isSource: true, // its result can carry untrusted content
async execute({ url }) {
return realFetch(url);
},
});
const shellExec = broker.wrap({
name: 'shell_exec',
capabilities: { capabilities: ['exec:shell'] }, // an EXEC-class sink
async execute({ cmd }) {
return realShell(cmd);
},
});
// Wire fetchUrl / shellExec into your agent loop's tool list exactly as you
// would the unwrapped versions — .execute() now routes through the broker.
await fetchUrl.execute({ url: 'https://example.com' });
// watermark is now RAW_UNTRUSTED for this scope
await shellExec.execute({ cmd: 'anything the model writes, paraphrased or not' });
// throws ToolCallBlockedError — EXEC sinks are blocked unconditionally
// once untrusted content is live in scope, regardless of what the
// command text saysNote the createBroker() above passes no auditSink. That's a supported, fully-working configuration — every gate above is still enforced correctly — but it also means this exact snippet produces zero audit trail: the default auditSink is a silent no-op. Pass a real one (createBroker({ auditSink: { record(e) { ... } } })) for anything beyond a quick local check — see Core model below and GAPS.md #25.
A more realistic session. shell_exec above is an EXEC sink, unconditionally BLOCKed once anything untrusted is live in scope — the clearest case to demonstrate first, but also the rare one: most real tools are MUTATE/EXFIL (write_file, send_email, an API call), which land on REQUIRE_APPROVAL instead of a flat BLOCK, and actually consulting that verdict means configuring approvalChannel, catching the resulting ToolCallBlockedError, and reading auditSink. This second example shows that full, common path:
import {
createBroker,
createDeferredApprovalChannel,
formatAuditTrail,
ToolCallBlockedError,
type AuditEvent,
} from 'taint-tracked-tool-broker';
const events: AuditEvent[] = [];
// A real integration notifies a human here (Slack, an approval-queue UI, a
// webhook) and calls approvalChannel.resolve(token, granted) from whatever
// handler receives their decision — createDeferredApprovalChannel() just
// gives you the token-keyed pending-request bookkeeping for that. This
// simulates an approval arriving shortly after the request is made.
const approvalChannel = createDeferredApprovalChannel({
onPending: (token) => {
setTimeout(() => approvalChannel.resolve(token, true), 50);
},
});
const broker = createBroker({
approvalChannel,
auditSink: { record(e) { events.push(e); } },
});
const fetchUrl = broker.wrap({
name: 'fetch_url',
capabilities: { capabilities: [] },
isSource: true,
async execute({ url }) {
return realFetch(url);
},
});
const writeFile = broker.wrap({
name: 'write_file',
capabilities: { capabilities: ['write:fs'] }, // a MUTATE-class sink
async execute({ path, contents }) {
return realWriteFile(path, contents);
},
});
await fetchUrl.execute({ url: 'https://example.com' });
// watermark is now RAW_UNTRUSTED for this scope, same as the EXEC example.
try {
await writeFile.execute({ path: '/tmp/notes.txt', contents: 'from the model' });
// REQUIRE_APPROVAL, granted by the simulated approval above -> proceeds.
} catch (err) {
if (err instanceof ToolCallBlockedError) {
// err.taint is the exact TaintContext this decision was computed from —
// which upstream content actually triggered it (err.taint.matchedRecords),
// and the scope level at decision time (err.taint.scopeLevel) — without
// separately wiring auditSink and correlating it back by call id.
console.log(`blocked: ${err.message} (scope was ${err.taint.scopeLevel})`);
} else {
throw err;
}
}
console.log(formatAuditTrail(events));
// one readable line per AuditEvent: timestamp, tool, args, verdict, scope
// level, executed?, and the policy's reason — see Core model below.Core model
- A single trust lattice —
CLEAN < DERIVED_UNTRUSTED < RAW_UNTRUSTED— used for both the scope watermark and individual fingerprint records. ToolExecutor.sourceClassis a second, orthogonal axis alongside that lattice: a free-form, integrator-defined origin-type label ('internal-mcp','public-web','user-pasted', ...) copied ontoProvenanceTag.sourceClassevery time a source raises the watermark, and surfaced asTaintContext.sourceClasses— the distinct classes contributing to the current scope, deduplicated.TaintLevelorders trust degree; this expresses why something is untrusted, which the lattice deliberately collapses.defaultPolicynever reads it — it's plumbing for your ownPolicyFn, not a built-in opinion on which classes are lower-risk. See GAPS.md #28 andnpm run example:source-class-policy.- The scope watermark is the safety boundary. It rises the instant a source tool (
isSource: true) returns, before the model reads the result. It is monotonic; onlybroker.declassify()lowers it, and only as an explicit, audited action. - One broker instance = one session. The watermark, the fingerprint registry, and the call-ordering lock are all per-instance in-memory state —
createBroker()once per agent session and reuse that instance for its entire lifetime (across turns too); never share one instance across two concurrent, unrelated sessions, and never treatBrokerOptions.sessionIdas a lookup key that isolates them for you — it's just a label copied into audit records. See GAPS.md #19. - Sinks are classified
EXEC/MUTATE/EXFIL/NONEby declared capability (exec:shell,write:fs,net:email, ...).EXECis hard-gated by watermark level alone — it needs no private data to be catastrophic.EXFIL/MUTATEcalls while untrusted content is live always require at least approval; areadsPrivateDatatool having been called this scope escalates that to a hard block (the "lethal trifecta"), it never gates on its own. write:agent-memorynames a write to the agent's own durable, cross-session memory (a vector store, a memory file, a re-injected scratchpad) distinctly from an ordinarywrite:fs/irreversible:other— sameMUTATEgating, but documenting why this write matters: it's the mechanism by which content read this session becomes context in a future one. Classifying the write correctly does nothing for the read side, though — a tool that later reads that memory back still needs its ownisSource: true, or content re-enters with zero taint regardless of how the write was declared. See GAPS.md #12 and theagent-memory-cross-session-launderingcorpus case (a true, asserted known gap).SinkCapabilities.irreversibleis a second, orthogonal axis alongside sink class: class ranks kind of side effect (EXEC>EXFIL>MUTATE), not how bad undoing it would be — an irreversiblefinance:purchaseand a fully reversiblewrite:fsscratch write land on the identical policy-matrix cell today. Declare it per sink and read it asTaintContext.sinkIrreversiblefrom your ownPolicyFn;defaultPolicynever reads it or changes class ranking because of it. See GAPS.md #32 andnpm run example:irreversible-sink-policy.- The fingerprint registry is secondary. Exact hash + simhash + word-shingle overlap gives precise "this argument literally contains text from source X" attribution when a literal or near-literal chain survives — but it can only ever tighten a policy verdict, never loosen one, and it is never the sole basis for allowing a call. Fuzzy lookup is indexed (LSH-banded simhash + a shingle inverted index), not a linear scan, and an optional
maxEntriesbounds memory for long-running sessions. ToolExecutor.extractTextcloses a real Layer 2 blind spot: the default text coercion for a source's result only knows how to pass a string through orJSON.stringify()anything else, so an image/audio/binary-returning source (a screenshot, a scanned page) registers an unmatchable blob. DeclareextractText(result)(OCR output, alt-text, a transcript) and the registry gets something an actual future match can find instead — Layer 0's watermark raise is completely unaffected either way. See GAPS.md #33 andnpm run example:screenshot-extract-text.broker.summarize()is the sanctioned way to condense/paraphrase untrusted content before it re-enters the model's context without staying atRAW_UNTRUSTED: a capability-less LLM call you supply, whose output the broker itself (not the LLM) registers asDERIVED_UNTRUSTED— never all the way back to clean.requireQuarantineSchemais an optional, additive strict mode forbroker.summarize()(GAPS.md #4):opts.schemais normally optional and defaults to unconstrained free text, which quietly reintroduces much of the riskDERIVED_UNTRUSTEDexists to reduce — set thistrueand a schema-less call is rejected outright (QuarantineSchemaRequiredError, audited as aBLOCK) rather than silently falling back to free text. Off by default; unaffected calls that already pass a schema.checkFieldGrounding()closesbroker.summarize()'s own blind spot: nothing stops the Q-LLM you supply from hallucinating — or being manipulated into fabricating — a field value that never appeared anywhere in the source text it was asked to condense. This opt-in, standalone utility fuzzy-checks each extracted field against the original source(s) and reports which are traceable versus fabricated; your ownQuarantineImpldecides what to do with an ungrounded field (reject the extraction, ask for re-extraction, flag for review) — this library doesn't decide that for you, the same "declares/enforces" split as tool classification. See GAPS.md #27.broker.declarePlan(steps)is an optional, additive strict mode (DESIGN.md §11): commit to the exact sequence of privileged tool calls before any untrusted read, and any later privileged call that doesn't match the next committed step is rejected — on top of, never instead of, the normal policy check above.allowedOutboundHostsis an optional, additive egress firewall (DESIGN.md §7.4): everyEXFIL-class call's arguments are scanned forhttp(s)URLs and email addresses, and one whose destination host isn't allowlisted is blocked — a structural boundary independent of the taint-based policy, applied even to aCLEANscope, rather than another approval prompt a human could rubber-stamp. Deliberately narrow in scope; see GAPS.md #18. A tool can declaredestinationKeyson itself to scope the scan to just the argument key(s) that actually carry its destination, instead of the whole-tree scan, eliminating false positives from an unrelated field that merely happens to look like a URL.createToolDescriptorGuard(broker)closes one specific, named instance of GAPS.md #1 (untracked context-injection channels): a malicious or compromised MCP server can rewrite a tool's description or input schema between twotools/listcalls to smuggle new instructions into whatever later reads it, a channel that never routes throughbroker.call()at all. The returned function fingerprints each tool's full descriptor (name + description + schema) and callsbroker.markToolDescriptionExposure()—ALLOW_WITH_WARNINGplus a taint raise, never a hard deny — the moment a previously-seen tool's descriptor changes. Doesn't decide whether a description is malicious, only whether it changed; the broader gap (arbitrary system-prompt fragments, pasted content, any other untracked channel) still needs an explicitmarkContextExposure()call.createBroker({ enforcement: 'observe' })is a standard adoption-ramp mode (CSP report-only, a WAF's detection mode) for measuring what'enforce'(the default) would have gated, against real traffic, before turning enforcement on:policy()still runs and everyAuditEventis populated identically, but aBLOCK/REQUIRE_APPROVAL/QUARANTINE_AND_RETRYverdict no longer prevents the call — it executes anyway, audited truthfully (AuditEvent.enforcement: 'observe', andformatAuditTrail()marks the overridden ones[OBSERVE MODE: NOT ENFORCED]). Refuses to construct without a realauditSink(ObserveModeRequiresAuditSinkError) — a broker that never gates and has nowhere to record what it would have gated is strictly worse than the silent default no-op sink below. Plan-freeze andallowedOutboundHostsare hard structural boundaries independent ofpolicy()and remain fully enforced regardless of this setting. See GAPS.md #31.QUARANTINE_AND_RETRYis a decisiondefaultPolicycan hand back alongsideALLOW/ALLOW_WITH_WARNING/REQUIRE_APPROVAL/BLOCK: when an otherwise-BLOCK/REQUIRE_APPROVALverdict traces to a specifically identifiable untrusted source (a confident Layer 2 fingerprint match, not just a bare watermark taint), it replaces that verdict with a named suggestion to re-run the source throughbroker.summarize()and retry — never auto-executed, purely informational.- State can cross a process boundary.
createBroker({ initialWatermark, registry })plusserializeBrokerState()/restoreBrokerState()let one broker's watermark, registry, and any declared plan-freeze plan (resuming at the exact cursor it was at when exported) be exported (JSON-safe) and used to seed another — for a sub-agent, a worker, or a resumed session. Not automatic; an integrator still has to call these and pass the result along. createTaintEnvelope(value, taint)is the same idea at a narrower grain: not a whole broker's state, but one specific value's taint provenance (scope level, matched fingerprint records, a human-readable summary) — for handing that one value across a boundary where the live registry isn't reachable at all (a downstream service, a database row, a human-review UI). One-way, like the audit log below, not something this library restores a broker from.- Every gated decision reaches
BrokerOptions.auditSinkas anAuditEvent— but don't hand it toJSON.stringify()directly. When a call's arguments fuzzy- or exact-match a previously-registered record (the ordinary case for a real attack, not an edge case),event.taint.matchedRecords[].record.fingerprintcarries abigintand aUint32Array, whichJSON.stringifythrows on and silently mangles, respectively — so the single most obviousAuditSink,record(e) { console.log(JSON.stringify(e)) }, crashes on the first such event. UseserializeAuditEvent()(src/persistence.ts) first:JSON.stringify(serializeAuditEvent(event)). SeeAuditSink's own doc comment (src/types.ts) for the full explanation. event.call.argsis the tool call's real, unredacted arguments, exactly as sent — a credential, an API key, a chunk of a private document can reach yourauditSinkverbatim.createBroker({ redactAuditArgs })is an opt-in hook applied tocall.argsonly, on everyAuditEvent, before it reaches your sink — this library ships no default redaction logic (it can't know what counts as sensitive in your own tool arguments), just the seam. Seedocs/audit-redaction.mdfor worked patterns and GAPS.md #24 for the gap this closes.- The default
auditSink— what you get by configuring nothing, including by following Quick start above verbatim — is a silent no-op. Every gate is still enforced correctly either way, but a broker built that way produces zero audit trail.formatAuditTrail(events),explainWatermark(scope), andAggregatingAuditSink(src/debug.ts, also exported from the package root) turn a configured sink's rawAuditEvents into readable prose, a plain-language explanation of why the watermark is what it is, and asnapshot(): Record<string, number>of verdict/approval/latency counters, respectively — none of it new tracking, all of it rendering data this library already collects. See GAPS.md #25.
Read DESIGN.md for why each of these choices was made, including the soundness gap the design's own judge-panel process found and closed before this was implemented.
Examples
Runnable, offline (no API key, no real network calls — everything is mocked except the broker itself) walkthroughs in examples/:
| Script | What it shows |
|---|---|
| npm run example | The core model end to end: verbatim injection, paraphrase bypass, the sanctioned summarize() path. |
| npm run example:mcp | All three MCP protocol surfaces (tools/call, resources/read, tools/list) and createToolDescriptorGuard(), the exported rug-pull guard (GAPS.md #1). |
| npm run example:mcp-sdk | The same MCP pattern against a real @modelcontextprotocol/sdk client/server pair (InMemoryTransport.createLinkedPair(), real JSON-RPC) instead of a mock. |
| npm run example:tool-loop | A full Anthropic Messages API-style tool loop — a blocked call recovering gracefully, REQUIRE_APPROVAL suspending the loop, and startNewTurn()'s one correct call site under resetScope:'turn'. |
| npm run example:langchain | Wiring broker.wrap() behind LangChain.js's tool()/Runnable.invoke() shape. |
| npm run example:vercel-ai | The same pattern behind the Vercel AI SDK's tool()/execute() shape. |
| npm run example:openai-agents | The same pattern behind the OpenAI Agents SDK's tool()/execute() shape, exercising the REQUIRE_APPROVAL path via createDeferredApprovalChannel(). |
| npm run example:openai-tool-loop | A raw OpenAI Chat Completions API-style tool loop (tools/tool_choice, a manual while loop feeding results back as role:'tool' messages) — distinct from example:openai-agents above, which wires the newer, higher-level Agents SDK instead. |
| npm run example:mastra | Wiring broker.wrap() behind Mastra's createTool({ execute: ({ context }) => ... }) shape. |
| npm run example:genkit | Wiring broker.wrap() behind Google Genkit's ai.defineTool() handler shape. |
| npm run example:agent-sdk | Wiring broker.wrap() behind the (Claude) Agent SDK's in-process tool()/createSdkMcpServer() helper — a third, distinct Anthropic integration shape alongside example:tool-loop (the raw Messages API) and example:mcp-sdk (a real network MCP server). |
| npm run example:llamaindex-ts | Wiring broker.wrap() behind LlamaIndex.TS's FunctionTool.from()/tool() shape. |
| npm run example:semantic-kernel-js | Wiring broker.wrap() behind Semantic Kernel JS's KernelFunction.from()/plugin shape. |
| npm run example:taint-envelope | Packaging a blocked/quarantined call's TaintContext into a portable, JSON-safe TaintEnvelope (createTaintEnvelope()) and handing it off across a process boundary. |
| npm run example:grounding-check | Rejecting a broker.summarize() extraction whose Q-LLM fabricated a field absent from its source, using the standalone checkFieldGrounding() utility inside a QuarantineImpl wrapper. |
| npm run example:source-class-policy | A custom PolicyFn reading TaintContext.sourceClasses (GAPS.md #28) to downgrade REQUIRE_APPROVAL when every contributing source is a reviewed internal MCP server — and not when a public-web source is also in scope. |
| npm run example:observe-mode | enforcement: 'observe' (GAPS.md #31): the construction-time safeguard, a gated verdict executing anyway while still auditing truthfully, formatAuditTrail()'s [OBSERVE MODE: NOT ENFORCED] marker, and plan-freeze/allowedOutboundHosts staying fully enforced regardless. |
| npm run example:irreversible-sink-policy | A custom PolicyFn reading TaintContext.sinkIrreversible (GAPS.md #32) to upgrade ALLOW_WITH_WARNING to REQUIRE_APPROVAL for an irreversible finance:purchase sink — and not for a reversible write:fs sink at the identical scope. |
| npm run example:screenshot-extract-text | ToolExecutor.extractText (GAPS.md #33): a screenshot source's OCR text registers into Layer 2 and later triggers an exact-match attribution; no OCR text found skips registration cleanly (watermark still raises); no extractText declared falls back to an unmatchable stringified blob. |
The framework examples above don't depend on the real langchain/ai/@openai/agents/mastra/genkit/llamaindex/@microsoft/semantic-kernel packages — each uses a small structural stand-in for that framework's real tool-definition shape, since the integration point (a name/description/schema object with an async execute function) is what matters, not fidelity to a fast-moving package's exact current types. Every framework's real dispatch loop calls that function the same way once broker.wrap() has interposed it. example:mcp-sdk is the one exception: it depends on the real @modelcontextprotocol/sdk (a devDependency, not a runtime dependency of this library) to confirm the stand-in pattern used by example:mcp actually holds against the genuine SDK's current shapes.
AWS Bedrock Agents isn't in the table above — its Action Group execution model (a Lambda/REST callout from AWS's own managed orchestrator, not an in-process function call) doesn't fit this shape, so a copy-paste mock would misrepresent the actual integration rather than simplify it. See docs/aws-bedrock-agents-pattern.md for the correct pattern (wrapping inside the Lambda handler) and its one real limitation (watermark state does not survive a cold start without wiring in serializeBrokerState()/restoreBrokerState(), GAPS.md #12).
Doctor CLI
checkToolCatalog(tools) / checkBrokerConfig(config, tools) / runDoctor({ tools, brokerConfig }) (src/doctor.ts, exported from the package root; GAPS.md #30) are a CI-runnable preflight over a tool catalog and broker configuration — catching the same shapes an integrator would otherwise only discover the hard way, at runtime or in review:
- Two deterministic
register()/wrap()rejections — a dual-role tool (isSource: trueplus a non-emptycapabilitiesarray) and a reserved__tttb_-prefixed name — flagged before a live broker ever sees the catalog. - The same
warnOnLikelyUnclassifiedSinkkeyword checkdocs/classifying-tools.mdalready documents running as a manifest lint, packaged into one call. - Config-inertness: a missing
auditSink(silent no-op, GAPS.md #25), a missing/unconfiguredquarantineImpl(escalated to an error the moment a tool declaresmayCallSummarize: true),requireQuarantineSchemaleft off (GAPS.md #4), and anEXFIL-capable tool with noallowedOutboundHostsconfigured (GAPS.md #18).
npx tttb doctor ./dist/my-tools-config.js # a plain, already-built JS module exporting `tools`/`brokerConfig`Calling checkToolCatalog()/checkBrokerConfig()/runDoctor() directly from your own CI test suite works identically and needs no CLI, no separate config module, and no build step — usually the more natural fit for a TypeScript-first integration. See docs/classifying-tools.md's "A packaged doctor preflight" section and src/cli/doctor.ts's own header for the CLI's exact config-module contract, and — same honesty bar as everything else in docs/classifying-tools.md — what this still cannot catch (a deliberately-deceptive tool, or one whose real behavior doesn't show up in its name).
Observability
The default auditSink — what createBroker() gets when you configure nothing, including by following Quick start above verbatim — is a silent no-op (GAPS.md #25). Every gate is still enforced correctly either way; you just get zero record of it. Configuring a real one turns this library's audit trail into something you can actually query, render, and alert on:
formatAuditTrail(events)/explainWatermark(scope)(src/debug.ts, exported from the package root) — pure renderers overAuditEvent[]/TaintScope.watermark.sourcesyou already have. No storage, no aggregation — just readable prose for a terminal or log line. See Quick start above for a working snippet.AggregatingAuditSink(src/debug.ts) — a small, dependency-freeAuditSinkwrapping an optional delegate, accumulating verdict-by-sink-class counts,REQUIRE_APPROVALgrant/deny counts and latency, andQUARANTINE_AND_RETRYoffer counts into a plainsnapshot(): Record<string, number>.npm run example:audit-prometheusrenders that snapshot as real Prometheus text-exposition format (# HELP/# TYPE/labeled samples) — noprom-clientdependency, pure string formatting.- Durable storage —
serializeAuditEvent()(src/persistence.ts) makes anAuditEventJSON-safe (itsfingerprint.simhash/shingleHashesfields are abigint/Uint32Array, whichJSON.stringifyeither throws on or silently mangles).npm run example:audit-sqlitewrites events to a real SQL table vianode:sqlite, queries them back with a realGROUP BY, and renders the revived events withformatAuditTrail()— proof the round trip is lossless, not just "doesn't throw." - Redaction before any of the above sees it —
BrokerOptions.redactAuditArgsstrips or replacescall.argsbefore it reaches your sink at all. Seedocs/audit-redaction.mdfor worked patterns (bysinkClass, byprivateDataSeen, a key denylist).
Injection corpus
npm run corpusRuns corpus/cases.ts: 23 cases across 16 attack classes — direct verbatim injection, light reformatting, inline paraphrase (the "summarize, then act" bypass), boolean decision-laundering, the sanctioned quarantine path (including an attempt to spoof its input-provenance check with fabricated text), lethal-trifecta escalation, translation/encoding evasion, plan-freeze catching an unplanned privileged action (both a single-step mismatch and a multi-step case where the cursor legitimately advances first), resetScope: 'turn-decay' narrowing (not closing) the cross-turn gap, the outbound-host allowlist blocking an unapproved-host call on an otherwise-CLEAN scope (both a URL-shaped and an email-address-shaped destination), and QUARANTINE_AND_RETRY being offered only when a specifically identifiable source backs it up — correctly withheld both when attribution is too weak to name one, and when a qualifying match exists but is a decoy: a strong fingerprint hit against an unrelated, harmless source that has nothing to do with the argument actually making the call dangerous — plus three true, asserted known gaps (untracked context-injection channels, cross-turn latent influence under resetScope: 'turn', and content written to write:agent-memory re-entering context through an unmarked memory-read tool with no taint at all). The corpus is also run under npm test, so a change that silently narrows coverage — or silently starts overclaiming it — fails CI, not just a manual read of GAPS.md.
npm run corpus's output isn't just "N/M passed" — that alone doesn't prove anything was actually at stake, since a case can pass by matching a documented expectation for a payload that was never going to do anything even if allowed. Every run also computes a counterfactual baseline: each case's sink call(s), replayed against the same fixtures with no broker mediating them at all (corpus/schema.ts's runUnprotectedCase). The report's final line makes the real number visible — as of this corpus, 21 non-benign cases, all 21 of which would have executed their sink call unprotected; the broker actually prevents 17 of them, 1 is the sanctioned quarantine path's expected ALLOW_WITH_WARNING (not an attack payload by the time it reaches the sink), and 3 are the documented true known gaps where protection provides none. test/corpus.spec.ts locks the "would have executed unprotected" half in as a regression test, not just something visible by eyeballing the CLI output.
Known gaps
This library does not achieve information-flow-control soundness — it achieves a conservative, structural approximation with named, honest limits. It gates on "was untrusted content live in this scope's tracked context," which is a sound proxy for "did the model act on it," not proof of causal influence. See GAPS.md for the full list, including the three true known gaps the corpus asserts rather than papers over.
Every gate rests on how you declare your own tools (isSource, trusted, capabilities, readsPrivateData) — get one wrong and that tool isn't gated incorrectly, it isn't gated at all, silently. See docs/classifying-tools.md for a checklist and worked examples for the less-obvious cases.
Found a way past the gating logic that isn't already in that list? See SECURITY.md.
Versioning
This project follows SemVer. As of 1.0.0, the exported API surface (everything reachable from src/index.ts) is stable — no more silent renames or shape changes without a major version bump. Before 1.0.0, a minor release could still include a breaking change while the API stabilized (see CHANGELOG.md for that history); that caveat no longer applies going forward. Check the changelog for what actually changed between any two versions before upgrading regardless.
That covenant is about API shape only. Behavioral limitations — what the broker does and doesn't catch — are tracked in GAPS.md and DESIGN.md regardless of version number, and reaching 1.0 doesn't imply those gaps are closed.
Language-neutral specification
This is a TypeScript/Node-only library, but the underlying model — the taint lattice, the watermark semantics, the sink-class taxonomy, the default policy decision table, the sanctioned quarantine path, and the audit-event shape — is published separately as PROTOCOL.md, in pseudocode/tables/prose rather than TypeScript. It exists for anyone wanting to implement or evaluate this model in a different language or runtime (a Python port, for instance) without requiring this repository to become a second security-critical codebase in a second language: this project is small and solo-maintained (see SECURITY.md), and a full cross-language port of a security-critical library was deliberately rejected in favor of a shared specification an independent implementation can build against. This TypeScript implementation is the reference implementation of that specification — see PROTOCOL.md's own Conformance section.
conformance/vectors.json is that specification's executable counterpart — the same 23-case, 12-tool injection corpus corpus/cases.ts/corpus/fixtures.ts now LOAD from this plain JSON file (not a second, independently-maintained copy of it) as an ordered sequence of operations (register a tool, call it with arguments, start a new turn, declare a plan, summarize a piece of text) plus each case's expected verdict, final watermark level, and minimum Layer 2 attribution strength. Any language's own conformance runner can read it directly — no JavaScript/TypeScript toolchain, and no dependency on this repository's own test harness — turning "this implementation matches PROTOCOL.md" from a claim into something a second implementation can mechanically check itself against. npm run corpus/npm test running green is, definitionally, this reference implementation conforming to its own published vectors, not a separately-maintained claim that could silently drift from them.
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest (unit tests + the injection corpus)
npm run corpus # just the corpus, with a readable pass/fail table
npm run coverage # vitest --coverage, enforced against vitest.config.ts's thresholds in CI
npm run lint # eslint . — type-aware, enforced in CI
npm run format # prettier --write over src/test/corpus/examples/bench/conformance
npm run build # emit dist/
npm run bench # structuredClone vs jsonSafeClone args-cloning benchmark
npm run bench:minhash # why fixed-size MinHash sketches were investigated and NOT shipped for the registry (DESIGN.md)See CHANGELOG.md for what's changed, and CONTRIBUTING.md for what a good PR looks like (this project is small/solo-maintained per SECURITY.md, but PRs are welcome). Participation is governed by the Code of Conduct.
License
Apache-2.0 — see LICENSE.
