Skip to content

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.

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, default false, 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_ENABLED defaults to true, 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. See pep-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.
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 proxy mode (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. embedded mode 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_input constructs (see opa-input-schema.md). OPA runs per replica (opa.enabled: true, port 8181, 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.send outbound HTTP from inside the policy engine — SSRF to internal services and cloud metadata
    opa.runtime dumps the OPA server’s env and config (secret exfiltration)
    net.* net.lookup_ip_addr / net.cidr_* — network and DNS reconnaissance from inside the cluster
    io.* io.jwt.* — token forging and inspection surface
    rego.parse_module compiles Rego at eval time from attacker-controlled input
    trace( internal evaluation-state disclosure (the builtin call only; a rule named trace is legal)
    data.norviq.managed cross-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 own reason text 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_score in the request body is discarded outright (payload.model_dump(exclude={"trust_score"}) in routers/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-api the 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 forged NRVQ_NAMESPACE is 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 is mock (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.

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 (Secret norviq-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, no openssl in operator hands. The hook image is the first-party bootstrap image (config.internalTls.proxyImage is nginx:1.27-alpine for 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 :8443 and proxying cleartext to the Python app over loopback on api.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 → API is 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 (CN norviq-sidecar, OU <namespace>), distinct from workload identity.
  • controller → API is fail-closed. The CRD controller (in the webhook) syncs policies over https://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 optional at the terminator, by design. ssl_verify_client optional lets 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 as X-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.

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 default
webhook:
injection:
fallbackMode: allow # fail-OPEN on a GENUINE outage only

allow 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.

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, so fallbackMode only decides what happens after a sustained outage — not during a rolling restart. Retrying a 4xx would only delay the same block.

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 default
config:
enforcementMode: block
noPolicyDecision: allow # NOT deny

config.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 nameddefault_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. Set NRVQ_MONITOR_EXEMPT_RATE_LIMIT=false if 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_closed and engine_rejected_request are never written to audit_log today. In proxy mode the sidecar has no local emitter — the central /evaluate is what persists the record, and that is exactly what is unreachable when those verdicts fire. The keys exist in system_health.py for the day a producer can deliver them; nothing populates them now. Alert on the sidecar’s own logsNRVQ-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.

Token validationnorviq/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 while config.requireStrongSecret: true (the default). The chart auto-generates and persists a strong random value when auth.adminPassword / api.secretKey are left at their sentinels.
  • OIDC RS256/ES256 (oidc.enabled: false by default) — validated against the IdP’s JWKS by key id; group claims are mapped to a Norviq (role, namespace, cluster) tuple via oidc.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 scopingscoped_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.

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.

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.

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: embedded

It 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 invokedallowPodOptOut: 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:

Terminal window
# pods that WILL be injected
kubectl get pods -n <ns> -l norviq.io/agent-class -o name
# every pod, with the label shown — blanks are NOT governed
kubectl get pods -n <ns> -L norviq.io/agent-class

Pair 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.

  • 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.
  • mock SPIFFE 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 protection workload-api provides.
  • config.inprocCacheTtlS trades convergence for latency. It ships 0 (off). Setting it to 5 serves 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.py has 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.
  • 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-band sha256-<digest>.sbom tags). The workflow refuses to publish an unsigned image.

  • The published chart is digest-pinned. release.yml resolves each just-built tag to its immutable index digest and writes it into images.<component>.digest, then renders the packaged chart and fails the release if any ghcr.io/norviq-dev/norviq-engine reference is not @sha256:-pinned. The in-tree values.yaml shows floating -latest tags and empty digests; a helm pull --version 0.2.5 chart 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.yml plus .pre-commit-config.yaml run gitleaks (PR commit range), bandit and semgrep (diff-aware, semgrep also covers ui/src), eslint-security (whole ui/src, fail-closed), pip-audit and a Trivy config/filesystem scan. It runs on pull_request and on push to main, 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. See security-baseline.md for the triage rule and the ratchet plan.

  • FOSSA covers open-source license and dependency posture; fossa test fails 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.

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.