Releases: lua-ai-global/governance
v0.16.0 — Per-policy multi-modal scan dispatch
0.15 introduced governance-sdk/scan/multi-modal as a host-callable
orchestrator with a global "scan everything you opt into" shape. That
worked for the SDK plumbing but coupled rules that have nothing to do
with each other (a token-budget rule has no business knowing about
images). 0.16 moves modality config onto the policy rule itself.
Added — scanModalities on PolicyRule
const rule: PolicyRule = {
id: "image-aware-injection-guard",
name: "Block prompt injection in vision payloads",
condition: { type: "injection_guard", params: { threshold: 0.5 } },
outcome: "block",
reason: "Injection detected in image OCR text",
priority: 100,
enabled: true,
scanModalities: ["text", "image"], // ← new
};Rules opt into modalities individually. Different policies can have
different coverage — a prompt_injection rule scoped to text + image,
a sensitive_data_filter rule scoped to text + pdf, etc. The host
runs scanMultiModal() once for the union and stuffs the per-modality
text into ctx.textByModality. Each rule's evaluator pulls the slice
it needs.
Added — textByModality on EnforcementContext
ctx.textByModality = {
text: "user prompt",
image: "OCR'd image text",
pdf: "extracted PDF body",
};Host populates this before calling enforce(). Content-scanning
evaluators consult it via getScanText(ctx, rule); metadata-only rules
ignore it entirely.
Added — CONDITIONS_SUPPORTING_MODALITIES registry
Exported from governance-sdk/scan/multi-modal. Six condition types
semantically operate on text content and accept scanModalities:
| Condition | Operates on |
|---|---|
injection_guard |
regex injection detection over input text |
ml_injection_guard |
pre-computed ML score (host runs the classifier on the modality union) |
blocklist |
term match in input text |
input_pattern |
regex over input text |
output_pattern |
regex over output text |
sensitive_data_filter |
curated patterns over output text |
Everything else — cost_budget, concurrent_limit, time_window,
tool_blocked, agent_level, network_allowlist, scope_boundary,
require_signed_identity, length checks, combinators themselves —
operates on metadata and ignores scanModalities entirely. Cloud UIs
use conditionSupportsModalities(type) to decide whether to render a
modality selector for a given rule type.
Added — getScanText(ctx, rule) helper
import { getScanText } from "governance-sdk";Returns per-modality text slices when the rule opts in (an array of
strings: each modality's text plus a joined cross-modality version
matching extractStrings's shape). Returns null to signal "use the
legacy input-walk fallback" — the backward-compat seam for rules that
don't opt in.
Changed — ConditionEvaluator signature
type ConditionEvaluator = (
ctx: EnforcementContext,
params: Record<string, unknown>,
rule?: PolicyRule, // ← new third arg
) => boolean;Structurally backward compatible — existing (ctx, params) => boolean
implementations satisfy the wider signature unchanged. The engine
threads the rule through evaluate, evaluateStage, and
evaluateCondition so evaluators that care about
rule.scanModalities can read it.
Changed — combinators preserve parent's modality scope
any_of, all_of, and not synthesise a per-child rule view that
preserves the parent's scanModalities while rebinding condition
to the nested type. So an any_of over injection_guard + blocklist
with scanModalities: ["image"] correctly scopes both sub-checks to
image-extracted text.
Migration
Drop-in. Rules without scanModalities see exactly the same content
as before — getScanText returns null, evaluators fall back to
extractStrings(ctx.input) / ctx.outputText. The existing 1,399
tests pass unchanged. New behaviour is purely additive.
Hosts wishing to enable multi-modal coverage:
- Configure the relevant policy rules with
scanModalities. - In your enforce wrapper, call
scanMultiModal(blocks, { enabled })
for the union of modalities across active rules. - Populate
ctx.textByModalityfrom the scan result. - Call
enforce()as usual — the engine handles per-rule dispatch.
Tests
1,413 / 0 (was 1,399 / 0). Fourteen new tests cover the registry, the
helper, per-rule dispatch, multi-rule independence, ignored-on-
metadata-rules safety, and combinator propagation.
v0.15.0 — Tool-result scanning across the framework adapters
0.14 wired tool-result scanning into the Mastra processor and MCP adapter
only. 0.15 rolls the same protection out to the four other adapters that
already do tool wrapping at construction time:
- LangChain —
tool.invokewrap (in bothgovernToolandgovernTools) - OpenAI Agents —
tool.invokeANDtool.executewraps - Genkit —
tool.callwrap - LlamaIndex —
tool.callwrap
For each, the wrapped invoke/call/execute now runs the tool's return value
through scanToolResult() (the same shared signal-then-enforce helper
the Mastra processor uses) at stage tool_result before returning. On
block, a { blocked, reason, ruleId } redacted detail object replaces
the original output, so the LLM never ingests the poisoned content.
Added — scanToolResults config flag on each adapter
const { tools } = await governLangChainTools(gov, [searchTool], {
agentName: "my-agent",
scanToolResults: true, // default — opt-out via false
toolResultInjectionThreshold: 0.5,
});Default true (matches the Mastra processor default). Existing callers
who upgrade to 0.15 get tool-result scanning automatically; set
scanToolResults: false to skip — useful for test environments that
mock tool returns.
What didn't change
- Anthropic / Mistral / Ollama still use a caller-driven
handleToolUse/handleToolCallpattern. Tool-result scanning here
has to be integrated at the call site by the user — the SDK can't
intercept transparently. Consider usinggov.scanToolResult()in
your handler manually. - Vercel AI — no native tool-wrapping path on this adapter today.
Tracked as a follow-up; for now usescanOutputon model output. - Bedrock — entry-gate only; tool execution happens inside AWS,
no post-execute hook is exposed by Bedrock Agents. - Mastra middleware adapter (
mastra.ts, not the processor) — uses
a different wrap shape; coverage to follow.
Migration
Drop-in. No public type breakage. The new config fields are optional
and additive. Existing tests that mock tool returns may need
scanToolResults: false if they don't expect the helper's path engine
to run on their fixtures.
Added — governance-sdk/scan/multi-modal (opt-in)
Closes the bypass where image, PDF, and audio content blocks pass through
enforce() unscanned. Ships orchestration only — actual OCR / PDF parsing
/ ASR are caller-supplied via a registry pattern, preserving the zero-
runtime-dep promise. Mirrors the InjectionClassifier shape: pluggable
async scanner + global registry + pre-enforce() invocation.
import {
registerModalityScanner,
scanMultiModal,
isFailClosed,
} from 'governance-sdk/scan/multi-modal';
registerModalityScanner('image', {
extractText: async (block) => await ocrEngine.recognize(block),
});
const scan = await scanMultiModal(blocks, {
enabled: ['text', 'image'],
onMissingScanner: 'block',
onExtractError: 'block',
timeoutMs: 5_000,
});
if (scan.failClosed) { /* block before enforce() */ }
// otherwise: feed scan.text into the existing detectInjection / hybridDetectConservative defaults — every modality except text is OFF until the
caller opts in. onMissingScanner / onExtractError default to 'skip';
timeoutMs defaults to 30s per block.
result.failClosed is pre-evaluated against the policy passed in —
trust it directly. isFailClosed(result, override?) is available for
callers wanting to apply a different policy after the fact (defaults to
result.policy when no override is given).
Failure modes recorded in result.blocked[]:
no_scanner— enabled modality with no extractor registered.extract_error— scanner threw, rejected, or returned a non-string.extract_timeout— scanner exceededtimeoutMs.
Scanner returning null is the documented benign signal "this block has
no extractable text" (e.g. a purely visual image). Recorded in
result.modalitiesEmpty[], NOT blocked[], and never triggers fail-
closed regardless of policy.
Changed — README honesty pass
- 12 framework integrations (was undercounted as "10")
- 47 export paths (was "44")
- 1,340 tests (was "1,328")
- Plugin export list now lists all 16 paths — previously omitted
mcp-allowlistandmcp-call-recorder - Tamper-evident HMAC audit chain promoted from a body-text mention to a
hero-section callout (it's a real competitive differentiator) - Sandboxing reframed: leads with "Process isolation is the security
model" instead of "No sandbox," same disclaimer scoped as a deliberate
choice rather than a gap - "What this is NOT" → "Limitations & Honest Scope"
v0.14.1 — Field extraction on the process stage
scope_boundary and network_allowlist rules at stage process (the default for those conditions, where pre-execution blocking happens) silently never fired on tool calls in 0.14.0 — evaluateToolCall (the path behind processOutputStep) didn't populate ctx.targetPath / ctx.targetUrl, and those conditions read those fields exclusively.
0.14.0 wired the field-extraction registry into wrapTool (tool_result stage). 0.14.1 wires it into evaluateToolCall too — same registry, same generic name conventions (path / filePath / url / href / ...).
With this fix:
- id: block-etc
condition: { type: scope_boundary, params: { blockedPaths: ["/etc/**"] } }
outcome: block
stage: process…now actually blocks device__lua_desktop__read_file({ path: "/etc/passwd" }) before the read happens, instead of falling through silently.
Tests
1,372 tests, 0 failures (+2 — scope_boundary fires on args.path, network_allowlist fires on args.url, both at stage process).
Upgrade
Drop-in. No behaviour change for anyone except orgs with scope_boundary or network_allowlist rules at stage process — those rules now fire as designed instead of silently passing.
v0.14.0 — tool_result stage + wrapTool helper
Closes the framework gap where tool-call return content (file contents, clipboard text, scraped pages, MCP returns) reached the LLM unscanned on every Mastra agent. Mastra's processor lifecycle has no hook between a tool's execute() returning and the next LLM call — scanning has to happen inside the tool's execute. The new wrapTool / wrapTools methods on GovernanceProcessor close that gap at construction time.
Added — "tool_result" PolicyStage
Four stages now: preprocess → process → tool_result → postprocess.
export type PolicyStage = "preprocess" | "process" | "tool_result" | "postprocess";Different threat model than postprocess:
- postprocess — agent's final output to the user. Threat: agent leaks credentials/PII.
- tool_result — content a tool returned, before the LLM ingests it on the next turn. Threat: external content carries prompt injection that poisons the LLM context.
Existing rules continue to fire at their original stage. Only condition defaults shifted (ml_injection_guard → tool_result); explicit stage: on a rule always wins.
Added — governance.enforceToolResult(ctx)
Symmetric with enforcePreprocess / enforcePostprocess. Evaluates only rules at the tool_result stage.
Added — scanToolResult() helper (signal-then-enforce)
import { scanToolResult } from "governance-sdk";
const { result, blocked, decision } = await scanToolResult({
governance: gov,
agentId, tool, args, result: toolReturnValue,
fields: { targetPath: "/path/from/args" },
});The helper extracts scannable text from any return shape, runs detectInjection() to populate ctx.mlInjectionScore, calls the engine at stage: "tool_result", substitutes a redacted BlockedToolResult on block.
Pattern: detectInjection is never a decision-maker. It's a signal generator. The policy engine — evaluating every applicable rule with all its composites and priority — is always the sole decision-maker, in both local mode (engine in-process) and cloud mode (engine via enforce() HTTP).
Added — GovernanceProcessor.wrapTool / wrapTools
The Mastra adapter for the helper above. Wrap individual tools or a tools dict before handing to a Mastra Agent:
const agent = new Agent({
tools: processor.wrapTools({ read_file, write_file, take_screenshot }),
...
});Wrapped tools' execute() runs the original, scans the result, returns either the original (allow) or a redacted { blocked, reason, ruleId } (block / require_approval). The LLM sees the redacted detail and adapts naturally on its next turn.
Config flags on GovernanceProcessorConfig:
scanToolResults— master switch, defaulttruetoolResultScans: { [name]: "always" | "never" }— per-tool overridetoolResultInjectionThreshold— local detection threshold, default 0.5toolFieldExtraction— per-tool registry mapping arg names to context fields. Generic defaults coverpath/filePath/url/href/uri/endpoint.
Added — toolFieldExtraction registry
Without field extraction, rules like scope_boundary: { allowedPaths: ["/project/**"] } silently never fire — the engine reads ctx.targetPath, not raw args.path. The new registry copies fields off the tool's input args onto the right EnforcementContext fields before enforce() runs.
Changed — MCP adapter delegates to the policy engine
The MCP plugin's tool-output scan previously ran detectInjection() inline and threw on detection — bypassing the policy engine. As of 0.14 it calls scanToolResult(), giving rule authors composite power (sensitive_data_filter, output_pattern, scope_boundary, require_approval outcomes, kill switch) on tool-output content.
Behaviour change: the block reason now comes from the matched rule rather than a hard-coded "Injection detected (score: X)". Existing behaviour is preserved for orgs whose rules look like the old default.
Changed — default stage for ml_injection_guard
Previously unmapped (fell through to process). Now defaults to tool_result. Rules with an explicit stage: are unaffected.
Tests
1,370 tests, 0 failures (+30 new tests covering scanToolResult, wrapTool / wrapTools, field extraction, MCP cleanup behaviour).
v0.13.2 — Remote register fetches real score/level
Previously remoteRegister() returned a synthetic { level: 0 } as a placeholder — the comment said "authoritative values arrive after first enforce()", but callers that cached the level (e.g. the Mastra processor's agentLevel field) then sent that 0 on every subsequent enforce. Rules gating on agent_level >= N fired incorrectly for higher-level agents until the process restarted.
Fix
POST to /api/v1/agents on register() and use the API's real compositeScore / governanceLevel in the returned receipt. The API already deduplicates by id/name, so calling this against a pre-existing agent returns the authoritative record — no need for a separate lookup.
Falls back to the old synthetic receipt when the API is unreachable or returns non-200, so register() still never throws for the caller. The next enforce() carries authoritative data either way.
v0.13.1 — Streaming streamId metadata
Streaming adapters generate one streamId per stream and inject it, plus a slice index, into metadata on every per-chunk enforce call. Lets cloud dashboards collapse N per-chunk audit rows into one logical operation for reviewers.
Additive — pure metadata, no public API change.
Applies to:
pre-post-stream.ts(used by Anthropic, LangChain, Vercel AI, etc.)mastra-processor-stream.ts(Mastra's per-chunk API)
v0.13.0 — Conventions flip + deprecation notices
Conventions flip + deprecation notices
Follow-up to 0.12. Two small, deliberate changes that the 0.12 roadmap promised — committed now so users have runtime notice before 1.0.
OTel `conventions` default flips from `"both"` to `"gen_ai"`
`createOtelHooks()` now defaults to emitting only the GenAI semantic conventions. Governance spans correlate out of the box with Anthropic, OpenAI, and Vercel-AI SDK spans in Honeycomb / Datadog / New Relic.
Migration. If your dashboards query the legacy `governance.*` operation names (`governance.enforcement`, `governance.audit`, etc.), set `conventions: "both"` explicitly:
```ts
createOtelHooks({ conventions: "both" });
```
This keeps the old op names alongside the new `gen_ai.*` attributes — same as the 0.12 default. `conventions: "governance"` disables GenAI emission entirely for customers who cannot adopt the spec yet.
`createMCPTrustRegistry` and `createChainAuditor` now warn
Both names misrepresented what the functions do. The honest names shipped as path re-exports in 0.12; 0.13 adds a one-shot `console.warn` when the old names are called so you see the nudge at runtime, once per process.
- `createMCPTrustRegistry` → rename to `createMCPAllowlist` (path: `governance-sdk/plugins/mcp-allowlist`)
- `createChainAuditor` → rename to `createMCPCallRecorder` (path: `governance-sdk/plugins/mcp-call-recorder`)
Removal scheduled for 1.0. Behaviour identical across both names — internals refactored into a shared `buildAllowlist` / `buildCallRecorder` so the honest names call the core directly and don't retrigger the deprecation path.
Tests
1,340 tests, 0 failures (up from 1,337).
What's next
- 0.14 — Multi-modal input scanning + signed compliance evidence export.
v0.12.0 — Trust hardening
Trust hardening
Closes the three most load-bearing honesty gaps surfaced by the post-0.11 audit. Theme: the things the SDK already claims must actually hold up under restart, real observability, and real naming.
Durable integrity audit chain
Before 0.12, integrityAudit: { signingKey } held chain state (latest hash, sequence, per-event integrity) in a createGovernance() closure. Process restart reset the chain to genesis and every Postgres event lost its integrity metadata because the write path never touched the integrity_* columns the schema defined.
GovernanceStoragegained three optional methods:createAuditEventWithIntegrity(),getChainHead(),getAuditIntegrity(). Memory and Postgres adapters implement all three.createGovernance()now persists integrity metadata in a singleINSERTwhen the adapter is integrity-aware, and resumes the chain fromgetChainHead()on boot. Kill the process mid-stream, boot a fresh instance, andverifyAuditIntegrity()passes across the restart boundary.- Third-party adapters written against the 0.11 interface still work. They fall back to the old in-process integrity map and emit an
onAuditErrornotice. - Postgres schema: integrity columns in base DDL,
integrity_sequencewidened toBIGINT, unique partial index prevents duplicate sequences under concurrent writers.
OTel GenAI semantic conventions
createOtelHooks() gained a conventions: "governance" | "gen_ai" | "both" option. "both" (the 0.12 default) is additive: governance.* still emits, and gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens/output_tokens, gen_ai.response.finish_reasons, gen_ai.tool.name, gen_ai.tool.call.id appear alongside. "gen_ai" switches operation names to the GenAI form so governance spans correlate with Anthropic / OpenAI / Vercel-AI SDK spans in Honeycomb / Datadog / New Relic. Default flips to "gen_ai" in 0.13.
Honest naming for MCP plugins
createMCPAllowlist(new path:governance-sdk/plugins/mcp-allowlist) — wascreateMCPTrustRegistrycreateMCPCallRecorder(new path:governance-sdk/plugins/mcp-call-recorder) — wascreateChainAuditor
The original exports stay and behave identically. Rename on your next touch of the file; no rush.
Fixed — remote status staleness after 4xx errors
createRemoteEnforcer().status() flipped connected: false on any RemoteEnforcementError, including non-retryable 4xxs. A 4xx means the API answered — the connection is live. Status now only reports connected: false on network/timeout failures.
Tests
1,337 tests, 0 failures. CI green.
What's next
- 0.13 — Ship
governance-sdk-ml(real ML classifier, published benchmark report). - 0.14 — Multi-modal input scanning + signed compliance evidence export.
v0.11.2 — Automate README sync (no code changes)
Adds infrastructure to keep packages/governance/README.md (the file npm publishes) in sync with the repo-root README — so the v0.11.1 fix can never silently regress.
What's new
scripts/sync-readme.mjs— generates the package README from the root, normalizing repo-relative links (./packages/...,./LICENSE,./CONTRIBUTING.md, etc.) to absolute GitHub URLs so they resolve correctly on npmjs.com. Idempotent.prepublishOnlyhook runs sync-readme before tsc, guaranteeing every npm release ships an in-sync README.npm run sync-readmeat the monorepo root for manual runs during dev.- CI guard added to
.github/workflows/ci.yml— fails the build if anyone commits a manual edit to the package README without running the sync. Catches drift on PRs.
What's NOT new
No code changes. SDK behavior identical to 0.11.1. This is purely build/CI infra.
If you're already on 0.11.1, this upgrade is unnecessary unless you want to track infra fixes.
v0.11.1 — Sync npm README with repo (no code changes)
The packages/governance/README.md (the file npm publishes) had drifted ~3 release cycles behind the repo-root README. This patch syncs the two so npm users see the same content GitHub viewers see — including the "What this is NOT" scope disclosures, the 0.11 module removals, and the behavioral-scorer demotion.
Relative links normalized to absolute GitHub URLs so they resolve correctly when read on npmjs.com.
No code changes. SDK behavior identical to 0.11.0.
If you're already on 0.11.0, this upgrade is purely cosmetic — bump for accurate npm docs.