Skip to content

Releases: lua-ai-global/governance

v0.17.0 — Custom conditions reachable from `createGovernance()`

07 May 21:40

Choose a tag to compare

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

30 Apr 17:38

Choose a tag to compare

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:

  1. Configure the relevant policy rules with scanModalities.
  2. In your enforce wrapper, call scanMultiModal(blocks, { enabled })
    for the union of modalities across active rules.
  3. Populate ctx.textByModality from the scan result.
  4. 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

30 Apr 14:28
988db46

Choose a tag to compare

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:

  • LangChaintool.invoke wrap (in both governTool and governTools)
  • OpenAI Agentstool.invoke AND tool.execute wraps
  • Genkittool.call wrap
  • LlamaIndextool.call wrap

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 / handleToolCall pattern. Tool-result scanning here
    has to be integrated at the call site by the user — the SDK can't
    intercept transparently. Consider using gov.scanToolResult() in
    your handler manually.
  • Vercel AI — no native tool-wrapping path on this adapter today.
    Tracked as a follow-up; for now use scanOutput on 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 / hybridDetect

Conservative 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 exceeded timeoutMs.

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-allowlist and mcp-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

30 Apr 00:22

Choose a tag to compare

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

30 Apr 00:21

Choose a tag to compare

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: preprocessprocesstool_resultpostprocess.

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_guardtool_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, default true
  • toolResultScans: { [name]: "always" | "never" } — per-tool override
  • toolResultInjectionThreshold — local detection threshold, default 0.5
  • toolFieldExtraction — per-tool registry mapping arg names to context fields. Generic defaults cover path/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

30 Apr 00:24

Choose a tag to compare

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

30 Apr 00:24

Choose a tag to compare

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

16 Apr 17:45

Choose a tag to compare

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

16 Apr 16:58

Choose a tag to compare

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.

  • GovernanceStorage gained three optional methods: createAuditEventWithIntegrity(), getChainHead(), getAuditIntegrity(). Memory and Postgres adapters implement all three.
  • createGovernance() now persists integrity metadata in a single INSERT when the adapter is integrity-aware, and resumes the chain from getChainHead() on boot. Kill the process mid-stream, boot a fresh instance, and verifyAuditIntegrity() 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 onAuditError notice.
  • Postgres schema: integrity columns in base DDL, integrity_sequence widened to BIGINT, 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) — was createMCPTrustRegistry
  • createMCPCallRecorder (new path: governance-sdk/plugins/mcp-call-recorder) — was createChainAuditor

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)

16 Apr 00:13

Choose a tag to compare

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.
  • prepublishOnly hook runs sync-readme before tsc, guaranteeing every npm release ships an in-sync README.
  • npm run sync-readme at 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.