Skip to content

What is Norviq

Norviq is a runtime policy enforcement point (PEP) for LLM agent tool calls. It sits between an agent and the tools it can invoke, and evaluates every call — allow, block, escalate, or auditbefore the tool runs.

An LLM agent is handed real capabilities: databases, shells, email, cloud APIs, internal services. At runtime the model decides which tool to call and with what arguments. That is the point of an agent — and also its exposure. A single prompt injection, a poisoned document, or an ordinary reasoning error turns “help me with this ticket” into a DROP TABLE, a bulk export, or an email to an attacker. The tool call is the moment intent becomes action, and by default nothing stands between the model’s decision and the side effect.

Guardrails on the prompt or the output don’t close this: they inspect text, not the concrete {tool, params} about to execute. And observability tells you what an agent did — after it did it.

Norviq intercepts the tool call itself. Every call is checked against an OPA / Rego policy scoped to the calling workload’s identity — a workload identity (SPIFFE shape) bound to where the pod runs, not a claim in the request body — and resolved to a decision the caller must honor before the tool executes.

flowchart LR
    A["Agent<br/>(model picks a tool call)"] -->|"{tool, params, identity}"| N["Norviq PEP<br/>POST /api/v1/evaluate"]
    N -->|allow / audit| T["Tool<br/>(DB · shell · email · API)"]
    N -.->|block / escalate| X["Refused<br/>+ rule_id + reason"]

The decision is enforced inline. A block never reaches the tool. An escalate also stops the call — the PEP refuses it under a distinct outcome so it can be routed to a human; there is no built-in approval queue that later resumes the original call. An audit proceeds and is recorded with the control that would have stopped it, which is what makes monitor mode actionable rather than merely quiet (GET /api/v1/policy-compliance, and the Policy Compliance page in the console, roll those up per control).

Norviq governs two different surfaces, and they meet at the same /api/v1/evaluate contract — one engine, one policy set, one audit log.

flowchart LR
    subgraph pod["Agent pod"]
      direction TB
      agent["Agent code"]
      sdk["L1 · tool-call PEP<br/>SDK protect() in-process,<br/>or injected sidecar"]
      mcpx["L2 · MCP action firewall<br/>python -m norviq.mcp"]
      agent --> sdk
      agent --> mcpx
    end
    gatea["Gate A · discovery<br/>scan + pin tool definitions<br/>(per session)"]
    mcpx --> gatea
    sdk -->|"tool call"| ev["POST /api/v1/evaluate<br/>one engine · one policy set"]
    mcpx -->|"Gate B · every tools/call"| ev
    ev --> up["Upstream tool<br/>or MCP server"]

L1 — the tool call your own code makes. Either the norviq Python SDK wraps your tools in process (protect()), or the mutating admission webhook injects a Norviq sidecar into agent pods in a labeled namespace and the agent’s calls reach it over a local Unix domain socket. The sidecar runs in proxy mode by default (webhook.injection.sidecarMode: proxy) — it holds no policy and forwards each call to the central API; embedded runs its own OPA and policy loader in the pod for air-gapped or edge use. Framework adapters ship for LangChain, LangGraph, CrewAI, AutoGen and Semantic Kernel, and the SDK works directly against any agent that can call a function. See SDK integration.

L2 — the MCP action firewall. Model Context Protocol traffic is tool calls on a wire protocol, so python -m norviq.mcp mediates it as a proxy — stdio (the child process an MCP host spawns) or streamable-HTTP (fronting a remote server) — and maps each tools/call 1:1 onto the same evaluate contract. It has two gates with deliberately different costs:

  • Gate A — discovery. initialize, tools/list and notifications/*_changed responses are scanned for instructions hidden in tool definitions (tool poisoning) and content-hash pinned, so a server that changes a definition after approval (a rug pull) is detected. Runs a handful of times per session. Pins live in the control plane by default (pinStore: control-plane), so an approval is tenant-scoped, RBAC’d and audited rather than lost on pod restart. pinMode: tofu trusts a definition on first sight and enforces change; strict quarantines it until an operator approves.
  • Gate B — invocation. Every tools/call (and, in scope, resources/read and sampling/createMessage) is evaluated before it reaches the upstream server; a block is answered locally and the server never executes it. Runs per call.

Gate A is a heuristic and is evadable by construction. Gate B is the deterministic backstop — the product’s own adversarial harness includes vectors that are expected to slip past Gate A and asserts that Gate B catches the resulting call.

MCP injection is opt-in and off by default (webhook.injection.mcp.enabled: false). Turning it on requires webhook.injection.mcp.proxyImage — there is no fallback image — and a per-pod annotation naming the containers that are MCP servers (norviq.io/mcp-servers). Admission denies a pod whose annotation names a container that doesn’t exist, or a named container with no explicit command, rather than letting a named server run ungoverned.

This matters more than the feature list, because a security product that is stricter in its docs than on your cluster is worse than one with no docs.

Allow by default, with named rules. The shipped Rego presets are default decision = "allow" plus a set of named block / escalate / audit triggers; precedence is block > escalate > audit > allow, ties broken deterministically by rule_id. This is not a positive-security allowlist model — a tool nobody wrote a rule about proceeds. A namespace with no policy at all likewise allows, under rule_id=default_allow, because config.noPolicyDecision ships as allow. Set it to deny for namespaces you have deliberately locked down, so deny-by-default is an explicit decision rather than the silent consequence of having installed the chart and not configured anything yet.

The baseline arrives observing, not blocking. The chart renders a cluster baseline policy per namespace in policyQuotaNamespaces from the strict preset — 21 controls covering prompt injection, SQL, shell, secret and PII egress, SSRF, cross-tenant access, chain depth and the MCP definition controls — but baselineClusterPolicy.enforcementMode ships as audit. Every control evaluates and records a non-compliance event; the call proceeds. You promote controls to enforcement once the blast radius is visible, per control, from the compliance view. (This is a real change of posture: shipping these as blocks put 20-plus block rules in front of every tool call on day one, and some of them are noisy enough to matter — deny_shell_execution fires on roughly one in eight ordinary alphanumeric identifiers via its base64 fan-out.)

What fails open, and what fails closed. Norviq is not uniformly fail-closed, and the split is deliberate:

Situation Shipped behaviour
Engine genuinely unavailable — 5xx, timeout, connect error, open circuit Allow, attributed. webhook.injection.fallbackMode (injected sidecar) and NRVQ_SDK_FALLBACK_MODE (SDK) both default to allow; the call carries rule_id=engine_unavailable_fallback (SDK) or thin_proxy_fail_open (sidecar) so you can count and alert on exactly which calls went unjudged
Engine answered with a 4xx — expired token, revoked credential, malformed body Block, always, whatever the fallback mode (rule_id=engine_rejected_request)
Policy subsystem not yet warm Block (rule_id=policy_load_pending)
Evaluation error, timeout, or malformed / spoofed identity Block — unless the namespace is explicitly in monitor mode, which softens these too
No policy loaded for the namespace Governed by config.noPolicyDecision (ships allow)

Defaulting the outage path to allow is a real trade: for the duration of an outage, calls proceed without a policy decision. It is defaulted that way because the alternative makes Norviq a single point of failure for every agent in the cluster. The mitigation is attribution, not silence — a fallback allow is never indistinguishable from a policy allow. See Configuration to flip either one.

Platform and security teams running LLM agents on Kubernetes who need those agents’ tool access to be governed the same way any other workload is — by policy, by identity, and centrally — rather than trusted to prompt engineering.

  • Enforce, not observe. Norviq is in the request path and can stop a call before its side effect, not flag it afterward — while shipping in audit mode so you choose when that starts.
  • Identity-scoped. Policy is keyed to the workload’s identity (SPIFFE shape), so an agent can’t grant itself more reach by editing what it sends. See Workload identity.
  • Framework-agnostic. The same enforcement covers LangChain, LangGraph, CrewAI, AutoGen and Semantic Kernel — or any agent, via the sidecar — with no change to how the model reasons.
  • MCP is first-class, not bolted on. An mcp/tools/call becomes the same ToolCallEvent your SDK calls produce, so one policy governs both and one audit log records both.
  • Honest about proven vs present. Norviq’s asset & attack graphs distinguish reach it has actually observed an agent exercise from reach that is merely possible — an unproven path is labeled as such, not asserted as fact.
  • Honest about its own limits. The PEP governs the calls that go through it — a pod that never gets injected is never governed, and the security model names that case and the others plainly rather than leaving them for you to discover.
  • How it works — the enforcement path end to end.
  • Get started — install the chart and watch a decision flip from allow to block.
  • Concepts — identity, policy tiers, enforcement modes, trust score, and the graphs in depth.