Skip to content

Concepts

Want the pitch first? What is Norviq gives the one-paragraph overview and the problem it solves. This page is the vocabulary underneath it — read it once and the rest of the docs fall into place.

flowchart LR
    A["Agent tool call<br/>{tool, params}"] --> P["Sidecar / SDK<br/>(the PEP)"]
    P -->|"POST /api/v1/evaluate<br/>+ bound credential"| E["Engine"]
    E --> I["Resolve identity<br/>from the CREDENTIAL"]
    I --> C["Collect candidate<br/>policies: tiers,<br/>floors, overlays"]
    C --> O["Evaluate each<br/>in OPA"]
    O --> R["Resolve →<br/>decision + rule_id + reason"]
    R --> T["Trust overrides,<br/>namespace posture"]
    T --> P
    E --> L["Audit log ·<br/>trust score · graphs"]

The PEP is cooperative. The sidecar or SDK asks the engine for a forward/drop verdict and the agent’s own process then executes (or does not execute) the tool. A pod that never calls /evaluate is never evaluated — see Security model for what that means and what bounds it.

A tool call is decided against four identity dimensions. Which of them the credential pins, rather than the request body, is the whole security story — so they are worth learning individually.

Dimension Where it comes from What it selects
namespace The pod’s namespace, injected as NRVQ_NAMESPACE Every namespace-scoped tier, the namespace’s posture and settings
agent_class The pod’s norviq.io/agent-class label, injected as NRVQ_AGENT_CLASS Which Rego program runs — the <namespace>:<agent_class> policy key
spiffe_id Built by the sidecar (mock mode) or read from an attested SVID (workload-api mode) The trust score, the per-agent rate limit, and the agent_frozen admin kill switch
workload Derived at admission from the pod’s owner reference, injected as NRVQ_WORKLOAD The additive <namespace>:deployment:<workload> tier — absent when the pod has no resolvable owner

The SPIFFE ID has the shape:

spiffe://norviq/ns/<namespace>/sa/<service_account>

Note the last segment: it is the service account, not the agent class. In the default mock resolver the sidecar builds the id from NRVQ_NAMESPACE and NRVQ_SERVICE_ACCOUNT, and the injecting webhook deliberately never sets NRVQ_SERVICE_ACCOUNT — so an injected sidecar’s id is deterministically spiffe://norviq/ns/<namespace>/sa/default. That determinism is what lets the webhook mint a token whose spiffe_id claim matches byte-for-byte.

Bound identity: the claim wins, the body does not

Section titled “Bound identity: the claim wins, the body does not”

agent_class, spiffe_id and workload are bound identity fields. When a caller’s credential carries a claim for one, the API writes the claim over whatever the request body said — including over an omitted value. Dropping a field is as powerful as substituting one: an empty agent_class would skip the class program and silently fall back to the looser baseline, so silence is corrected rather than trusted. An explicit mismatch is a 403 (NRVQ-AUTH-14019), because that is an attempted spoof and operators should see it.

auth.requireBoundAgentIdentity (chart default true) is the ratchet. It applies to machine principals (role=service) only, and requires agent_class and spiffe_id. workload stays optional — a bare pod or a CRD-managed workload legitimately has no owner reference, and demanding the claim would fail those closed for a tier that does not apply to them. Human sessions are exempt; they have no agent identity to bind and are already tenant-pinned.

Because workload is additive, a bound credential that lacks the claim has any body-supplied workload cleared — otherwise a namespace-scoped sidecar could name any Deployment and pull in that tier’s program.

config.spiffeMode:

  • mock (default) — identity comes from environment variables the injector sets on the pod. The webhook can predict the resulting SPIFFE id exactly, so it binds a spiffe_id claim into the sidecar token and the whole four-field identity is credential-bound.
  • workload-api — the sidecar fetches a real X.509 SVID from the SPIFFE Workload API socket and takes (namespace, service_account) from the attested SVID only. Fail-closed and spoof-resistant: a socket or SVID error raises rather than falling back to env vars, and a forged NRVQ_NAMESPACE is ignored. Opt-in, and requires an existing SPIRE deployment plus the SPIFFE CSI driver — Norviq consumes a SPIRE, it does not bundle one. Setup: SPIFFE/SPIRE identity.

Identity resolution is cached, with the TTL hard-clamped to 300 seconds regardless of what NRVQ_SPIFFE_CACHE_TTL_S is set to. An over-long TTL would not be a faster cache, it would be SVID rotation and revocation turned off; the clamp is logged (NRVQ-IDT-10007) rather than refused.

Calls arriving through the Norviq MCP proxy (python -m norviq.mcp) carry an extra mcp object — server, transport, pin_status, scan_severity. It is tempting to read mcp.server as a fifth identity dimension. It is not, and treating it as one would be a security bug.

input.mcp is the PEP’s report about the tool’s definition. It is exactly as trustworthy as tool_name and tool_params, which come from the same place: a compromised proxy that could forge mcp.pin_status could equally forge the tool name, or simply not report the call at all. So:

  • mcp.server is not in _BOUND_IDENTITY_FIELDS — no credential claim pins it.
  • There is no <namespace>:mcp:<server> policy tier. Nothing in candidate collection keys on it.
  • The authoritative pin state lives in the control plane (mcp_tool_pins), and the approved digest never leaves it — POST /api/v1/mcp/pins/observe sends the observed hash up and the server computes the verdict.

What mcp.server is good for: policies may legitimately gate on it (server is an addressable intent field), and the operator’s MCP server registry compiles to the __mcp__ floor, which does key on input.mcp.server. But that floor encodes which integrations the operator expects in this namespace, which is a statement about the environment, not about who is calling. The registry controls are also inert on an empty registry, so they cost nothing on an estate with no MCP. For the pin store, drift detection and quarantine workflow behind that registry, see MCP servers.

The same discipline covers pep_decision: a PEP may report that it refused a call before policy ran, and the field is constrained to "" or "block" at the model boundary. There is no value it can carry that means “allow”, so a PEP-reported field can only ever add a block record.

Agent identity answers which agent is calling. A separate question is whether the traffic between the sidecars, the webhook and the API is really the control plane — so a rogue pod cannot POST forged decisions or scrape policy. Norviq answers it with zero-touch internal mTLS.

With config.internalTls.enabled: true (the default), a Helm pre-install hook mints an internal CA and the API serving cert, the API pod fronts itself with an nginx TLS terminator on :8443, the CRD controller verifies the API cert against that CA, and the injector mints a per-namespace CA-signed client cert for each sidecar so it does mutual TLS to the API. No openssl, no CSRs, no cert-manager. If the client-cert mint fails the injector logs NRVQ-WHK-4048 and falls back to plaintext + JWT. Set config.internalTls.enabled: false only for a throwaway dev cluster.

This is complementary to SPIFFE, not a replacement: mTLS binds the control-plane transport, spiffeMode: workload-api binds the workload’s own identity. Mechanism detail is in How it works.

An NrvqClass is a cluster-scoped CRD that registers an agent class name — customer-support, data-analyst.

apiVersion: norviq.io/v1alpha1
kind: NrvqClass
metadata:
name: customer-support
spec:
description: Customer-facing chatbot agents for orders, refunds, and product help
allowedTools: [search_kb, get_customer, get_order, update_order_status, send_email]
blockedTools: [execute_sql, delete_record, spawn_pod, exec_shell]

The enforcement — what actually happens when execute_sql is called — comes from an NrvqPolicy.

apiVersion: norviq.io/v1alpha1
kind: NrvqPolicy
metadata:
name: customer-support-guard
spec:
target:
agentClass: customer-support # or: namespace: <ns> or: kind: Deployment + name: <name>
enforcementMode: block # block | audit | escalate — required
preset: strict # strict | moderate | permissive
priority: 200 # 0–499; clusterPriority 500–1000 is admin-only

spec.target must set agentClass, namespace, or both kind and name. kind accepts Deployment and nothing else — the evaluator builds exactly one workload key, deployment:<name>, so StatefulSet/DaemonSet/ReplicaSet were removed from the enum rather than continuing to be accepted, synced clean, reported phase: Active, and enforced never.

spec.rules is advisory only. It is accepted and stored and read by nothing; listing a rule id there does not enforce it. The rules that run are the ones in spec.rego or the chosen spec.preset.

spec.rego (max 65536 characters) overrides preset.

The engine queries data.<package>.decision. Every module — preset, generated or hand-written — must define decision, rule_id and reason:

package norviq.custom.sql_guard
default decision = "allow"
default rule_id = "default_allow"
default reason = "Allowed"
violation {
input.tool_name == "execute_sql"
contains(lower(input.tool_params.query), "drop")
}
decision = "block" { violation }
rule_id = "custom_sql_guard" { decision == "block" }
reason = "DROP statement blocked by custom policy" { decision == "block" }

default decision = ... is required, not stylistic. Both the CRD controller (policy must define default decision) and the API’s write-time validator reject a module without one, with a 422 naming the four legal values. An unmatched call therefore resolves to an explicit, named decision instead of a bare undefined value.

This matters because one undefined case is treated as fail-closed rather than allow: if a policy’s partial-set rules (blocks / escalates / audits — the pattern the shipped presets and packs use) fire but no top-level decision is produced, that is a detection that matched with no resolver to turn it into a decision, and the engine returns evaluator_invalid_payload (block) rather than risk silently allowing a fired block.

The partial-set pattern is why several rules can fire on one call — PCI and PII and injection in one payload — without a complete-rule conflict: the sets collect triggers and a deterministic resolver picks the winner (block > escalate > audit > allow, ties by sorted rule_id).

spec.preset picks one of three shipped modules in webhook/presets/:

Preset What it is
strict The full horizontal policy (~1200 lines), inlined from comprehensive.rego. Composes the content detectors — injection, secret egress, PII, PCI, SSRF, base64 fan-out — in addition to the high-risk tool-name blocks, so an unprovisioned class falling back to a baseline gets real content protection rather than a name allowlist. The only preset with a CONTROLS-BEGIN/END region.
moderate 37 lines. Blocks any tool whose query parameter contains drop; escalates execute_sql.
permissive 25 lines. Allow by default, escalating only when input.trust_score < 0.4.

For a given tool call the engine collects every candidate that could apply and resolves them together. There are three kinds of candidate, and they combine differently.

Base tiers — each carries its own enforcementMode, and they resolve by priority:

Key Applies to
<ns>:<agent_class> Agents of that class in that namespace
<ns>:namespace:<ns> Every call in the namespace
<ns>:deployment:<workload> One Deployment — only when the caller’s credential carries the workload claim
<ns>:__baseline__ Every agent in the namespace (the chart’s baselineClusterPolicy renders here, one per listed namespace)
__cluster__:__baseline__ The whole cluster

Floors — generated modules in reserved scopes, collected as tighten-only, so priority is irrelevant to them:

Key Written by
<ns>:__controls__ PUT /api/v1/baseline/controls. Absent until an operator saves control effects at least once.
<ns>:__mcp__ POST /api/v1/mcp/servers/decision — the compiled MCP server registry. Inert on an empty registry.

__controls__ used to be a base tier at priority 2, and that was a bug worth knowing about: base tiers resolve by priority outright, so a class policy authored at 100 discarded the controls tier entirely. Writing a single class policy silently switched every promoted detector off for that class while Target Settings still reported them enforcing. As a floor it is tighten-only and lands in the hard partition, so nothing — not even a pack weaken — can relax it.

Overlays — opt-in, additive, and also tighten-only:

Key What it is
<ns>:__pack__ An enabled sector compliance pack
<ns>:__pack_override__ An operator’s tighten-only customization of that pack
<ns>:__pack_weaken__ An admin’s explicit relaxation of a pack’s own added block — the one exception to tighten-only, and scoped to the pack family alone
<ns>:__guardrail__ An opt-in per-namespace tool allowlist, authored via POST /api/v1/policies
<ns>:<class>__remediation__ A per-class overlay generated by the compliance “generate enforcing policy” workflow. It adds a block for one gap; it never replaces the class’s base policy.
flowchart TB
    subgraph base["Base tiers — highest priority wins among ENFORCING layers"]
        cls["&lt;ns&gt;:&lt;agent_class&gt;"]
        wl["&lt;ns&gt;:deployment:&lt;workload&gt;"]
        nst["&lt;ns&gt;:namespace:&lt;ns&gt;"]
        nsb["&lt;ns&gt;:__baseline__"]
        clb["__cluster__:__baseline__"]
    end
    subgraph fl["Floors — tighten-only, priority irrelevant"]
        ctl["__controls__"]
        mcp["__mcp__"]
    end
    subgraph ov["Overlays — tighten-only"]
        pack["pack family<br/>(__pack__ · __pack_override__ · __pack_weaken__)"]
        guard["__guardrail__"]
        rem["&lt;class&gt;__remediation__"]
    end
    base --> M{"Combine on EFFECTIVE<br/>restrictiveness;<br/>tie → base"}
    fl --> M
    ov --> M
    M --> D["Final decision"]

An audit-mode base tier cannot disarm an enforcing one. Base tiers are partitioned: layers whose enforcementMode is audit are pulled out and re-applied as tighten-only observers, exactly like an overlay. Otherwise trialling a rule the documented safe way — save it in audit mode at a high priority — would have won on priority and switched enforcement off. An observing layer can raise an allow to an audit; it can never lower a block.

Comparison uses effective decisions, not raw ones. A block emitted by a policy saved in audit mode is softened to audit moments later, so it is compared as an audit. Without this, the chart’s audit-mode __baseline__ and an enforcing __controls__ floor both looked like blocks, the tie went to the base, and a control the operator had explicitly promoted to Enforce came back as policy_audit_would_block: — enforcement losing silently to a tiebreak.

priority is 0–499 for namespace users. The 500–1000 band is clusterPriority, reserved for cluster administrators and the control-plane webhook controller; the API rejects a non-admin write into it with a 422 rather than silently clamping. The chart’s baselineClusterPolicy ships at clusterPriority: 900.

Enforcement mode vs control effect vs decision

Section titled “Enforcement mode vs control effect vs decision”

Four different things share overlapping words. They are not interchangeable.

Concept Where it is set Values What it does
decision Emitted by the Rego, per call allow · block · escalate · audit The verdict for one tool call
enforcementMode On one NrvqPolicy block · audit · escalate How that policy’s verdicts are applied
control effect Per detector, PUT /baseline/controls off · monitor · deny Which partial set a detector’s head registers into
namespace posture PUT /api/v1/settings?namespace= block · audit Whether the whole namespace enforces or only observes

The decision. allow and audit both let the call proceed — the sidecar forwards on either. block and escalate both stop it: the SDK raises NorviqBlockError / NorviqEscalateError and the sidecar drops the call. escalate means “refused, and a human should look at this”, not “held for release” — there is no release mechanism. audit means the call was evaluated, recorded as non-compliant, and allowed through.

enforcementMode. Only audit is special-cased by the engine. When an audit-mode policy wins, its block/escalate is softened to audit and the rule_id is prefixed policy_audit_would_block: — so the policy reports what it would have done without stopping the call. That is the right way to trial one policy. block and escalate behave identically; setting escalate does not turn a policy’s blocks into escalations.

Control effect. A preset registers each detector as a one-line head on a named predicate:

blocks["deny_shell_execution"] { shell_injection_detected }

An effect is nothing more than which set that head registers into — denyblocks, monitoraudits, off → omitted entirely. The detection predicates above the marked region are left byte-identical, which is what makes the compiler safe to reason about. Twenty-one controls ship: seventeen with default_effect: deny, four with monitor (llm02_data_leakage, base64_decoded_threat, strict_default_block, scope_violation_dangerous_tool). The compiled __controls__ policy always runs in block mode — the module already carries each control’s own effect, and softening the whole policy on top would collapse three effects into two. For the full list of detectors and which ones are known to false-positive (deny_shell_execution’s base64 fan-out among them), see Baseline controls.

To roll out enforcement observably across a whole namespace without editing individual policies, put the namespace into monitor mode:

Terminal window
curl -sS -X PUT "$NRVQ_API_URL/api/v1/settings?namespace=chatbot-prod" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"enforcement_mode":"audit"}'

The namespace query parameter selects the target and defaults to default. Do not put namespace in the body — the request model uses extra="forbid" and will 422, deliberately: it used to return 200 while writing the posture of a namespace you did not name.

Monitor mode softens every would-be block/escalate to a logged audit with the rule_id prefixed monitor_would_block:. Two rule ids stay hard:

  • trust_frozen — an admin explicitly froze this agent. Incident response outranks posture.
  • rate_limit_exceeded — while NRVQ_MONITOR_EXEMPT_RATE_LIMIT is true (the default; it is an engine setting, not a chart value, and is read per call so flipping it needs no restart). “Do not block on policy” is a statement about policy judgements, not a request for unbounded call volume.

Engine-health blocks — policy_load_pending, evaluator_error, evaluator_invalid_payloaddo soften now. They used to stay hard, which meant a namespace configured specifically not to drop traffic still dropped it whenever Norviq’s own engine had a bad moment. They stay loudly logged and distinctly attributed; they no longer take the customer’s production down.

Writable settings on this endpoint are enforcement_mode, trust_threshold, rate_limit, sector and apply_mode. There is no per-namespace violation_penalty — it never reached the engine and was removed rather than left inert.

Every evaluated call resolves to a PolicyDecision with three fields that always carry a value: decision, rule_id (which rule produced it — never blank on a block) and reason (the authored, human-readable explanation, returned on /evaluate as well as written to the audit log). Confirming an outcome by rule_id and reason is the product’s own discipline: rule_id says which rule fired, reason says why its author cared.

Failure paths each carry their own named rule_id, so an engine-health problem is never mistaken for a real policy block:

rule_id Meaning
evaluator_error / evaluator_fallback OPA evaluation failed
evaluator_timeout The 2 s evaluation budget was exceeded
evaluator_invalid_payload Rules fired but produced no resolvable decision
invalid_spiffe_identity The caller’s SPIFFE id failed format validation
policy_load_pending The replica’s policy warm-load has not completed
no_policy_loaded No policy at all, under an explicit deny-by-default
pep_refused A PEP refused before policy ran and named no control of its own

Alongside the policy decision, every call recomputes a per-agent trust score from the agent’s recent history. It is never asserted by the caller. Seven weighted signals:

Signal Weight
violation_rate 0.25
tool_novelty 0.20
scope_drift 0.15
param_entropy 0.15
time_decay 0.10
chain_depth 0.10
session_velocity 0.05

The score buckets into four categories:

  • high — score ≥ 0.7
  • medium — score ≥ 0.4
  • low — below 0.4. A low-trust agent’s would-be allow is escalated (rule_id: escalate_low_trust).
  • frozenadmin only. A computed score never auto-freezes; a score of exactly 0.0 categorizes as low. A frozen agent has every call blocked (rule_id: trust_frozen), and that block survives namespace monitor mode.

A per-namespace trust_threshold moves the boundaries: it becomes the high boundary, and the low boundary scales with it (low = high × 0.4/0.7). The cluster default is config.trustThreshold: 0.7.

PUT /api/v1/agents/{spiffe_id}/trust is admin-only and has full-state, mutually exclusive semantics on the score it is given:

  • 0 — freeze. Every call blocks, and any cap is cleared.
  • 0 < score < 1 — a tighten-only cap. The engine uses min(computed, cap), so this can push an agent toward escalate or freeze and can never raise trust above what behaviour earns.
  • 1.0 — clear both the freeze and the cap, back to purely behavioural trust.

The freeze and the cap are persisted durably to agent_registry and re-seeded into Redis at startup, so a Redis flush or restart cannot silently lift a kill switch. They are read fresh on every call and are deliberately never cached, so a freeze propagates cluster-wide immediately even with the in-process L1 cache enabled.

From the CLI:

Terminal window
norviq agent freeze spiffe://norviq/ns/chatbot-prod/sa/default
norviq agent reset-trust spiffe://norviq/ns/chatbot-prod/sa/default --score 0.8

reset-trust posts whatever --score you give it, so the default of 0.8 unfreezes the agent and leaves a 0.8 cap behind. Pass --score 1.0 to clear the cap as well.

As calls are evaluated, Norviq incrementally builds an asset graph per namespace — agents, the tools they have called, the data and resources those tools touch, and any MCP servers seen — so you can see an agent’s real reach rather than its declared one. The attack graph walks that graph from each origin looking for paths to sensitive data or destructive tools, scoring them and mapping them to MITRE ATLAS techniques where applicable.

Non-agent origins (MCP servers) are capped at 40 of the 200 path slots. mcp.server names are PEP-reported, so an agent that can call /evaluate chooses which server names become graph nodes; without the sub-cap, 300 fabricated servers pushed every real agent kill-chain out of the view. The response reports the true non_agent_paths count, so a truncated view states its own size.

On top of the graphs sit the console’s discovery-and-defence workflows — source capability findings, Simulate, Defend, and the tool-classification promotion lifecycle. All of it is in Asset & attack graphs.