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
audit — before the tool runs.
The problem
Section titled “The problem”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.
What Norviq is
Section titled “What Norviq is”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).
Two places it intercepts
Section titled “Two places it intercepts”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/listandnotifications/*_changedresponses 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: tofutrusts a definition on first sight and enforces change;strictquarantines it until an operator approves. - Gate B — invocation. Every
tools/call(and, in scope,resources/readandsampling/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.
The posture it actually ships with
Section titled “The posture it actually ships with”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.
Who it’s for
Section titled “Who it’s for”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.
What makes it different
Section titled “What makes it different”- 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/callbecomes the sameToolCallEventyour 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
allowtoblock. - Concepts — identity, policy tiers, enforcement modes, trust score, and the graphs in depth.