Repository navigation
Releases: norviq-dev/norviq
Release list
v0.2.5 — log codes that named two things at once
⚠️ Upgrade note — if you alert on Norviq log codes, read this first
Eight log codes named both a signal you would page on and ordinary traffic, so alerting on any
of them also fired on routine activity and had to be silenced to be usable. Each alertable event now
has its own code.
Existing alert rules keyed on the old codes will keep matching — they will just stop matching the
event you cared about. That is the failure mode to watch for: nothing breaks loudly, the alert
simply goes quiet on the thing it was written for.
| event | was | now |
|---|---|---|
sidecar.remote_evaluator.fail_open |
NRVQ-SDC-3032 |
NRVQ-SDC-3036 |
api.rate_limit.fail_open |
NRVQ-API-7080 |
NRVQ-API-7133 |
api.rate_limit.exceeded |
NRVQ-API-7081 |
NRVQ-API-7134 |
api.policy.priority_band_denied |
NRVQ-API-7020 |
NRVQ-API-7135 |
api.body_too_large |
NRVQ-API-7050 |
NRVQ-API-7136 |
api.settings.mirror_failed |
NRVQ-API-7063 |
NRVQ-API-7137 |
startup.ns_settings_warm_failed |
NRVQ-API-7063 |
NRVQ-API-7138 |
engine.trust.freeze_check_failed |
NRVQ-ENG-2050 |
NRVQ-ENG-2067 |
fleet.pull_failed |
NRVQ-FLT-15016 |
NRVQ-FLT-15043 |
fleet.puller_no_trust_root |
NRVQ-FLT-15016 |
NRVQ-FLT-15044 |
mcp.servers.control_plane_unreachable |
NRVQ-MCP-5070 |
NRVQ-MCP-5077 |
Each old code keeps the meaning its documentation named first, so a runbook grepping
NRVQ-API-7080 still matches what it always matched — it just stops also matching the fail-open.
(NRVQ-MCP-5070 is the exception in form only: MCP codes are documented in
guides/mcp.md, not the error reference, and the guide's
meaning is the one 5070 keeps.)
The sidecar code needs a pod restart to take effect. NRVQ-SDC-3036 is emitted from inside the
sidecar injected into your agent pods. Upgrading the chart does not re-inject running pods, so
existing agents keep emitting NRVQ-SDC-3032 for the fail-open until they are restarted and the new
sidecar image is injected. Roll your agent workloads, or expect the old code until you do.
Security-relevant
The sidecar's fail-open was hiding behind startup noise. NRVQ-SDC-3032 carried two startup
INFO lines (sidecar.mode.proxy, remote_evaluator.mtls_enabled) and
remote_evaluator.fail_open — the record that a tool call ran unjudged because the control plane
was unreachable and sdk_fallback_mode is allow. The error reference described 3032 as "two
events, both about proxy mode" and never mentioned the third, so an operator who read the docs
correctly and filtered 3032 as startup noise was filtering away the only signal that says agents are
currently ungoverned. Now NRVQ-SDC-3036, alone.
MCP Gate A no longer reads as clean when it is blind. The server registry gained a degraded
flag that nothing read, so discovery could not distinguish "this server was checked and is fine" from
"I could not reach the control plane to find out". Gate A now emits
nrvq.mcp.gate_a.server_decision_unavailable (NRVQ-MCP-5076) when the registry is degraded
and it holds no decision for the server being discovered.
api.rate_limit.fail_open — Redis unreachable, so the HTTP throttle passes everything through —
shared a code with cluster_info.served, which fires on every console page view. Alerting on the
limiter failing open meant alerting on ordinary browsing.
settings.mirror_failed and ns_settings_warm_failed both mean saved per-namespace posture is
not the posture being enforced: the row committed and the console shows it applied, but the Redis
mirror the evaluator reads did not update. Both shared a code with settings.warmed, which fires on
every API process start.
api.body_too_large — a 413 rejection, and the only record the request-size cap fired, since the
reject path returns before the metric. It shared a code with asset_graph.served, one per page view.
Operator-visible
- The console no longer publishes a clean bill of health for a namespace it could not read. An
empty block feed meant two opposite things: genuinely zero blocks under enforcement, or a namespace
whose posture the Dashboard could not determine — which may be in Monitor mode, where a policy
block can never appear in that feed at all. It printed the confident one. It now shows a distinct
"posture could not be read" state when neither the coverage field nor the settings posture answers. NRVQ-MCP-5070named two unrelated conditions and the docs disagreed about which. The
operator-facing guide won: 5070 stays withgate_a.schema_not_fully_enforceable; the servers-store
failure moved toNRVQ-MCP-5077.docs/error-codes.mdnow states how much reuse there actually is — 96 codes, where it had said
"a handful" — and the invariant that replaced the guesswork: a code that fires on something
alertable does not also fire on a routine read.
Everything else
- A regression test (
tests/test_log_code_uniqueness.py) derives severity from the log level at
each call site rather than from a hand-written list of words. The previous keyword approach
reported zero problems while five of the splits above were live —body_too_largecontains no
security word,mode.proxycontains no routine word, so neither pairing matched. Two frozen lists
ratchet downward so the debt can only shrink. NRVQ-API-7132(policy.intent_without_baseline) shipped previously with no reference row; it has
one now.
Verification
Published artifacts were checked against the registries rather than the workflow's exit status: the
chart verifies with cosign (claims, transparency log, trusted CA) and was control-tested against a
wrong signer identity; all five GA image tags resolve to exactly the digests the chart pins; the
wheel was installed from PyPI into a clean venv with python -I from an empty directory. A fresh
install on a clean cluster and an in-place 0.2.4 → 0.2.5 upgrade both came up with 0 restarts, the
upgrade preserving its Postgres and Redis volumes.
Install
policyQuotaNamespaces is required — list the namespaces that will run agents. The chart refuses to
install with it empty rather than silently rendering no baseline.
kubectl create namespace norviq
kubectl create namespace chatbot-prod # your tenant namespace(s), created BEFORE install
helm install norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.5 -n norviq \
--set 'policyQuotaNamespaces={chatbot-prod}'Upgrade from 0.2.4
helm upgrade norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.5 -n norviq \
--reset-then-reuse-values
kubectl -n norviq rollout status deploy/norviq-api deploy/norviq-engine
# then roll your agent pods so the new sidecar is injected — see the upgrade note aboveVerify the chart signature before installing if you want to:
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.comFull changelog: v0.2.4...v0.2.5 ·
Error code reference: https://docs.norviq.dev/ · Docs: https://docs.norviq.dev/getting-started/
v0.2.4 — two chart values that were connected to nothing
At a glance
Large release: MCP Gate A hardening, several identity fixes, and five behaviour changes that do not
announce themselves during helm upgrade.
| if you… | then |
|---|---|
set config.rateLimit or config.violationPenalty in values |
they were connected to nothing and now take effect. Check your values before upgrading |
| reference sidecar credential Secrets by name | the names change — the hash suffix is now unconditional |
use the norviq.io/workload pod label to pick an identity |
it is now a fallback, not an override; the attested owner chain wins |
apply generated intent policies, or call /system-health as a no-scope viewer |
those requests now 409 / 403 where they used to succeed |
| run the fleet hub | its Postgres DSN moved from a ConfigMap into norviq-secrets (only when fleet.hub.enabled=true) |
The security section below is the substance of the release — an MCP tool call that executed twice, a
handshake Gate A never scanned, and a positive-security policy a tool name could invert.
⚠️ Upgrade note — five behaviour changes on an existing install
None of these announces itself when you run helm upgrade — three surface later as HTTP refusals,
the rest are silent. Read them before upgrading.
Two chart values that were connected to nothing now take effect. config.rateLimit renders as
NRVQ_RATE_LIMIT and config.violationPenalty as NRVQ_VIOLATION_PENALTY, and no Settings field
consumed either name — extra="ignore" dropped both in silence. Both shipped defaults matched the
code defaults (60 and 0.05), so each knob looked wired right up until someone depended on it. If you
set either to a non-default value, that value starts being enforced on this upgrade.
config.rateLimit sets the default per-identity tool-call ceiling per window
(evaluator_rate_limit_per_window) — a namespace with an explicit per-namespace rate-limit override
still wins over it, and read-prefixed tool names stay exempt by default; config.violationPenalty is
the trust-score decrement per violation. Check what your values file actually says before you run the
upgrade.
Sidecar credential Secret names change. The name is documented as identifying the
(namespace, workload, agent_class) triple, but the hash suffix carrying agent_class was only
appended when the readable part needed sanitising or truncating. A clean, short workload name skipped
it — so checkout/support-agent and checkout/payments-agent both resolved to
norviq-sidecar-checkout. The suffix is now unconditional, which means names change for every
workload that previously skipped it. Old Secrets are left behind. They are inert once pods are
admitted against the new name, but nothing garbage-collects them; delete norviq-sidecar-* objects
with no matching pod once your agent workloads have rolled.
norviq.io/workload is now a fallback, not an override. It used to be consulted before the
owner references, so an explicit label always won — and a pod author controls their own labels, while
the workload tier selects which policy program runs. The label is now read only when the ownership
chain yields nothing (a bare pod, or an owner kind the webhook does not recognise). If you were using
the label to give an owned pod a workload name different from its Deployment / ReplicaSet /
StatefulSet / DaemonSet / Job, that pod now gets the owner-derived name instead.
Three requests that used to succeed now refuse.
| request | was | now |
|---|---|---|
POST /policies with a generated intent policy into a namespace with no baseline |
200 | 409, naming the cause and the way out (NRVQ-API-7132) |
GET /system-health as a non-admin with an empty namespace claim |
200, full deployment | 403 No namespace scope |
POST /mcp/pins/observe as a viewer |
200 | 403 Admin or service role required |
The fleet hub's Postgres DSN moved out of a ConfigMap and into norviq-secrets — fleet-hub
installs only (fleet.hub.enabled: true, off by default; a single-cluster install renders none of
this and is unaffected). Nothing about how the hub consumes it changes — the pod already loads that
Secret by secretRef. But the password was sitting in norviq-fleet-config for every prior release,
so it has been readable to anything holding get configmaps in the namespace and printed in full by
kubectl describe and every routine cluster dump. If you run the hub, rotate it. Anything of
yours that read NRVQ_FLEET_PG_URL out of the ConfigMap directly will also need repointing.
Security-relevant
A tool name could invert the one positive-security policy in the product. generate_intent_rego
emits DEFAULT-DENY Rego over an allowlist, and every interpolated value went into the generated source
as f'"{v}"' with no escaping. A tool name of a"} allow_intent { true } unused := {" closes the set
literal and defines the rule the policy gates on; agent_class is interpolated the same way and works
the same way. It is the inversion of the control rather than a degradation of it, and it is quiet: the
module still compiles, so the policy validator accepts it on apply, and the generated draft still
carries a header saying DEFAULT-DENY with the injected rule below it. Escaping now happens at
emission — _rego_string, _rego_inner and _rego_comment, with json.dumps as the encoder
because Rego string literals are JSON strings, and ensure_ascii left on so
homoglyphs and zero-width characters are visible in a policy a human is asked to approve.
_rego_comment also strips control characters, because a newline ends a # line and starts
executable policy.
One approved MCP tool call could execute twice. _handle_post issued a buffered POST, and if the
server answered text/event-stream rather than application/json it fell through to _stream_through
with the same approved body — POSTing the tool call a second time. The first response was discarded
unread, so the client saw one execution while the tool had run twice against one Gate-B decision and
one audit row. The server picks that content-type per request and is the untrusted party, so any MCP
server could double every state-changing call it was permitted to make, with nothing surfacing to the
client or the operator. Real clients send Accept: application/json, text/event-stream, which accepts
a stream rather than requiring one, so this was the ordinary path. _post_once now opens the response
as a stream and reads the content-type before consuming the body: exactly one upstream request, either
shape.
Gate A never scanned the initialize handshake. The response dispatch had branches for
server/discover, tools/list, prompts/get, resources/list and the rest — and no case for
initialize, so the reply fell through to the terminal forward and reached the client byte-for-byte:
unscanned, unfenced, unannotated. firewall.py's own module header and __init__.py's overview both
listed initialize as covered. It is the worst surface to have missed: instructions is free text the
host puts in front of the model, arriving on the first message of the session, before tools/list,
before any registry check, before any pin exists. It is not dead protocol either — protocol.py is
explicit that SPEC_2026 does not retire SPEC_2025, so this is the dominant path for every client that
has not moved to server/discover. Both revisions now route through one _gate_a_handshake body, and
NRVQ-MCP-5064 (nrvq.mcp.discover.flagged) carries a new surface field naming which one fired.
Flagged rather than blocked, matching server/discover: refusing a handshake bricks the server, and
the threat is the model reading advertised text as instructions.
Running the red-team suite silently escalated every real agent it simulated against. The simulator
builds its events with the real agent's SVID. _safe_register_agent already excluded that synthetic
traffic from the console's violation counter; _safe_record_history, queued from the same
_persist_behavior fan-out two calls later, did not.
TrustCalculator._VIOLATION_CAPS caps a score at 0.30 once an agent has at least
_MIN_OBSERVATIONS_FOR_CAP (20) observations at a >20% block rate, and
_apply_trust_overrides turns a low category into escalate — and most of the shipped attack corpus
is expected to block. So one admin "Run suite" click put every governed agent in that namespace into
"every benign call held for human approval" for the length of the trust window, attributed to attacks
that never happened, with the violations page reporting none. The earlier fix applied to the visible
half and not the enforcing half. Both _safe_record_history and _safe_update_profile now exclude
synthetic traffic, from the whole history record rather than just
its decision, because tool_novelty, scope_drift and session_velocity all read that store.
A pod could choose its own workload identity. workloadFromPod consulted the norviq.io/workload
label before the owner references, and the workload tier selects which policy program runs — so a pod
under a strict workload policy could label itself with a permissive workload's name and have the
webhook mint that name into its bound workload JWT claim. An identity tier the identity can pick is
not one. The other two tiers are attested: namespace comes from the SVID, agent_class is rewritten
from the credential. See the upgrade note for what changes.
Two agent classes on one workload overwrote each other's sidecar credential. Because the
agent_class hash suffix was conditional, checkout/support-agent and checkout/payments-agent
resolved to the same Secret name — and admission rewrites rather than merges, so each pod's
admission overwrote the other's. The surviving pod's sidecar picks up claims minted for a different
agent_class on its next restart: a refusal, or th...
v0.2.3 — a PEP refusal recorded as an allow, and defaults documented backwards
Security-relevant
A PEP refusal was persisted as an allow on the cache-hit path. evaluate() returns a decision on
two non-exception paths (its three exception handlers mint their own fail-closed decisions). The
fresh-evaluation path folds the upstream PEP refusal into the decision before _persist_behavior, and
the comment there says exactly why: the attack graph, trust history, behavioural profile, the agent
registry's violation flag and the decision counters all read that value. The cache-hit path never folded
it. _handle_cache_hit applies rate-limit, trust overrides, posture and block attribution — but not the
fold — so a refused call reached every one of those derived surfaces as an allow and the caller was
never penalised. The cached base decision is deliberately never folded (a PEP refusal is a fact about
one call; caching it would deny every later caller sharing the key), which is precisely why the fold
has to be re-applied per call, on both paths.
Reachable without contrivance: two MCP-proxied pods of the same deployment, sharing a namespace, agent
class and the Redis eval cache, both in front of an operator-blocked server. Their tools/list refusals
agree on every term of the eval-cache key — tool name, params digest, call_depth, workload and the
MCP-context hash — so the second pod is served the first's cached decision, and its refusal was recorded
as an allow. The HTTP answer stayed correct the whole time: the API boundary and the SDK re-apply the
fold, and the audit row is written after it, so the caller was never served a wrong verdict and nothing
in the response or the audit log would have revealed the drift. The audit row was right and every
derived surface was wrong.
This is the only runtime change in 0.2.3. After upgrading, expect refusals that were previously
invisible to start appearing in trust scoring and the attack graph.
Four documented defaults were not the shipped ones, and one README sentence described a posture the
chart does not install. Nothing changed in the product here — values.yaml, the CRDs, the templates
and norviq/config.py are byte-identical to 0.2.2. The documentation was wrong, and mostly in the
dangerous direction.
| setting | docs said | actually ships |
|---|---|---|
config.noPolicyDecision |
deny (fail-closed) |
allow |
webhook.injection.fallbackMode |
block (fail-closed) |
allow |
gracefulShutdown.preStopSleepSeconds |
3 |
15 |
auth.requireBoundAgentIdentity |
false |
true |
fallbackMode is the sharpest of these. It told an operator that a governed agent refuses every tool
call while the engine is unreachable. It allows them — forwarded ungoverned, audited under the named
thin_proxy_fail_open rule rather than as a policy allow. An operator reading that row concludes an
engine outage is a safe state and never sets the value. noPolicyDecision is the same shape one level
up: it decides what happens in a namespace nobody has written a policy for, i.e. the state a fresh
install is in, and the reference docs said an ungoverned namespace was already protected. It answers
allow, attributed to the named default_allow rule.
requireBoundAgentIdentity drifts the safe way — the docs understated what is enabled — but it is the
row an operator reads before an upgrade, so being wrong about which side it starts on is not harmless.
preStopSleepSeconds was documented at the value the chart's own comment records as measured wrong.
The README claimed the chart "installs a fail-closed strict namespace baseline". The baseline ships in
audit mode (baselineClusterPolicy.enforcementMode): every control evaluates and records a
non-compliance event, and the call proceeds. Set enforcementMode: block when you are ready to enforce.
The secret scanner ran on main and read nothing. security.yml has run on push to main since
0.2.1, because direct pushes skipped the entire suite. It ran, and examined zero commits:
github.event.pull_request.base.sha is empty on a push, so --log-opts="${BASE}..${HEAD:-HEAD}"
collapsed to ..HEAD, which git resolves as HEAD..HEAD. gitleaks inspected nothing and exited 0 — and
that green check is indistinguishable from a real clean scan, in the repo of a security product. Bandit
had the same empty-BASE shape and printed "no changed python" for pushes that changed Python; semgrep's
empty --baseline-commit turned a diff scan into a whole-repo scan by accident rather than by decision.
All three now resolve BASE from the event that actually fired and, when there is no usable base (new
branch, force-push, rewritten history), fall back to a full scan rather than an empty one — gitleaks
to --all, bandit to every tracked norviq/**/*.py, semgrep to a no-baseline scan — each echoing which
mode it took. gitleaks additionally counts the resolved range and scans full history rather than nothing
when that count is zero. The ..HEAD collapse is closed at its source in all three steps; the explicit
empty-range guard is gitleaks' alone.
Operator-visible
- No chart value, CRD, template or engine setting changed in this release. If you set any of the
four rows above explicitly, nothing moves. If you left them alone on the strength of the docs, re-read
them — your posture is the one in the right-hand column, and always was.
Everything else
- The kind-e2e login gate was skipped by a mode bit.
scripts/e2e.shreached for
kind-e2e/login-gate.shbehind[ -x ... ]and the file was committed100644, so the guard was
simply false — and because a failed-xtest is just a skipped branch, it printed nothing at all.
NRVQ_E2E_PASSWORDstayed unset, the 11 specs that drive the real login form fell back to a
placeholder password, and each waited out a 20-second timeout. That is the whole reason the job was
red every day since 2026-08-13. The bit is set, and the gate no longer depends on it: it is invoked
throughbash, guarded on-f, and a genuinely missing file now says so. - The Playwright browser install is bounded at 12 minutes with a named error, and the job cap is
raised 45 → 60 minutes.--with-depsshells out to apt and can stall on a package lock or a slow
mirror; unbounded, that stall looks exactly like a slow suite from the outside — one run sat there 39
minutes and was killed at the cap having never started a single spec. Run through
bash -c 'cd … && …'rather thanenv -C, which needs coreutils 8.28+. - A stale e2e assertion, and its missing complement.
apply-surfaces-enforceasserted that an
unknown namespace/class fails closed, which was right when the engine shipped default-deny. It now
asserts what actually matters: the allow is attributable, carrying the nameddefault_allowrule
rather than an emptyrule_id, since an anonymous allow is indistinguishable from a policy that ran
and permitted the call. Its fail-closed complement is asserted alongside it against the live engine —
a bogus credential must be refused401, never served a decision. - A trust test that could not fail.
tests/attacks/conftest.pypostedchain_depthin every request
body whileEvaluateRequestdeclarescall_depthand sets nomodel_config, so pydantic dropped the
key: every caller sent depth 0, andtest_chain_depth_reduces_trustassertedtrust_score <= 0.8
against an input of0.8— an assertion that holds whether or not anything moves, and nothing moved.
The field name is fixed, and the test now compares depth-4 against a depth-0 baseline taken in the
same run rather than against a constant it was handed. - The OPA download in
Dockerfile.api/Dockerfile.engineretries (--retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 15 --max-time 180), which every CI workflow that fetches OPA
already did. These are the builds that matter most — the identicalRUNline in both files produces
the api and engine images thatrelease.ymlpublishes, so a transient blip failed a release rather
than a nightly. Same binary, still checksum-verified after the fetch; no runtime change.
Verification
From this repo's own evidence, at the v0.2.3 tree:
- The cache-hit fold is covered by
tests/engine/test_evaluator_unit.py::test_pep_refusal_is_folded_on_the_CACHE_HIT_path_too, which
runs in the gated pytest job. The file was run at the tag — 12 passed — and then control-tested by
reverting the 17-line evaluator hunk: the new test fails withassert 'allow' == 'block'on the
cache-hit path, so it is a test that can actually fail rather than one that reports safety it never
checked. - The empty-range collapse reproduces at the tag:
git rev-list --count ..HEADreturns 0 where
git rev-list --count HEADreturns 476. - The documented install command renders:
helm templatewithpolicyQuotaNamespaces={chatbot-prod}
against the v0.2.3 chart succeeds, and the same render with the list empty fails at
templates/baseline-cluster-policy.yaml:15— so the guard below is real, not just documented. The
rendered manifests carryNRVQ_NO_POLICY_DECISION: "allow",NRVQ_SDK_FALLBACK_MODE: "allow",
preStop … sleep 15,NRVQ_AUTH_REQUIRE_BOUND_AGENT_IDENTITY: "true"and baseline
enforcementMode: audit— the right-hand column of the table above, as the chart actually emits it. - The only source change under
norviq/betweenv0.2.2andv0.2.3is that 17-line addition to
norviq/engine/evaluator.py;helm/norviq/values.yaml,helm/norviq/crds/,helm/norviq/templates/
andnorviq/config.pyare unchanged. tests/attacksis excluded from the gated pytest run by design — its conftest targets a running stack
— so the chain-depth harness fix above is not CI-gated. The commit records it measured against a
live 0.2.2 engine (call_depth=0→ trust 0.855,call_depth=4→ 0.775,call_depth=12→
...
v0.2.2 — a 4xx must never open the circuit breaker into fail-open
Security-relevant
An expired credential silently disabled enforcement. The SDK's policy engine client counted every
httpx.HTTPStatusError toward its circuit breaker — 4xx included. The breaker is checked at the
top of evaluate(), so once it opened, every later call returned from _fallback_decision() and
never reached the rule that makes a 4xx block. With the shipped defaults
(sdk_circuit_fail_threshold 3, sdk_fallback_mode allow) three consecutive 401s were enough:
calls 1–3 blocked with engine_rejected_request, and every call after that was allowed with
engine_unavailable_fallback.
It did not recover on its own. _record_success is the only thing that resets the breaker, so a
credential that is simply wrong never produces one — the breaker re-arms on each probe and the agent
settles into a mostly ungoverned state rather than a failed one. _handle_http_error's docstring
names the attacker-provokable variant of the same shape: influence a tool parameter into a 422 and
the fallback allows the call.
Only a silent engine may trip the breaker now. 5xx, timeouts and connect errors still count toward it;
a 4xx is the engine answering, and it blocks every time, however many arrive
(norviq/sdk/client/engine.py, _post).
Scope is PolicyEngineClient — the SDK's remote-evaluation path, and the MCP gateway, which
constructs one unconditionally (norviq/mcp/http.py, norviq/mcp/stdio.py). The injected thin-proxy
sidecar evaluates through norviq/sidecar/remote_evaluator.py, a separate code path that has no
circuit breaker and is unchanged in this release.
Operator-visible
- After upgrading, a bad credential blocks instead of passing. An agent that had settled into
fail-open on an expired token will now block every tool call withengine_rejected_request—
nrvq.sdk.evaluate.rejected,NRVQ-SDK-1014— until the token is fixed. That is the intended
posture, but it is a visible change from 0.2.1: the symptom flips from silence to a hard stop. - The fix ships in the
norviqPython package. Upgrading the chart does not patch an agent image
that pins an oldernorviqwheel — rebuild, orpip install -U norviq==0.2.2, wherever your agents
run the SDK or the MCP gateway. - Two docs named
openpolicyagent/opa:1.18.0-staticagainst a chart that already pins
1.19.1-static— theopa.imagerow indocs/configuration.mdand the bundled-dependency list in
docs/getting-started.md. Both now read1.19.1-static, and
docs/engineering/security-baseline.md— which recorded the 1.19.0 pin and stopped there — gained
the entry explaining why the pin moved to 1.19.1. Docs-only:opa.imagein
helm/norviq/values.yamlis1.19.1-staticat both tags and is unchanged in this release. Two
historical records (docs/campaign2/HANDOVER.md,docs/design/IMPLEMENTATION-LOG.md) still name
opa:1.18.0-staticand are deliberately left as written.
Everything else
- Two regression tests, one per direction, in
tests/sdk/test_engine_client.py: repeated 401s must
block every call, and 5xx must still open the circuit. norviq/sdk/client/engine.pywas normalized from CRLF to LF, which is why its diff reads as a
full-file rewrite. The only behavior change is the>= 500guard in_post.
Verification
The 4xx test was control-tested rather than trusted green: with 0.2.2's test file against 0.2.1's
engine.py, test_repeated_4xx_never_opens_the_circuit_into_fail_open fails at exactly the 4th call
(three block/engine_rejected_request logged NRVQ-SDK-1014, then three
allow/engine_unavailable_fallback); at 0.2.2 all 13 tests in that file pass. Its companion,
test_5xx_still_opens_the_circuit, passes at both tags — it guards the direction the fix must not
break, so it was never shown able to fail and is not evidence for the fix itself.
No CI suite ran on this tag. At 0.2.2, test.yml triggers on pull_request and
workflow_dispatch only — there is no push trigger — and release.yml, which fires on the v*.*.*
tag, runs no suite of its own. So the job that covers tests/sdk
(pytest tests/ -q --ignore=tests/integration --ignore=tests/attacks) is a pull-request gate, and
both commits in this range landed on main directly rather than through one. The release workflow
does sign the chart keylessly with cosign and then verify the signature it just produced, failing the
release if it cannot; the wheel is published to PyPI via Trusted Publishing.
No breaking changes: helm/norviq/values.yaml, norviq/config.py and the CRDs are identical between
the two tags, and no env var or rule_id was renamed.
Published artifacts were not checked against the registries for this release note.
Install
policyQuotaNamespaces is required — list the namespaces that will run agents. The chart refuses to
install with it empty rather than silently rendering no baseline.
kubectl create namespace norviq
kubectl create namespace chatbot-prod # your tenant namespace(s), created BEFORE install
helm install norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.2 -n norviq \
--set 'policyQuotaNamespaces={chatbot-prod}'Upgrade from 0.2.1
helm upgrade norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.2 -n norviq \
--reset-then-reuse-values
kubectl -n norviq rollout status deploy/norviq-api deploy/norviq-engine
# then upgrade the norviq wheel wherever your agents run the SDK or the MCP gateway —
# the chart upgrade alone does not carry this fix into your own imagesVerify the chart signature before installing if you want to:
cosign verify ghcr.io/norviq-dev/charts/norviq:0.2.2 \
--certificate-identity-regexp '^https://github.com/norviq-dev/norviq/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comFull changelog: v0.2.1...v0.2.2 ·
Docs: https://docs.norviq.dev/getting-started/
v0.2.1 — agent_class is attested, not asserted
At a glance
Security-hardening release. It will refuse traffic that 0.2.0 accepted — read the upgrade note
before rolling it out.
| if you… | then |
|---|---|
mint role=service credentials without an agent_class |
they now get 403 on every evaluate. Set auth.requireBoundAgentIdentity=false, roll pods, re-issue keys, set it back |
| rely on the baseline observing rather than blocking | 17 of 21 controls now ship deny. Controls you set explicitly are untouched; ones you never touched move to their new default |
parse GET /threats/attack-paths |
cls and trust can now be null, and src_kind is new |
set NRVQ_API_URL implicitly |
it now follows NRVQ_POLICY_ENGINE_URL instead of defaulting to a name that only resolves in the API's own namespace |
run helm install with a policyQuotaNamespaces entry that does not exist |
the chart now refuses to render instead of dying half-applied |
Everything else here is a fix, not a change in what you must do. Full detail below.
⚠️ Upgrade note — this release changes defaults that refuse traffic
agent_class is now attested instead of asserted. auth_require_bound_agent_identity flipped
False → True (norviq/config.py:459; chart key auth.requireBoundAgentIdentity,
helm/norviq/values.yaml:862).
Under the old default, scoped_identity bound only claims whose credential value was non-empty — so
a namespace-scoped service key minted with agent_class='' had that claim skipped and the request
body chose the class. agent_class selects the Rego program. The profitable move was not
impersonating a real class but naming one nobody had written a policy for, which falls through to
no_policy_decision: allow. The namespace dimension has returned 403 on a mismatch all along; this
is the same rule for the other dimension that decides which program runs.
What it costs: a role=service caller must now carry both agent_class and spiffe_id
(_REQUIRED_BOUND_FIELDS, norviq/api/auth.py:370). spiffe_id is dropped from the required set
when spiffe_mode=workload-api, because the injector cannot mint a SPIRE-issued id it does not
control. A credential missing a required field gets 403 "Credential is not bound to an agent
identity", logged as nrvq.auth.identity_unbound_denied. Human sessions are unaffected, and
role=admin bypasses binding entirely.
Upgrading from 0.1.x with sidecars or API keys that predate agent_class binding: install with
--set auth.requireBoundAgentIdentity=false, roll your agent pods so the webhook re-mints their
tokens (it stamps agent_class from the pod's norviq.io/agent-class label), re-issue workload API
keys with an agent_class, then set it back to true. The flag and its runbook are unchanged.
Other refusals that used to be silent acceptances
| what | was | now |
|---|---|---|
POST /keys, role=service, no agent_class — or no spiffe_id where the deployment requires it |
200, and a key that 403s on every evaluate call | 422 naming the missing field |
POST /policies at an overlay scope (__pack__, __pack_override__, __pack_weaken__, __guardrail__, __egress__, __controls__, __mcp__, <class>__remediation__) with enforcement_mode="audit" |
200, stored audit, badged audit, hard-blocked the next call |
422 |
POST /redteam/suite with any undeclared query param (e.g. ?namespace=) |
200, silently measured default |
422 naming target_agent / target_namespace |
NRVQ_MCP_PIN_STORE set to anything outside ""/memory/file |
in-process store, drift detection off, every surface still reporting pinned |
ValueError at construction — startup, never the hot path |
helm install listing a policyQuotaNamespaces entry that does not exist |
died partway through on the ResourceQuota, leaving a failed release to uninstall |
chart refuses to render, naming every missing namespace |
The chart guard probes for kube-system first, so helm template and client-side --dry-run — where
lookup returns empty and cannot know — still render.
The baseline now enforces. 17 of 21 controls ship at deny where every control previously
shipped at monitor; normalize_effects seeds from each control's own shipped default rather than
the global one (norviq/api/baseline.py:469). Stored per-control effects are sparse and still win, so
anything you set explicitly is untouched — but a control you never touched moves to its new shipped
default the next time that namespace's baseline is compiled. The four that stay observing are
llm02_data_leakage, base64_decoded_threat, strict_default_block and
scope_violation_dangerous_tool; the last registers as audits[...] in the preset, and a test now
asserts an audit-only control never ships enforcing. Note that deny does not always mean block —
the compiler preserves each head's authored severity, so an escalates[...] control still escalates.
GET /threats/attack-paths can now return nulls. cls and trust became str | None /
float | None, src_kind was added (agent | mcp_server), and governed_by gained "n/a". An
MCP-server origin has no agent class, and the previous code fabricated cls="" and trust=0.8 —
which renders green and capped such a path at "high" through the (1.0 - trust) * 0.5 severity term.
NRVQ_API_URL now follows NRVQ_POLICY_ENGINE_URL unless you set it explicitly
(norviq/config.py:536). They are documented as the same target but are two env vars, and every
deployment sets only the latter — so anything reading api_url fell back to the short name
http://norviq-api:8080, which resolves only inside the API's own namespace.
Security-relevant
- Cleared the CVEs that blocked the v0.2.0 release run. OPA moved 1.18.0 → 1.19.1 across both
image Dockerfiles, the chart's OPA sidecar and all three CI workflows that install it — four pin
sites, sincetest.ymlcarries two — with the SHA256 digests re-derived from the published
downloads so the build still fails closed on a mismatched artifact.
The initial 1.19.0 pin closedoras.land/oras-go/v2CVE-2026-50163 — reached through the
vendored OPA static binary, not through any Norviqgo.mod— and cleared all three baselined HIGHs
at once; 1.19.1 then cleared eight Gostdlibfindings..trivyignore.yamlis now
vulnerabilities: []: the suppressions were removed rather than version-bumped, because a stale
entry silently disarms the gate for a CVE the shipped binary no longer has. - The webhook shipped a vulnerable
golang.org/x/net— CVE-2026-46600, HIGH, an// indirect
requirement that is nonetheless in the module graph Go embeds in the binary. Now v0.58.0. - Nine Debian-layer HIGHs in the engine and api images.
Dockerfile.apiandDockerfile.engine
ranapt-get update && installbut neverapt-get upgrade, so both shipped whatever the base froze
with.Dockerfile.uialready patched its base; these two now do the same. - SAST never ran on
main.security.ymltriggered onpull_requestonly, so a fast-forward
merge skipped gitleaks, bandit, semgrep, pip-audit and the Trivy fs/config scan entirely — and
mainshowed green. It now also runs on push tomain. The eight findings that surfaced were
cleared; twonosemgrepdirectives had been written on f-string continuation lines while semgrep
reports at thetext(call, so they had shipped inert. - A cloud-metadata / SSRF floor in the baseline (
norviq/engine/ssrf.py,blocks["ssrf_metadata"]).
IMDS at169.254.169.254, ECS task credentials,metadata.google.internaland loopback in every
spelling —127.0.0.1,2130706433,0x7f000001,127.1,localhost— all evaluated asallow
before. Classification is Python (ipaddress,inet_aton) rather than more Rego because the
encodings are the attack and a pattern enumerating spellings always misses one. RFC1918 is
classified and deliberately not blocked: an agent in Kubernetes talks to in-cluster services
constantly, soprivateis published for a customer who wants that rule. - One normalize+decode stage in front of every content detector (
norviq/engine/content_norm.py).
Measured before: 15 of 22 evasion variants walked past the detectors —123-45-6789was PII, the
same digits with a non-breaking space, a zero-width character, or base64'd were nothing. After: 1 of
22, with 0 false positives on a deliberately awkward benign corpus (order ids, ISO dates, UUIDs,
SHA-256 digests, semver, phone numbers). - The login lockout was a remote denial of service. It keyed on username alone, so five bad
adminlogins locked out the real admin — the one person who would respond to the attack. Now keyed
on(username, source IP), reusing_client_iprather than the caller-writableX-Forwarded-For
(keying on that header would trade a lockout DoS for a throttle bypass). A per-username counter is
kept at a 10× ceiling so a distributed attempt is still bounded. - Session revocation failed open, unbounded.
is_revokedreturnedFalseon any Redis error, so
a logged-out token kept working for as long as the store was unreachable. It is now time-boxed to a
30-second grace window (_REVOCATION_GRACE_S), which covers the restart/failover case/readyz
already drains for; beyond it, it refuses. A successful read clears the clock. A password change now
also revokes the presented token — sessions on other devices need a per-user token epoch and are not
claimed here. - A PEP's own refusal now reaches the evaluate contract. Gate A, schema conformance and the
tool-header guard all refuse a call before any policy runs, so none reached the control plane:
measured live, three Gate-A denials produced zero audit rows.pep_decision/pep_rule_id/
pep_reasonare tighten-only three independent ways ...