Security model
Norviq’s trust boundaries and threat model, stated plainly: what it defends, what trusts what, which failures fail open and which fail closed, and where the honest limits of the current design are.
If you read one section, read Failure posture. It is the section operators get wrong, and the one where a wrong sentence in these docs would tell you the product is stricter than it is.
What Norviq defends
Section titled “What Norviq defends”Norviq is a policy enforcement point (PEP) for LLM agent tool calls: it enforces per-identity
allow/block/escalate/audit decisions on the inputs of a tool call (tool_name + tool_params)
before the tool body runs. The policy decision is always made centrally, at
POST /api/v1/evaluate — the sidecar and the SDK never decide locally. The MCP action firewall is
the one exception, and only in the tightening direction: it enforces its own pre-policy controls
(definition-pin drift, a definition that scanned as hostile, arguments the tool’s own schema forbids)
before any policy runs, and then reports that refusal to the engine so it is recorded. That report
can only ever say block — there is no value meaning “allow” — and it is written only when it
tightens a decision the engine had already made.
Two scope statements, both deliberate, both load-bearing:
- The PEP is input-only. An allowed call whose tool body returns sensitive data is outside the
input-PEP’s view.
NRVQ_SDK_OUTPUT_DLP_ENABLED(sdk_output_dlp_enabled, defaultfalse, not a chart value) is an opt-in partial mitigation that redacts PAN/SSN from an allowed tool’s return value in the SDK adapter. The MCP action firewall makes the opposite choice —NRVQ_MCP_OUTPUT_DLP_ENABLEDdefaults totrue, because an MCP tool result is pasted straight into the model’s context rather than handed to application code that can be trusted with the raw value. Policy coverage of egress/export tools on their inputs — blocking the export call itself, regardless of what it would have contained — is the primary control. Seepep-input-only-scope.md. - The PEP is cooperative. The agent’s SDK/sidecar asks for a forward-or-drop decision, and the
agent executes the tool. A pod that ignores the SDK can reach tools directly. The network-layer
bound on that is
agentEgressPolicy— see Cooperative enforcement below.
Trust boundaries
Section titled “Trust boundaries”flowchart LR
A["Agent pod<br/>agent code<br/>(LangGraph / SDK / adapter)"] -->|"tool call · UDS"| S["Sidecar PEP<br/>norviq-proxy<br/>auto-CA client cert<br/>(+ SPIFFE SVID, opt-in)"]
M["MCP proxy PEP<br/>python -m norviq.mcp"] -->|"mTLS + service JWT"| T
S -->|"mTLS + service JWT"| T["TLS terminator :8443<br/>(API-pod sidecar, nginx)"]
C["CRD controller<br/>(in webhook)"] -->|"mTLS · pins API cert<br/>to the internal CA"| T
T -->|"loopback :8080<br/>(plaintext, same pod)"| E["Central API / engine<br/>/api/v1/evaluate<br/>+ trust calculator"]
E -->|"query"| O["OPA<br/>(per replica)"]
E --> DB[("Postgres + Redis<br/>policies · audit · trust")]
-
The sidecar (PEP) — injected into the agent’s pod by the mutating webhook, it intercepts tool calls over a pod-private Unix domain socket and forwards them to the central engine. It presents the workload’s identity as claims on a service JWT (and, opt-in, a SPIFFE SVID), not a secret the agent code chose. In
proxymode (webhook.injection.sidecarMode: proxy, the default) the sidecar holds no policy state, so compromising one sidecar exposes that pod’s own traffic, not the policy set.embeddedmode runs a local Redis client + OPA subprocess + policy loader in the pod, for air-gapped/edge use — it needs the datastore reachable from the pod, which is a wider blast radius. -
The central API/engine — validates the caller’s JWT or API key, binds the request’s claimed agent identity to the credential’s claims, collects candidate policies, computes trust server-side, and queries OPA.
-
OPA — evaluates Rego against the input document
_build_inputconstructs (seeopa-input-schema.md). OPA runs per replica (opa.enabled: true, port8181,config.opaMode: server) rather than one shared OPA for the fleet, so a compromised instance is contained to its replica. Every submitted policy is rejected at the API layer if it references a forbidden token, and then independently re-compiled by the real OPA compiler (opa check) against a locked-down capabilities file. The forbidden set (norviq/api/routers/policies.py) is exactly:Token Why it is banned http.sendoutbound HTTP from inside the policy engine — SSRF to internal services and cloud metadata opa.runtimedumps the OPA server’s env and config (secret exfiltration) net.*net.lookup_ip_addr/net.cidr_*— network and DNS reconnaissance from inside the clusterio.*io.jwt.*— token forging and inspection surfacerego.parse_modulecompiles Rego at eval time from attacker-controlled input trace(internal evaluation-state disclosure (the builtin call only; a rule named traceis legal)data.norviq.managedcross-tenant read of another namespace’s compiled policy A
data.reference outside the module’s own declared package is rejected on the same path. Comments and string literals are stripped first, so a policy’s ownreasontext may mention these words freely. -
The database — Postgres holds policies, audit records and the agent registry; Redis holds the short-TTL evaluation cache, trust state and rate-limit counters. Trust is always recomputed server-side: a caller-supplied
trust_scorein the request body is discarded outright (payload.model_dump(exclude={"trust_score"})inrouters/evaluate.py), so a client can never assert its own trustworthiness. -
SPIFFE identity — the trust root for which agent this is, when you run it. In
config.spiffeMode: workload-apithe sidecar fetches a real SVID from the SPIFFE Workload API and takes namespace + service account from the attested SVID only: it fails closed on any socket/SVID error, with no env-var fallback, and a forgedNRVQ_NAMESPACEis ignored. This is bring-your-own SPIRE — it needs an existing SPIRE deployment plus the SPIFFE CSI driver; Norviq does not bundle SPIRE. The shipped default ismock(env-var identity), which exists for local dev, tests and the attack suite and should not be used where cross-pod identity spoofing is a live threat. See the SPIFFE/SPIRE identity guide. -
Tenant/namespace isolation — see Multi-tenancy.
Channel security (internal TLS/mTLS)
Section titled “Channel security (internal TLS/mTLS)”The identities above answer who is calling; this answers how the control-plane channels are
protected on the wire. With config.internalTls.enabled: true (the default — the operator does
nothing) the chart wires zero-touch mutual TLS across the control plane, so there is no unencrypted
sidecar → API or controller → API hop to intercept.
- Auto-CA, no operator certs. A pre-install/pre-upgrade Helm hook mints an internal CA (Secret
norviq-internal-ca) and a CA-signed serving cert for the API (Secretnorviq-api-tls). It is idempotent — existing secrets are reused, so the CA identity is stable across upgrades — and it self-deletes on success. No cert-manager, no CSRs, noopensslin operator hands. The hook image is the first-party bootstrap image (config.internalTls.proxyImageisnginx:1.27-alpinefor the terminator itself); it deliberately carries openssl and curl but not kubectl. - API TLS terminator on
:8443. The API pod runs an nginx terminator sidecar serving TLS (TLSv1.2/1.3) on:8443and proxying cleartext to the Python app over loopback onapi.port(8080) in the same pod. The app and its probes stay on plaintext localhost, never re-encrypted, while every off-pod control-plane client uses the TLS port. sidecar → APIis mutual-authenticated. The injector signs a per-namespace client cert from the internal CA and hands it to each injected sidecar, so the sidecar presents a CA-verified client cert and its service JWT. That cert is a transport credential (CNnorviq-sidecar, OU<namespace>), distinct from workload identity.controller → APIis fail-closed. The CRD controller (in the webhook) syncs policies overhttps://norviq-api.<ns>.svc:8443, pinning the serving cert to the internal CA. If the CA material cannot be loaded it falls back to a client with an empty trust pool, so every handshake fails verification (NRVQ-WHK-4046) rather than silently downgrading to plaintext.- Client-cert verification is
optionalat the terminator, by design.ssl_verify_client optionallets bearer-token clients (console, CLI) reach the API while a sidecar presenting a CA-signed client cert is verified and its identity forwarded to the app asX-Nrvq-Client-Verify/X-Nrvq-Client-Subject. mTLS is defense-in-depth alongside the JWT, not a replacement.
Failure posture: the fail-open/fail-closed matrix
Section titled “Failure posture: the fail-open/fail-closed matrix”Norviq sits in the request path of production agents. That forces a real trade, and the product takes an explicit position on it: a genuine Norviq outage does not stop the customer’s agents, but every unjudged call is named, counted and alertable. Everything else — an engine that answered and refused, a policy subsystem that has not warmed, an evaluation that faulted — fails closed.
The one decision the operator makes
Section titled “The one decision the operator makes”webhook.injection.fallbackMode (chart, values.yaml:443) → config.sdk_fallback_mode /
NRVQ_SDK_FALLBACK_MODE. It ships allow. Both the Python default and the Go webhook default
are allow, and the chart sets it explicitly, so allow is what every stock install runs.
# helm/norviq/values.yaml — shipped defaultwebhook: injection: fallbackMode: allow # fail-OPEN on a GENUINE outage onlyallow means: when the engine is genuinely unreachable — 5xx, timeout, connection error, or an
open circuit breaker — the tool call is forwarded ungoverned, and the decision is stamped with a
distinct rule_id so it is countable. block means no call ever proceeds unjudged, at the cost of
taking every agent in the cluster down for the duration of the outage.
What fallbackMode does not cover
Section titled “What fallbackMode does not cover”flowchart TD
R["PEP sends POST /api/v1/evaluate"] --> Q{"What came back?"}
Q -->|"2xx"| D["Engine's decision<br/>(allow / block / escalate / audit)"]
Q -->|"4xx — the engine ANSWERED and refused"| B["BLOCK, always<br/>fallbackMode is not consulted"]
Q -->|"5xx / timeout / connect error / open circuit"| F{"fallbackMode"}
F -->|"allow (default)"| FO["ALLOW ungoverned<br/>rule_id = thin_proxy_fail_open<br/>or engine_unavailable_fallback"]
F -->|"block"| FC["BLOCK<br/>rule_id = thin_proxy_fail_closed<br/>or engine_unavailable_fallback"]
A 4xx always blocks, whatever fallbackMode says. A 4xx is not an outage — the engine answered
and refused the request. Treating it as an outage would be both a bad diagnostic (operators chase
healthy engine pods when the cause is an expired token) and a governance bypass: with
fallbackMode: allow, every 401/403 would become an allow, so a revoked credential would silently
turn into a total bypass. Worse, a 4xx an attacker can provoke — influence a tool param into a 422
— would allow the call.
Two consequences that follow from the same rule, both regressions that were found live and fixed:
- Only a 5xx counts toward the circuit breaker. The breaker is checked at the top of
evaluate(), so if 4xx responses tripped it, the Nth consecutive 401 would open the circuit and every later call would short-circuit straight to the fallback — never reaching the 4xx rule. With the shipped defaults that flipped an expired credential from fail-closed to fail-open. The breaker also only resets on a success, so a permanently bad credential never recovered. Thresholds (env-only, not chart values):NRVQ_SDK_CIRCUIT_FAIL_THRESHOLD=3,NRVQ_SDK_CIRCUIT_RESET_AFTER_MS=2000. - A 4xx is not retried. Both data-plane paths retry transport errors and 5xx with exponential
backoff (
NRVQ_SDK_RETRY_MAX_ATTEMPTS=2,NRVQ_SDK_RETRY_BACKOFF_BASE_MS=100) before giving up, sofallbackModeonly decides what happens after a sustained outage — not during a rolling restart. Retrying a 4xx would only delay the same block.
The full matrix
Section titled “The full matrix”Every row is a real code path. “Softened by monitor mode?” means: in a namespace whose
enforcement_mode is audit, the block becomes an audit decision with the rule_id prefixed
monitor_would_block: — the call proceeds and the would-be block is recorded.
| Condition | Decided by | Decision | rule_id |
Softened by monitor mode? |
|---|---|---|---|---|
| Engine returns 4xx (bad/expired credential, malformed request, 422) | Sidecar PEP | block | thin_proxy_fail_closed (reason names it as a refusal, not an outage) |
n/a — minted in the PEP, never reaches the engine |
| Engine returns 4xx | SDK / MCP proxy PEP | block | engine_rejected_request |
n/a |
| Engine unreachable: 5xx, timeout, connect error, open circuit | Sidecar PEP | fallbackMode (allow by default) |
thin_proxy_fail_open / thin_proxy_fail_closed |
n/a |
| Engine unreachable | SDK / MCP proxy PEP | fallbackMode (allow by default) |
engine_unavailable_fallback (emitted in both modes) |
n/a |
NRVQ_SDK_FALLBACK_MODE set to an unrecognised value |
SDK PEP | block (coerced) | engine_unavailable_fallback, plus NRVQ-SDK-1015 |
n/a |
| Policy loader bound but warm load incomplete (cold replica, mid-rollout) | Engine | block | policy_load_pending (NRVQ-ENG-2056) |
yes |
Policy load failure (load_from_db raised), or any unclassified exception |
Engine | block | evaluator_fallback (NRVQ-ENG-2000 / 2003) |
yes |
| Evaluation exceeded the budget | Engine | block | evaluator_timeout (NRVQ-ENG-2020 / 2021) |
yes |
| OPA evaluation failed persistently (retried once first) | Engine | block | evaluator_error (NRVQ-ENG-2057) |
yes |
| OPA returned no usable decision payload | Engine | block | evaluator_invalid_payload |
yes |
| Agent SPIFFE id failed format validation | Engine | block | invalid_spiffe_identity (NRVQ-ENG-2006) |
yes |
| No policy loaded for the namespace/class | Engine | allow | default_allow |
n/a (already an allow) |
No policy loaded, and config.noPolicyDecision: deny in enforcementMode: block |
Engine | block | no_policy_loaded (NRVQ-ENG-2055) |
yes |
| Admin froze the agent | Engine | block | trust_frozen |
no — exempt |
| Per-namespace rate limit exceeded | Engine | block | rate_limit_exceeded |
no by default (NRVQ_MONITOR_EXEMPT_RATE_LIMIT = true) |
| Redis unreachable at the HTTP rate limiter | API middleware | fails open — request proceeds | none; logged NRVQ-API-7133 |
n/a |
| Injector unavailable at pod admission | Kubernetes | pod creation rejected | webhook.injection.failurePolicy: Fail |
n/a |
No policy loaded allows, with a named rule
Section titled “No policy loaded allows, with a named rule”This is the row people misread most often, and the previous version of this page had it backwards.
# helm/norviq/values.yaml — shipped defaultconfig: enforcementMode: block noPolicyDecision: allow # NOT denyconfig.noPolicyDecision ships allow (values.yaml:744, matching config.py:73). A namespace
with no policy loaded gets decision: allow, rule_id: default_allow, reason: "No policy matched". The allow is named — default_allow is a real value in the audit record, and several
surfaces key on it precisely because it means “nothing adjudicated this” rather than “a rule
permitted this” (/policy-compliance and the red-team efficacy scorer both exclude it).
It was deny once, and the reasoning for the change is worth stating rather than hiding: installing
the chart into a namespace and not yet writing a policy took that namespace’s tool calls to zero. A
namespace with no policy has no defense the customer asked for, and inventing one by refusing
everything is an outage with a security-shaped rule_id. Deny-by-default is still right for a
namespace you have decided to lock down — that decision now has an explicit home in a baseline
control set to deny, or config.noPolicyDecision: deny, rather than being the silent consequence
of not having configured anything yet.
Note the ordering inside _no_policy_decision: not-yet-warmed is checked first. A cold replica
returns policy_load_pending and blocks; it is never mistaken for genuine no-policy. And a load
failure never reaches this function at all — load_from_db raising propagates to evaluate()’s
outer handler, which fail-closes with evaluator_fallback and logs NRVQ-ENG-2000.
Monitor mode softens engine faults — deliberately
Section titled “Monitor mode softens engine faults — deliberately”A namespace set to enforcement_mode: audit (monitor mode) is a promise: evaluate everything, record
non-compliance, interrupt nothing. As of 0.2.3 that promise covers operational blocks too —
policy_load_pending, evaluator_error, evaluator_timeout, evaluator_fallback,
evaluator_invalid_payload, invalid_spiffe_identity all soften to an audit decision with the
rule_id prefixed monitor_would_block:.
Earlier releases kept them hard, on the reasoning that an engine fault is not a policy decision and should not be “monitored away”. True, and beside the point: the customer is not asking Norviq to monitor an engine fault, they are telling it not to break their agents. Two rules stay hard:
trust_frozen— an admin explicitly froze this agent. Incident response outranks posture.rate_limit_exceeded— a resource control protecting the customer’s own backend. SetNRVQ_MONITOR_EXEMPT_RATE_LIMIT=falseif you want even this to soften (read per call, so it takes effect without a restart).
One thing softening does not do: if the namespace posture cannot be read (Redis is often the reason
you are on the failure path at all), the hard verdict stands. Unknown posture never means monitor
(NRVQ-ENG-2061).
Detecting that you are in one of these states
Section titled “Detecting that you are in one of these states”GET /api/v1/system-health backs the console banner. It reports only what it can prove from
recorded decisions in a 15-minute window, and it matches both the bare and the
monitor_would_block:-prefixed spelling of every infra rule, so the banner survives monitor mode.
Two honest gaps you should plan around:
thin_proxy_fail_open,thin_proxy_fail_closedandengine_rejected_requestare never written toaudit_logtoday. In proxy mode the sidecar has no local emitter — the central/evaluateis what persists the record, and that is exactly what is unreachable when those verdicts fire. The keys exist insystem_health.pyfor the day a producer can deliver them; nothing populates them now. Alert on the sidecar’s own logs —NRVQ-SDC-3036(fail-open),NRVQ-SDC-3031(fail-closed),NRVQ-SDK-1013(SDK fallback) — not on the console banner.- A window with no real recorded decisions is reported as
status: "unknown", never"ok". Idle and severed are indistinguishable from the API’s side. Policy Tester and red-team rows are excluded from the liveness count, so the console cannot manufacture its own all-clear.
evaluator_error, evaluator_timeout, evaluator_fallback and policy_load_pending do reach
audit_log — the engine mints them and they travel out through the API’s own emitter, so the API is
by definition up when they are recorded. Those are the substantiable half.
AuthN/Z
Section titled “AuthN/Z”Token validation — norviq/api/auth.py supports two mutually exclusive paths, each pinned to a
single-algorithm allowlist so an attacker cannot downgrade an OIDC RS256/ES256 token into an
HS256-with-the-public-key forgery (alg confusion):
- Legacy HS256 (
oidc.legacyHs256Enabled: true) — a shared secret (api_secret_key/NRVQ_API_SECRET_KEY) signs short-lived session tokens for local username/password login. The API refuses to boot with the built-in default secret or default admin password whileconfig.requireStrongSecret: true(the default). The chart auto-generates and persists a strong random value whenauth.adminPassword/api.secretKeyare left at their sentinels. - OIDC RS256/ES256 (
oidc.enabled: falseby default) — validated against the IdP’s JWKS by key id; group claims are mapped to a Norviq(role, namespace, cluster)tuple viaoidc.groupMappings. An authenticated-but-unmapped user gets the least-privilege floor (viewer, no namespace, no cluster) rather than falling through to broader access, and conflicting group mappings fail closed rather than picking one silently.
Logged-out tokens are rejected server-side by a revocation check (a signature-valid but logged-out
JWT is dead, not merely client-discarded), and a token minted with must_change=True — the seeded
admin, or any account after norviq admin reset-password — is locked to the change-password, logout
and /me routes until the password is actually changed.
Role model — three roles, ranked: admin (3) > service (2) > viewer (1). service is for
machine principals (the webhook controller syncing CRDs, sidecars, the fleet relay) and cannot
self-elevate to a human’s write paths. Policy mutation requires admin, or admin_or_service for
the narrow CRD-sync create/delete paths the controller uses; there is no “policy editor” role in the
API’s runtime auth. The chart’s norviq-policy-editor Kubernetes ClusterRole is a different
layer — it gates kubectl access to NrvqPolicy CRDs, not the API’s auth roles. The chart ships
norviq-admin, norviq-policy-editor and norviq-viewer ClusterRoles with no subject bindings
(rbac.bindings: [], rbac.exampleBindings.enabled: false); binding them is your job.
Namespace scoping — scoped_namespace / read_namespace bind every namespace-scoped request to
the caller’s namespace claim. An admin (or a claim of "*") may read/write any namespace or request
namespace=all; a scoped tenant asking for all gets its own namespace, never a cross-tenant
read; a non-admin human with no namespace claim gets a 403 rather than defaulting to broad
access. scoped_cluster applies the same pattern to the fleet dimension, and require_target_cluster
rejects a write carrying an X-Nrvq-Target-Cluster header that does not match this deployment’s
served cluster (409) — a mutation aimed at another cluster must never silently land here.
Machine principals are the residual. A service principal with an empty namespace claim is
still trusted with the namespace the request body names; the hot path needs that latitude. Two things
bound it: attested_namespace derives the namespace from the caller’s own credential-claimed SPIFFE
id when there is one (a Norviq SVID encodes spiffe://norviq/ns/<ns>/sa/<sa>, parsed with the strict
4-segment parser, and a credential that contradicts itself is a loud 403), and
_reject_cross_namespace_spiffe pins a body-supplied SVID to the token’s own namespace claim.
Identity binding — auth.requireBoundAgentIdentity
Section titled “Identity binding — auth.requireBoundAgentIdentity”Ships true (values.yaml:866). A machine principal must carry the bound-identity claims for
this deployment’s SPIFFE mode, or its /evaluate call is refused with a 403
(NRVQ-AUTH-14020). A claim that disagrees with the request body is also a 403
(NRVQ-AUTH-14019); a merely absent value is corrected silently from the claim.
The required set is mode-dependent, and this is not a preference:
config.spiffeMode |
Required claims |
|---|---|
mock (default) |
agent_class, spiffe_id |
workload-api |
agent_class only |
The injector can only mint a spiffe_id claim when it can predict the SVID byte-for-byte, which is
the mock resolver. A SPIRE-issued SVID has a trust domain the webhook does not control, so demanding
the claim under workload-api would 403 every tool call — the one flag whose whole purpose is to be
turned on could not be.
Rate limits and request size
Section titled “Rate limits and request size”An HTTP-layer throttle — separate from the per-namespace config.rateLimit, which is an evaluated
policy decision — sits in front of the whole API (Redis-backed fixed window,
NRVQ_HTTP_RATE_LIMIT_WINDOW_S = 60), keyed per identity with a per-IP fallback for
unauthenticated requests:
| Route class | Per 60s window |
|---|---|
/api/v1/evaluate |
3000 |
/api/v1/auth/login |
20 (always per-IP — pre-auth) |
| policy dry-run | 20 |
| red team | 15 |
| everything else | 300 |
/healthz, /readyz and /metrics are never throttled. The limiter fails open if Redis is
unreachable — availability over strictness — logged at most every 30s as NRVQ-API-7133.
X-Forwarded-For is client-writable, so it is believed only when the TCP peer is in
config.httpRateLimitTrustedProxyCidrs (default ["127.0.0.0/8","::1/128"], i.e. exactly the in-pod
nginx) and the Nth entry from the right is taken, where N is
config.httpRateLimitTrustedProxyHops (default 1, matching a terminator that replaces the
header). Raise the hop count only if you add appending proxies, and widen the CIDRs to match.
Request bodies over NRVQ_MAX_REQUEST_BODY_BYTES (262144, 256 KiB) are rejected — the bound on the
base64 fan-out amplifier and on generic memory abuse.
/metrics is unauthenticated. It is mounted as a plain ASGI app with no auth dependency. It
exposes decision counters and latency histograms, not tool parameters, but treat it as a
cluster-internal endpoint and do not expose it through ingress.
Multi-tenancy
Section titled “Multi-tenancy”Policies and audit records are namespace-scoped by construction: the loader key is
{namespace}:{agent_class}, and audit rows carry the originating namespace. On the OPA side every
pushed policy gets its own package, norviq.managed.<sanitized-key>, computed server-side from
the full namespace:agent_class key — the submitted module’s package line is rewritten at push
time. That rewrite is why data.norviq.managed is an unconditional ban in the forbidden-token set:
the module body is not rewritten, so without the ban a tenant could declare
package norviq.managed.<victim-key>, pass the self-reference check, read the victim’s compiled
policy, and exfiltrate it through a dry-run reason string.
Cross-namespace reads require an explicit namespace=all and a role permitted to make it. The
default behavior on every scoped endpoint is single-tenant. See
namespace-scoping.md.
Known design decisions / non-goals
Section titled “Known design decisions / non-goals”These are stated intentionally, as operator responsibilities and threat-model notes — not gaps that were missed.
The baseline ships observing, not blocking
Section titled “The baseline ships observing, not blocking”baselineClusterPolicy.enforcementMode defaults to audit (values.yaml:86). A stock install
evaluates every baseline control and records what it would have blocked; the call proceeds. This
shipped as block, which put the strict preset’s rules in front of every tool call in every tenant
namespace on day one, before the customer had written a policy or seen a decision — and
deny_shell_execution fires on roughly 1 in 8 ordinary alphanumeric identifiers via the base64
fan-out, so a stock install dropped a real fraction of a support bot’s legitimate lookups and
attributed it to shell injection.
Do not read the per-control default_effect values from GET /api/v1/baseline/controls as what your
install does. Seventeen of the 21 preset controls carry default_effect: "deny", but the rendered
NrvqPolicy runs in audit, so nothing is dropped until you set enforcementMode: block or promote
individual controls. Use /compliance/policies in the console to see the blast radius first.
Related install fact: baselineClusterPolicy.enabled: true with an empty policyQuotaNamespaces
fails the install outright (templates/baseline-cluster-policy.yaml), rather than rendering zero
baselines while NOTES.txt claims one exists. Two safe-looking defaults combining into a silent gap
is refused loudly.
Cooperative enforcement, and the network-layer bound
Section titled “Cooperative enforcement, and the network-layer bound”The PEP is cooperative: the SDK/sidecar decides, the agent executes. A pod that bypasses the SDK
reaches its tools directly. agentEgressPolicy (default enabled: false) is the network-layer
bound — a default-deny egress policy for agent pods that permits only the Norviq API, DNS and an
operator-approved allowlist:
agentEgressPolicy: enabled: true engine: networkpolicy # or `cilium` for FQDN allowlisting namespaces: [] # empty => reuse policyQuotaNamespaces allowDNS: true allowedCIDRs: ["10.0.0.0/8"] # everything else, including the internet, is denied allowedFQDNs: [] # engine=cilium only allowedFQDNPatterns: [] # engine=cilium only allowedPorts: [] # empty => all ports embeddedDatastores: false # true only for sidecarMode: embeddedIt requires a NetworkPolicy-enforcing CNI (Calico, Cilium — kindnet ignores NetworkPolicy), and
it does not replace the PEP: per-call parameter policy still needs the SDK path. Leaving
allowedCIDRs empty is correct only if every tool the agent calls lives in the Norviq namespace;
otherwise its tool calls will fail. Full non-cooperative enforcement (the sidecar executing tools) is
a roadmap item, not a shipped control.
Two ways a pod escapes injection — and only one can be switched off
Section titled “Two ways a pod escapes injection — and only one can be switched off”A pod is injected only when its namespace carries norviq-injection=enabled and the pod carries
norviq.io/agent-class (webhook.injection.gateOnlyAgentPods, default true).
Opting out — adding norviq-injection=disabled or the norviq.io/skip-injection annotation — is
documented per-pod flexibility, e.g. exempting an infra pod in a labeled namespace. It is also a
bypass a pod author can grant themselves, so it can be closed cluster-wide with
webhook.injection.allowPodOptOut: false: the injector then ignores the label/annotation and
injects every routed pod.
Omitting the agent-class label is different and cannot be closed the same way. That gate lives
in the webhook’s objectSelector, so the API server filters those pods out before the injector is
invoked — allowPodOptOut: false never sees them. An unlabeled pod in a governed namespace runs
ungoverned, silently: no admission error, no event, no log. It looks identical to a healthy
governed pod. The same applies on upgrade: agent pods that relied on namespace-wide injection without
an agent-class label stop being injected on their next restart, silently.
That is the deliberate trade behind gateOnlyAgentPods. Setting it false leaves no escape by
omission, but makes Norviq’s availability a precondition for creating any pod in that namespace —
with failurePolicy: Fail, an injector outage then blocks databases, ingress controllers and Jobs
Norviq does not govern.
Because the omission path has no preventive control, treat it as detective. Nothing in the
product computes injected-vs-expected — system-health reports only recorded infra verdicts — so
verify by hand:
# pods that WILL be injectedkubectl get pods -n <ns> -l norviq.io/agent-class -o name
# every pod, with the label shown — blanks are NOT governedkubectl get pods -n <ns> -L norviq.io/agent-classPair that with RBAC on who may write the norviq.io/agent-class label and the opt-out
label/annotation.
CRD policy rules are enforced by the controller, not by admission
Section titled “CRD policy rules are enforced by the controller, not by admission”webhook/controller.go validates NrvqPolicy semantics — cross-namespace targets, clusterPriority
bounds (500–1000, restricted to the admin policy namespace), Rego content — when it syncs a CRD to
the central API, and logs-and-skips a CRD that fails. A failing CRD still exists in the cluster
(kubectl get nrvqpolicy shows it) but is never synced to the enforcement engine, so a malformed or
malicious CRD applied directly with kubectl is inert rather than rejected at admission time.
Norviq ships no ValidatingAdmissionPolicy for policies.
The one policy-flood guard that ships is an opt-in Kubernetes ResourceQuota
(templates/resource-quota.yaml) capping count/nrvqpolicies.norviq.io at 100 per namespace. It
renders one per entry in policyQuotaNamespaces, and it limits object count — it validates
nothing. Note the precondition: every namespace you list must already exist, or the install fails
with a clear message telling you to create it first. RBAC on who may create NrvqPolicy/NrvqClass
objects is the operator-side mitigation.
Everything else
Section titled “Everything else”- A single JWT signing secret is the legacy-auth trust root. In the HS256 path one shared secret
(
api_secret_key) signs every session token; compromising it forges tokens for any role.config.requireStrongSecret(default on) refuses to boot on a weak/default secret, but operators with an IdP should prefer OIDC, where per-key JWKS validation removes the single-secret exposure. - The input-side PEP does not inspect tool outputs by default — see What Norviq defends.
mockSPIFFE mode trusts environment variables for identity. It exists for local dev, tests and the attack suite. In a multi-tenant cluster where pods can influence their own environment it removes the identity-spoofing protectionworkload-apiprovides.config.inprocCacheTtlStrades convergence for latency. It ships0(off). Setting it to5serves namespace posture and stored trust from a per-pod L1 cache, bounded stale by the TTL. The admin freeze and trust cap are read fresh inside the trust calculator and are never cached — the kill switch does not go stale.norviq/api/egress_allowlist.pyhas no router. It is a complete, tested compiler for destination-keyed egress control that is not reachable from any endpoint. There is nothing to configure and nothing to rely on; it is named here so nobody plans around it.
Supply chain
Section titled “Supply chain”-
Container images — every image is Trivy-scanned fail-closed post-build (
build.yml), cosign keyless-signed by digest, and SBOM-attested (SPDX JSON, out-of-bandsha256-<digest>.sbomtags). The workflow refuses to publish an unsigned image. -
The published chart is digest-pinned.
release.ymlresolves each just-built tag to its immutable index digest and writes it intoimages.<component>.digest, then renders the packaged chart and fails the release if anyghcr.io/norviq-dev/norviq-enginereference is not@sha256:-pinned. The in-treevalues.yamlshows floating-latesttags and empty digests; ahelm pull --version 0.2.5chart does not. -
Verify the chart before installing it:
Terminal window cosign verify ghcr.io/norviq-dev/charts/norviq:0.2.5 \--certificate-identity-regexp '^https://github.com/norviq-dev/norviq/.github/workflows/release.yml@.*' \--certificate-oidc-issuer https://token.actions.githubusercontent.com -
OPA binary is pinned and checksummed in the build, not pulled loose at runtime. The OPA image is
openpolicyagent/opa:1.19.1-static. -
SAST gate —
.github/workflows/security.ymlplus.pre-commit-config.yamlrun gitleaks (PR commit range), bandit and semgrep (diff-aware, semgrep also coversui/src), eslint-security (wholeui/src, fail-closed), pip-audit and a Trivy config/filesystem scan. It runs onpull_requestand on push tomain, because a fast-forward merge used to land unscanned while main still showed green. gitleaks, bandit, semgrep and eslint-security already fail the build on new HIGH/CRITICAL findings; pip-audit/npm audit and the checkov/kube-linter/Trivy config-fs scan are still report-only (continue-on-error) pending a one-time whole-repo baseline pass. Seesecurity-baseline.mdfor the triage rule and the ratchet plan. -
FOSSA covers open-source license and dependency posture;
fossa testfails the job on a policy violation. -
Every third-party GitHub Action is SHA-pinned, not tag-pinned, across all nine workflows (
build,security,release,test,fossa,framework-compat,kind-e2e,pypi-publish,verify-release). A mutable tag on a security-relevant action is a real supply-chain risk — the trivy-action project itself had a compromise.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Found a security issue? Do not open a public GitHub issue. Norviq’s coordinated-disclosure process
lives in SECURITY.md at the repo root — follow it to report privately.