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.
Identity: the four dimensions
Section titled “Identity: the four dimensions”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.
Identity resolution modes
Section titled “Identity resolution modes”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 aspiffe_idclaim 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 forgedNRVQ_NAMESPACEis 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.
Why mcp.server is not an identity
Section titled “Why mcp.server is not an identity”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.serveris 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/observesends 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.
Control-plane trust
Section titled “Control-plane trust”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.
Agent classes (NrvqClass)
Section titled “Agent classes (NrvqClass)”An NrvqClass is a cluster-scoped CRD that registers an agent class name — customer-support,
data-analyst.
apiVersion: norviq.io/v1alpha1kind: NrvqClassmetadata: name: customer-supportspec: 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.
Policies (NrvqPolicy)
Section titled “Policies (NrvqPolicy)”apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: customer-support-guardspec: 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-onlyspec.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 Rego contract
Section titled “The Rego contract”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).
Presets
Section titled “Presets”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. |
Policy tiers, floors and overlays
Section titled “Policy tiers, floors and overlays”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["<ns>:<agent_class>"]
wl["<ns>:deployment:<workload>"]
nst["<ns>:namespace:<ns>"]
nsb["<ns>:__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["<class>__remediation__"]
end
base --> M{"Combine on EFFECTIVE<br/>restrictiveness;<br/>tie → base"}
fl --> M
ov --> M
M --> D["Final decision"]
Two rules that are easy to get wrong
Section titled “Two rules that are easy to get wrong”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 bands
Section titled “Priority bands”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 — deny → blocks, monitor →
audits, 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.
Namespace monitor mode
Section titled “Namespace monitor mode”To roll out enforcement observably across a whole namespace without editing individual policies, put the namespace into monitor mode:
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— whileNRVQ_MONITOR_EXEMPT_RATE_LIMITis 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_payload — do
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.
Decisions
Section titled “Decisions”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 |
Trust score
Section titled “Trust score”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.7medium— score ≥ 0.4low— below 0.4. Alow-trust agent’s would-beallowis escalated (rule_id: escalate_low_trust).frozen— admin only. A computed score never auto-freezes; a score of exactly 0.0 categorizes aslow. 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 usesmin(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:
norviq agent freeze spiffe://norviq/ns/chatbot-prod/sa/defaultnorviq agent reset-trust spiffe://norviq/ns/chatbot-prod/sa/default --score 0.8reset-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.
Asset graph & attack graph
Section titled “Asset graph & attack graph”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.