Releases: lua-ai-global/governance
v0.17.0 — Custom conditions reachable from `createGovernance()`
The condition registry (registerCondition / unregisterCondition /
getRegisteredCondition / getRegisteredConditions /
clearConditionRegistry) and PolicyEngineConfig.conditions were already
on PolicyEngine since 0.15, but GovernanceInstance (the thing
createGovernance() returns) didn't expose them — instance.policies is
a ReadonlyPolicyEngine view that intentionally hides mutators. So
callers who followed the documented createGovernance() flow had no path
to register a custom condition without dropping down to
createPolicyEngine() and re-wiring everything else themselves.
This release closes that gap. Additive only — no breaking changes.
Added — GovernanceConfig.conditions
const gov = createGovernance({
conditions: [{
name: "geo_fence",
description: "Block actions outside allowed regions",
evaluator: (ctx, params) => /* ... */ false,
}],
rules: [/* ... */],
});Forwarded into the underlying createPolicyEngine call.
Added — registry passthroughs on GovernanceInstance
Mirroring the existing addRule / removeRule pattern:
gov.registerCondition(entry, opts?)gov.unregisterCondition(name)gov.getRegisteredCondition(name)gov.getRegisteredConditions()gov.clearConditionRegistry(opts?)
All thin forwarders to the engine.
Changed — GovernanceConfig.defaultOutcome accepts the full PolicyOutcome union
Was "allow" | "block"; now matches PolicyEngineConfig.defaultOutcome
("allow" | "block" | "warn" | "require_approval" | "mask"). Existing
callers passing "allow" or "block" are unaffected.
Docs
README's "Quick Start" section gained a Custom Conditions subsection
demonstrating both construction-time (config.conditions) and runtime
(gov.registerCondition()) registration via createGovernance(). The
previous custom-condition example used the lower-level createPolicyEngine
which left users on the documented createGovernance path stuck.
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.