Skip to content

Releases: lua-ai-global/governance

v0.18.1 — Evasion-resistant injection normalization

Choose a tag to compare

@github-actions github-actions released this 26 Jun 10:30

Hardens detectInjection() against obfuscated prompt-injection attacks that
slipped past keyword patterns, and adds two agent-specific attack patterns.
The detector now folds three more evasion classes back to their plain form
before matching, so an attacker can no longer dodge a rule by spacing out
letters, breaking a word with markdown, or swapping in lookalike characters.

Purely additive — no API changes, no config changes. Existing thresholds and
custom patterns behave exactly as before; the new normalization only widens
what the same patterns can see.

On the Lua Injection Benchmark v1 (6,931 samples) this lifts the shipped
regex detector from 781 → 801 true positives (recall 37.26% → 38.22%,
F1 48.27% → 49.19%) for a single additional false positive — precision holds
at ~69% and the false-positive rate is unchanged at ~7.4%.

Added

  • Confusable (homoglyph) folding — Cyrillic/Greek lookalikes are mapped to
    their Latin form during normalization (systеm prоmpt with Cyrillic е/о
    system prompt). NFKC does not fold these, so they previously survived
    untouched.
  • Spaced-character collapsingcollapseSpacedChars() rejoins runs of
    4+ single characters split by spaces or . _ - (i g n o r eignore),
    replacing the previous single hardcoded pattern. Short runs (initials,
    acronyms like U S A) are left intact.
  • Markdown-emphasis strippingstripMarkdownEmphasis() removes *, _,
    ~, and backtick markers attackers insert mid-word (ig**no**reignore).
  • Two new built-in patterns (54 → 56): agent_worm_propagation (instructions
    that try to spread to other agents) and forced_tool_call (tool/function
    selection controls smuggled into free-text input).

Changed

  • The matcher now scans the normalized input plus each obfuscation variant
    (:leet, :despaced, :demarkdown) in one pass; a pattern already matched
    in a cleaner form is not re-counted. Obfuscation-variant hits keep the same
    +0.1 weight nudge that encoded-payload matches already received.

v0.18.0 — Per-org (multi-tenant) audit chains

Choose a tag to compare

@github-actions github-actions released this 26 Jun 07:46
0b90cce

The tamper-evident audit chain is now scoped per organization. Before
this release a single createGovernance({ integrityAudit }) instance kept
one global hash chain, so every org's events were interleaved into the same
chain and sequence space — you couldn't hand one tenant a clean, contiguous,
independently-verifiable export, and one tenant's volume affected another's
chain. Now each organizationId gets its own head, its own 1..N sequence,
and its own write lock; events with no org share a single org-less chain,
byte-for-byte compatible with chains written before this release.

Additive and backward-compatible — org-less usage is unchanged.

Added

  • EnforcementContext.organizationId, AgentRegistration.organizationId,
    ActionOutcome.organizationId — supply the tenant to scope its chain.
    enforce() / enforceStage() also fall back to metadata.organizationId.
  • integrityChain.stats(organizationId?) and integrityChain.export({ organizationId })
    now operate on a single org's chain.
  • GovernanceStorage.getChainHead(organizationId?) — resume the right org's
    chain on restart. The in-memory and Postgres adapters implement it.

Changed

  • canonicalize() binds organizationId into the per-event hash only when
    present
    , so an event cannot be relabelled into another org's chain without
    detection. Org-less events hash exactly as before (no migration needed).
  • Postgres adapter now persists organization_id on agents and audit events
    (the columns/indexes already existed but were never written), and the
    integrity_sequence unique index is now per-org
    ((COALESCE(organization_id,''), integrity_sequence)). The integrity
    migration drops the old global unique index and creates the composite one
    — idempotent, safe on existing rows.

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

Choose a tag to compare

@github-actions github-actions released this 07 May 21:40

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

Choose a tag to compare

@github-actions github-actions released this 30 Apr 17:38

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

Choose a tag to compare

@github-actions github-actions released this 30 Apr 14:28
988db46

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

Choose a tag to compare

@scotty595 scotty595 released this 30 Apr 00:22

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

Choose a tag to compare

@scotty595 scotty595 released this 30 Apr 00:21

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

Choose a tag to compare

@scotty595 scotty595 released this 30 Apr 00:24

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

Choose a tag to compare

@scotty595 scotty595 released this 30 Apr 00:24

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

Choose a tag to compare

@scotty595 scotty595 released this 16 Apr 17:45

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.