Writing Policies
There are three ways to author a policy in Norviq, and they all end at the same place: a Rego module stored against a scope key, loaded by the engine, resolved by priority. The difference is who writes the Rego.
| Path | Where | Who writes the Rego | Use it when |
|---|---|---|---|
| Visual Policy Builder | Console → Security Operations → Policy Catalog → Visual Builder | The console compiles it in your browser from a rule graph | You want block/escalate/audit rules over detectors, keywords, tool names, trust and argument facts — and you want the compiled Rego shown to you before you save it |
| Propose from traffic | Console → Security Operations → Propose from traffic (/intents), or POST /api/v1/intents/* |
The API compiles a declarative intent into a default-deny module | You want positive security — state what an agent class is for and deny the rest — derived from what the class actually did |
| Advanced (raw Rego) | Console → Policy Catalog → Advanced (raw rego), norviq policy create -f, or a NrvqPolicy CR |
You do | The other two cannot express it |
If you have not read Concepts, do that first — it covers agent identity, the tiers and the decision model this guide assumes. For copy-paste starting points see the Policy cookbook.
1. Scope, priority, and what actually wins
Section titled “1. Scope, priority, and what actually wins”Every policy is stored against a loader key of the form <namespace>:<key>. The console, the
CLI, the API and the NrvqPolicy controller all resolve to the same three enforceable tiers
(resolve_policy_key in norviq/api/routers/policies.py, mirrored client-side by loaderKeyFor in
ui/src/lib/builderCompile.ts):
| Tier | Loader key | Matches | Default priority the builder posts |
|---|---|---|---|
| Agent class | <agent_class> |
every agent carrying that class label, in that namespace | 100 |
| Workload | deployment:<name> |
only calls whose identity carries that workload | 200 |
| Namespace | namespace:<ns> |
every call in the namespace | 50 |
deployment is the only enforceable workload kind. The CRD’s target.kind enum accepts
Deployment and nothing else, and resolve_apply_target_key rejects any other target_kind with a
422 — the evaluator builds exactly one workload key (<ns>:deployment:<name>), so a StatefulSet or
DaemonSet target would be a policy that reports healthy and decides nothing.
Priority bands
Section titled “Priority bands”priority is 0–499 for a namespace-scoped policy. 500–1000 is the admin-only clusterPriority
band. The CRD enforces this at admission (minimum: 0 / maximum: 499 on priority,
minimum: 500 / maximum: 1000 on clusterPriority) and _enforce_priority_band enforces it again
on POST /api/v1/policies, so a namespace-scoped service key that passes require_admin_or_service
still cannot write into the cluster band:
priority 800 is outside the namespace band (0-499); the clusterPriority band (500-1000)is reserved for cluster administrators.The resolution order
Section titled “The resolution order”The engine collects every candidate for a call, resolves the base tiers by priority, resolves the
overlays separately, and then combines them most-restrictive-wins (_collect_candidates /
_resolve_with_packs in norviq/engine/evaluator.py).
flowchart TB
subgraph base["Base tiers — highest priority wins"]
c["<ns>:<agent_class>"]
w["<ns>:deployment:<workload>"]
n["<ns>:namespace:<ns>"]
b["<ns>:__baseline__"]
cb["__cluster__:__baseline__"]
end
subgraph ovl["Tighten-only layers — priority is irrelevant"]
ctl["__controls__ — tuned baseline controls"]
egr["__egress__ — compiled destination rules"]
mcp["__mcp__ — MCP server registry"]
pack["__pack__ / __pack_override__ / __pack_weaken__"]
gd["__guardrail__"]
rem["<class>__remediation__"]
end
base --> R{"most restrictive of<br/>base winner vs overlay winner"}
ovl --> R
R --> D["decision"]
Two properties of this are worth stating plainly, because both were bugs before they were features:
- A tighten-only layer can never be outranked.
__controls__,__egress__and__mcp__are collected as floors, not base tiers.__controls__used to be a base tier, which meant writing a single agent-class policy at priority 100 silently switched every tuned baseline detector off for that class while Target Settings still reported it enforcing. As floors all three apply regardless of priority, and they land in the hard partition — a__pack_weaken__cannot relax them. - An
audit-mode policy can only tighten, never disarm._resolve_precedencepartitions audit-mode base layers out and re-applies them as tighten-only observers. Without that, trialling a rule the documented safe way (save it inauditmode at a high priority) beat the lower-priority policy that would actually have blocked — turning enforcement off.
GET /api/v1/policies/effective?namespace=<ns>&agent_class=<class> returns the real ordered candidate
stack, built by calling the same _collect_candidates that enforcement uses, with an overlay flag
and a human label per layer. It is the fastest way to see which layer is winning:
curl -s -H "Authorization: Bearer $NRVQ_API_TOKEN" \ "$NRVQ_API_URL/api/v1/policies/effective?namespace=chatbot-prod&agent_class=customer-support" | jq{ "namespace": "chatbot-prod", "agent_class": "customer-support", "layers": [ {"scope": "chatbot-prod:customer-support", "label": "agent-class policy", "priority": 100, "overlay": false}, {"scope": "chatbot-prod:__controls__", "label": "__controls__", "priority": 2, "overlay": true}, {"scope": "chatbot-prod:__baseline__", "label": "namespace baseline", "priority": 900, "overlay": false} ], "note": "overlay layers are tighten-only (can only make a decision stricter)"}The __baseline__ layer above is the chart’s own guard: baselineClusterPolicy renders one
NrvqPolicy per namespace in policyQuotaNamespaces at clusterPriority: 900, and the controller
re-keys a whole-namespace cluster-priority baseline to __baseline__. On a stock 0.2.5 install that
policy ships with enforcementMode: audit — it observes and records, it does not drop. Check
Configuration before assuming a fresh cluster is enforcing.
Reserved scopes
Section titled “Reserved scopes”__baseline__, __controls__, __egress__, __mcp__, __pack__, __pack_override__,
__pack_weaken__ and __guardrail__ are managed keys. Some are writable through their own routes
(baseline controls via PUT /api/v1/baseline/controls — see the
Baseline controls guide for the full detector list, packs via
POST /api/v1/policy-packs/{id}/enable); none may be rolled back through
POST /policies/{ns}/{class}/rollback, and applying to one via /apply is a 422. The __cluster__
namespace is never writable through POST /policies.
The console’s aggregate namespace=all picker is a view sentinel, not a namespace. Writing a
policy at all:<class> is refused (422) because no agent ever reports that namespace, so the policy
could never be selected — while every status surface would report it as enforcing.
2. The Visual Policy Builder
Section titled “2. The Visual Policy Builder”Open it from Policy Catalog → Visual Builder. The sheet is a numbered left rail: pick a scope tier, add rules, watch the Rego compile, dry-run it, save.
No free-form Rego enters the builder. Every field in the graph is an enum, string, number or
nested condition, and every user-supplied literal is passed through JSON.stringify before it lands
inside a Rego string literal (ui/src/lib/builderCompile.ts). A graph cannot inject Rego syntax.
Rule shape
Section titled “Rule shape”A rule is decision + rule_id + reason + an OR-of-AND condition set: an outer list of rows
(OR between rows), each row a list of conditions (AND inside a row). That is exactly how
comprehensive.rego expresses OR today — several blocks["same_id"] { … } bodies sharing one
rule_id. decision is one of block, escalate, audit. Every rule needs at least one row with
at least one condition; the compiler rejects anything less.
The condition vocabulary
Section titled “The condition vocabulary”| Condition | What it compiles to |
|---|---|
| Detector | One of five self-contained detectors extracted from comprehensive.rego: sql_injection, shell_injection, prompt_injection, pii, destructive_tool |
| Keyword | A keyword list matched against the tool name, the params, or both |
| Tool in | Tool-name membership |
| Trust below | input.trust_score under a threshold (0 < t ≤ 1) |
| Source + verb | A registry-backed capability source and verb pair |
| Param regex | regex.match on one flat input.tool_params field |
| Scalar / collection / numeric fact | The engine’s own input.derived primitives — see below |
| NOT | Negates exactly one non-not condition |
The scoping facts are the important addition, and they deliberately use the same field names and
operator names as the server-side intent schema (norviq/engine/intent/schema.py) so that a builder
graph and a declared intent describe the same policy in the same words:
- Scalar fields (
verb,tool_name,tool_kind,server,pin_status,scan_severity,sql_normalized,agent_class,namespace, and anyparam_paths.<dotted.path>) takeequals/in/matches/notMatches. - Collection fields (
data_classes,sql_tables,sql_statements,param_values,destinations.emails|urls|hosts|schemes) takesubsetOf/noneOf/anyOf/maxCount. - Numeric fields (
param_bytes,call_depth,trust_score) takemax/min.
These beat the older flat paramRegex: param_paths.<dotted> reaches a value at any nesting depth,
destinations.* is extracted by the engine from every parameter (so it cannot be dodged by moving
a URL into a differently-named field), and data_classes states “this call must not carry a
credential”, which no tool-name rule can express. Only matches and notMatches spend the regex
budget — every set operation and numeric bound is free.
Allowlist mode
Section titled “Allowlist mode”A graph has a mode: rules (the default OR-of-AND rail) or allowlist — a different policy shape
entirely, default-deny with an explicit tool allowlist plus the four coarse refinements
(readonly / egress / scope / rate, see §3). The
two cannot compose in one policy, so allowlist is a sibling mode rather than another condition type.
Budget meter
Section titled “Budget meter”The builder enforces the write gate’s caps client-side so a graph that would be rejected on save is
never offered as ready: 65536 bytes, 500 lines, 25 regex operations. The server’s own
line cap is now 650 (validate_rego_source), so the meter is the stricter of the two — deliberately,
since the shipped strict.rego preset once reached exactly the old 500-line wall and froze the
security baseline.
Live preview, provenance, and detachment
Section titled “Live preview, provenance, and detachment”The compiled Rego is shown read-only in a Monaco editor with inline compile errors. The same graph always compiles to the byte-identical Rego string, so version-history diffs mean real changes rather than dictionary ordering.
The compiled module carries two header lines: # nrvq-builder-graph/v1: <base64> (the graph itself,
so reopening reconstructs it exactly) and # nrvq-builder-hash: <8 hex> (a hash of the body). The
Policy Catalog reads them back and badges the policy:
- not-builder — no builder header; hand-written or preset Rego.
- attached — the body still hashes to the embedded value, so the graph is the source of truth.
- detached — the header is there but the body was hand-edited afterwards. The embedded graph no longer describes what is live and must not be trusted to reconstruct it.
The dry-run gate
Section titled “The dry-run gate”Save & enforce is disabled until a valid dry-run has run against the exact Rego on screen. Any graph edit recompiles, which invalidates the previous dry-run by exact string identity. If the replay measured nothing — no real recent traffic for the scope — the button stays disabled until you explicitly acknowledge that, and a fresh measurement retires the previous acknowledgement.
Save posts the compiled Rego to POST /api/v1/policies with the tier’s loader key as agent_class
and the tier’s priority (100 / 200 / 50). Note that the dry-run call sends a different
agent_class: the class tier sends its class name (which filters replayed audit records), while the
namespace and workload tiers send "" so the replay covers the whole namespace — a loader key like
namespace:default matches no audit record’s agent_class and would always replay zero rows.
3. Propose from traffic — the intent path
Section titled “3. Propose from traffic — the intent path”The console page is Security Operations → Propose from traffic (/intents). The model is
inverted from a block list: an intent states what an agent class is for, and everything it does
not state is denied.
The loop is observe → propose → dry-run → draft → apply, and it exists because deny-by-default that is switched on cold gets switched off in week one.
flowchart LR
A["POST /intents/propose<br/>candidate from real traffic"] --> B["POST /intents/compile<br/>the Rego you'd approve"]
B --> C["POST /intents/dry-run<br/>replay, with near-miss"]
C --> D["POST /intents/drafts<br/>non-enforcing, admin"]
D --> E["Policy Catalog drafts inbox<br/>Review & apply — the gated flow"]
There is no apply endpoint under /intents, deliberately. A draft is persisted to the
intent_drafts table, which the evaluator’s _collect_candidates never reads. Applying stays the
one gated Policies flow, so there is exactly one place where enforcement begins.
The intent document
Section titled “The intent document”{ "name": "support-reads", // lowercase alphanumeric/dash, <= 63 chars "class": "customer-support", // required; no control characters "call": [ // plane: call | answer | content — at least one { "id": "read-tickets", // globally unique; lands in the audit row as rule_id "match": {"verb": "read", "tool_name": {"in": ["list_tickets", "get_ticket"]}}, "require": {"data_classes": {"noneOf": ["secret"]}} } ]}Validation is strict and rejects rather than coerces — unknown keys are errors. A typo that is
silently accepted becomes a rule that silently never matches, and under deny-by-default that is an
outage nobody can debug. match and require are one conjunction at evaluation time; they are
separate keys because they read differently to a human (match selects the call, require states the
conditions under which it is permitted) and the near-miss explainer keeps the labels. server and
from are sugar for match.server.
Addressable fields and operators are the same table the builder uses
(§2), plus trust: {atLeast: low|medium|high} — an ordered category
rather than a raw score, because a policy pinned to a score changes meaning when the trust model is
retuned.
What the compiler emits
Section titled “What the compiler emits”POST /api/v1/intents/compile returns the module, its rule_ids, the per-rule predicate labels,
and a sha256. Nothing is stored. The module is package norviq.intent.<class_token> and opens:
# Deny is the absence of a matching allow rule.default decision = "block"default rule_id = "intent_no_match"default reason = "no intent rule matched this call"Two things in the generated module are load-bearing and easy to mistake for noise:
- Availability predicates. Any rule reading a version-gated
input.derivedroot (param_paths,param_paths_ambiguous,destinations,data_classes,sql_tables,param_bytes) also asserts that the engine publishes it. Collection operators compile to counted comprehensions, and a comprehension over an absent root yields the empty array — sononeOfandsubsetOfwould be vacuously satisfied on an older engine and the intent would allow the call it was written to refuse. The guard is a labelled predicate, so the near-miss names it (“data_classesis published by this engine” failed) instead of reporting a silent no-match. - Derivation guards on
param_paths.*. Everyparam_pathsoperator is AND-ed with “this path was derived, and not from a caller-minted key”. Without it, an absent path defaults to""andnotMatchesreads as satisfied — so a rule saying “bodymust not mention a password” permitted{"body": {"content": "the password is hunter2"}}, because nesting the value moved the path.
Proposing from traffic
Section titled “Proposing from traffic”curl -s -X POST -H "Authorization: Bearer $NRVQ_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"ns":"chatbot-prod","cls":"customer-support","name":"support-reads","limit":500}' \ "$NRVQ_API_URL/api/v1/intents/propose" | jqRules are grouped by (MCP server, verb) — the two facts that describe an operation without
depending on what a tool happens to be called — and each rule carries the observed tool names as a
registration perimeter, so an unseen name fails the list rather than being classified into it. A
recipient-domain constraint is only proposed when at least three observed sends agree on one domain.
Every proposal adds require: {data_classes: {noneOf: ["secret"]}}, on the reasoning that nothing in
the recorded window should have been carrying a credential — and if that breaks, it is a finding.
Three things a proposal is not: not enforcement (the output is a dict); not a substitute for review (it describes what the class did, and “did” is not “should” — if the window contains an attack it will happily encode it); and not minimal (it errs tighter, because a rule that is too tight shows up as would-block rows you can loosen, while one that is too loose shows up as nothing).
Red-team and test/probe rows are excluded from the corpus, and the 422 says so — a class whose only traffic is a red-team run has rows visible in the Audit Log, and telling the operator there is none would send them to check a working emitter.
Argument evidence
Section titled “Argument evidence”The response reports how much the traffic actually showed about arguments, in three states rather
than two — params_detail is none (nothing was captured; not “the calls had no arguments”),
keys (argument path names, no values) or masked (names plus masked values). observed_params
gives it per tool, and observed_params_truncated / params_truncated say when the list you are
looking at is incomplete. An operator shown 12 of 400 argument names who believes that is all of them
is worse off than one shown none.
Dry-run and the near miss
Section titled “Dry-run and the near miss”POST /api/v1/intents/dry-run compiles the candidate, pushes it to the shared OPA server under a
throwaway scratch package, replays the recorded calls through it, and removes it again. It never
touches the policies table. The report is built around the would-block list, each entry naming the
rule that came closest and the single clause that failed:
no intent rule matched; closest read-tickets met 3/4, failed: verb == readThat decomposition is returned structurally (closest_rule, met, predicates, failed) so the
console does not have to re-implement the compiler’s label rules in a second language.
replayed_without_values is reported separately from would_block and matters: a row that recorded
argument names but no values reconstructs to an input document with no param_paths, so every
argument-level predicate reads as unsatisfied and the call replays as a block. That is fail-closed
and therefore the right direction — but a refusal caused by what the audit log lacks is not a
refusal the candidate would make in production, and the two must not be reported as one number.
Saving a draft
Section titled “Saving a draft”POST /api/v1/intents/drafts is admin-only and writes a non-enforcing row (enforcing: false)
that expires after 14 days (fixed in intents.py; the generator drafts under /threats/* use the
chart’s config.retention.draftTtlDays, also 14). The full intent rides along in the
row’s toggles, so the console can round-trip and re-edit it rather than being left with generated
Rego it cannot map back to the sentences that produced it. The draft then appears in the Policy
Catalog’s drafts inbox, where Review & apply hands it into the editor and the normal save path
applies it.
The older Attack-Graph generators
Section titled “The older Attack-Graph generators”The Attack Graph’s Defend action uses a different, narrower generator
(generate_intent_rego / generate_capability_rego in norviq/api/threat_intent.py): a tool-name
allowlist plus four coarse refinement toggles.
readonly— the tool’s verb must be a read verb. An admin-promoted (learned) verb overrides the name heuristic in both directions.egress— the tool must not be an egress sink. This one readsinput.derived.verbbut does not obey it: a name whose leading token is a retrieval verb keeps its lead (soget_mailis not refused), unless the call carries a destination-shaped argument, and an unambiguous egress action token anywhere in the name is a sink on its own.scope— anynamespace/ns/tenantfield intool_paramsmust equal the caller’s own namespace.rate— advisory only. A stateless OPA policy cannot count calls per minute; this checksinput.call_depth <= 8as a proxy. Real rate limiting is a separate engine layer (§7).
These drafts are pushed at the namespace comprehensive-baseline priority (_baseline_priority in
threats.py), so the most-restrictive tie-break means a baseline block always beats the intent
policy’s allow — applying one can only ever add denials.
4. The advanced path — raw Rego
Section titled “4. The advanced path — raw Rego”The policy contract
Section titled “The policy contract”At evaluation time the engine queries exactly one path off your module’s (rewritten) package root —
<package>.decision — and reads rule_id and reason off the same result object. All three must
be defined for every reachable decision value.
POST /api/v1/policies, POST /policies/dry-run and PUT /policy-packs/override all run the same
validator, validate_rego_source in norviq/api/routers/policies.py, before OPA ever sees the
source. Checks run cheapest-first:
- Size / complexity caps: ≤ 65536 characters, ≤ 650 non-blank lines, ≤ 25
regex.*/re_match(calls. The regex cap is a soft abuse heuristic, not a ReDoS guard — OPA’s RE2 engine is linear-time. - Forbidden builtins and cross-package data:
http.send,opa.runtime,net.*,io.*,rego.parse_module, the builtin call formtrace(, and anydata.reference outside your own declared package — most pointedlydata.norviq.managed. Comments and string literals are stripped first, so a policy’s ownreasontext may mention these words freely; only real references are rejected. - A decision resolver must exist. Either
default decision = "block"|"escalate"(deny-by-default is a resolver, and the strongest one — it bindsdecisionon every input), or a complete ruledecision = "block" { … }, or the partial-set idiom (blocks[…]/escalates[…]/audits[…]) plus the resolver that turns a fired set into adecision. Partial sets with no resolver are rejected outright:decisionwould be undefined and the engine would read that as a silent allow. - A
default decision = "…"is required whenever the resolver is a complete rule or partial-set pair. Without one, adecision = "block" { <condition> }whose condition never matches real input produces no binding at all, and the engine’sstr(result.get("decision", "allow"))falls back to allow — a fired block turned into an invisible allow, at whatever priority the policy was pushed at. - A block/escalate rule must be reachable. If every
decision = "block"/= "escalate"body is literallyfalse, it is rejected withrego_source enforcement rule must be reachable. A fake block is worse than no block. decision,rule_idandreasonmust each appear as identifiers somewhere in the source.
The norviq.* package namespace
Section titled “The norviq.* package namespace”| Package | What it is | Where it lives |
|---|---|---|
norviq.strict |
The canonical strict baseline — the full security ruleset. Also inlined as the strict preset. |
comprehensive.rego |
norviq.presets.strict / .moderate / .permissive |
The three starter presets you select with spec.preset instead of writing Rego. |
webhook/presets/*.rego |
norviq.sector.<sector> |
A sector pack, enabled per namespace as a tighten-only overlay. | policies/sector/<sector>/*.rego |
norviq.guardrail.<name> |
A guardrail overlay template (e.g. the per-namespace tool allowlist). | policies/templates/*.rego |
norviq.intent.<class_token> |
A compiled intent (§3). | generated by norviq/engine/intent/compiler.py |
norviq.remediation.* |
Generated remediation drafts — capability-guard and compliance-control flows. | generated by threat_intent.py |
norviq.custom.<name> |
The convention for your own hand-authored policy. | your spec.rego |
norviq.managed.* |
Reserved — engine-internal. Never declare or reference it. | never authored by hand |
Isolation rewrite. At load time the engine rewrites every policy’s package declaration to a unique per-policy
norviq.managed.<sanitized-key>and queriesdata.norviq.managed.<key>.decisionagainst that (rewrite_packageinnorviq/engine/opa_client.py). The package name you write is cosmetic — it never collides and it does not change how the engine finds yourdecision.
That is exactly why data.norviq.managed is forbidden: it is the shared per-policy namespace, so a
submission reaching into it could read a different policy’s compiled rules.
A minimal working policy
Section titled “A minimal working policy”The engine builds input from the real evaluator schema — tool_name, tool_name_normalized,
tool_params, derived.*, agent.namespace, agent.agent_class, trust_score, trust_category,
call_depth, direction, and mcp.* on an MCP-proxied call (_build_input / _derived_input in
norviq/engine/evaluator.py). There is no input.action or input.resource.
This is crds/examples/policy-custom-rego.yaml (reformatted to fit here; functionally identical) —
block execute_sql when the query contains DROP, allow everything else:
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: custom-sql-guard namespace: chatbot-prodspec: target: kind: Deployment name: smartsales-agent enforcementMode: block rego: | package norviq.custom.sql_guard default decision = "allow" violation { input.tool_name == "execute_sql" contains(lower(input.tool_params.query), "drop") } decision = "block" { violation } decision = "allow" { not violation } rule_id = "custom_sql_guard" { decision == "block" } reason = "DROP statement blocked by custom policy" { decision == "block" } rule_id = "default_allow" { decision == "allow" } reason = "Allowed" { decision == "allow" } priority: 300spec.target must set agentClass, or namespace, or both kind and name (CEL-validated at
admission). Instead of rego you can set preset: strict|moderate|permissive; rego wins if both
are present.
Authoring from the CLI
Section titled “Authoring from the CLI”# validate + replay before anything is storednorviq policy dry-run -f ./sql-guard.rego -n chatbot-prod -c customer-support
# save it (creates or updates; returns the new version)norviq policy create -f ./sql-guard.rego -n chatbot-prod -c customer-support --mode blockRecords checked: 210Would block: 8Would allow: 198Recommendation: Would NEWLY block 3 of 210 recent calls (1.4%) — review the flips before deploying.5. Enforcement modes
Section titled “5. Enforcement modes”Three different knobs use similar words. They are not the same thing.
| Knob | Set where | Effect |
|---|---|---|
spec.enforcementMode (per policy) |
CRD / POST /policies / --mode |
When this policy wins, audit softens its block/escalate to an audit decision with rule_id prefixed policy_audit_would_block: |
enforcement_mode (per namespace) |
PUT /api/v1/settings?namespace=<ns> |
Namespace-wide monitor posture: any would-block or would-escalate in that namespace becomes an audit decision with rule_id prefixed monitor_would_block: |
apply_mode (per namespace) |
PUT /api/v1/settings?namespace=<ns> |
dry_run_only makes the API refuse writes for that namespace with a 409 |
Per-policy mode. block and escalate behave identically here — only audit is
special-cased, so setting escalate does not turn a policy’s blocks into escalations. Trialling one
rule this way is safe: an audit-mode layer is partitioned out of the priority race and re-applied as a
tighten-only observer (§1), so it can raise an allow to an audit but can never lower a block
another layer produced.
Namespace posture. Monitor mode is a promise that nothing gets interrupted, and it now keeps that
promise for operational blocks too — a cold replica (policy_load_pending), an OPA fault
(evaluator_error) and a malformed payload (evaluator_invalid_payload) all soften. Only
trust_frozen stays hard unconditionally; rate_limit_exceeded also stays hard by default and
softens if you set NRVQ_MONITOR_EXEMPT_RATE_LIMIT=false (an engine setting, not a chart value —
it is read per call, so flipping it takes effect without a restart).
Apply mode. apply_mode: dry_run_only is server-enforced, not a console nicety, and it is broader
than its name suggests — assert_apply_allowed gates POST /policies (create), POST /policies/{ns}/{class}/apply, PUT /baseline/controls, and pack enable/disable:
409 namespace 'payments-prod' is in dry-run-only mode — policy applies are disabled (dry-run and draft saves are still allowed). An admin can re-enable enforcement in Settings.The safe-rollout loop:
- Author (builder, intent, or raw Rego).
- Dry-run it (§6) — the replay tells you how many currently-allowed calls it would newly block.
- Optionally save it with
enforcementMode: auditand watch the audit log forpolicy_audit_would_block:*, or put the whole namespace in monitor mode and watch formonitor_would_block:*. - Save with
enforcementMode: block, thennorviq policy apply(or the console’s Apply flow) if you need it on a second scope.
6. Dry run
Section titled “6. Dry run”POST /api/v1/policies/dry-run compiles the submitted Rego, evaluates one synthetic probe input
against it, and then replays it against up to 500 real audit records from the last 24 hours for
the policy’s scope. Synthetic, red-team, policy-tester and e2e/probe rows are excluded in SQL,
before the cap — otherwise a namespace whose most recent 500 rows are all Policy Tester sessions would
fill the cap with rows the loop then skips and report that it replayed the namespace.
The route is a write-class capability, not a passive read: it compiles and executes arbitrary
submitted Rego on the shared OPA server, so it requires admin or service, the namespace write
scope, and the same validator create runs.
{ "valid": true, "errors": [], "sample_decision": {"decision": "allow", "rule_id": "...", "reason": "..."}, "scope": {"namespace": "chatbot-prod", "agent_class": "customer-support"}, "time_range": "last 24 hours", "recommendation": "Would NEWLY block 3 of 210 recent calls (1.4%) — review the flips before deploying.", "total_records_checked": 210, // records ACTUALLY evaluated — the denominator "records_fetched": 231, "synthetic_skipped": 21, "eval_errors": 0, // non-zero means the simulation is PARTIAL "would_block": 8, "would_allow": 198, "would_escalate": 4, "newly_blocked": 3, "newly_allowed": 0, "newly_blocked_samples": [{"tool_name": "send_email", "was": "allow", "now": "block", "rule_id": "llm02_data_leakage"}], "block_rate_pct": 3.81, "truncated": false, "replay_cap": 500, "no_replayable_traffic": false, "params_captured": false, "advisory": "Replayed records carry empty tool_params because audit_capture_masked_params is off (the default). Tool-name and scope rules are exercised correctly; a rule matching on CONTENT cannot fire here. Enable audit_capture_masked_params for a content-matching dry-run."}newly_blocked / newly_allowed are decision flips relative to what actually happened — the
numbers that matter before you apply. total_records_checked counts records the candidate was
actually evaluated against; a fetched-but-skipped or errored row is not in it, so an empty replay
cannot manufacture an all-clear. When eval_errors is non-zero the recommendation says the simulation
is partial, whatever the verdict.
Dry-run is namespace-scoped like every sibling route: a non-admin caller can only replay their own namespace’s traffic.
Red-team is the other half of validation: norviq redteam run --agent <agent_class> --namespace <namespace> (or POST /api/v1/redteam/suite, admin-only) runs the built-in adversarial catalog
against the in-process evaluator and scores pass/fail plus an efficacy roll-up. An attack tied to a
sector-pack control only counts as applicable when that pack’s enforcing rule is actually loaded for
the namespace, so you are scored against what you enabled. That is how you prove a policy blocks, not
just that it compiles. See the Red team guide for the full attack catalog and how
“proven blocking” is computed.
Recommended loop: author → dry-run replay → red-team suite → apply.
7. Apply, versions, and rollback
Section titled “7. Apply, versions, and rollback”Apply copies an already-saved policy onto a second scope. It does not accept Rego — save first.
norviq policy apply chatbot-prod customer-support \ --target-type workload --target-ns chatbot-prod --target-name smartsales-agent --mode block--target-type is agent_class (default), namespace, or workload. workload requires
--target-name — the server keys a workload policy at deployment:<name>, and without a name there
is no key; the apply used to silently fall back to a class policy while reporting success. --target-kind
accepts only deployment. Apply is admin-only, refuses reserved scopes and the __cluster__ target
namespace, and re-reads the target entry afterwards so the 200 reports the mode that was actually
persisted rather than the one requested.
Versions. Every save bumps a version. GET /policies/{ns}/{class}/versions returns each version’s
version, saved_by, saved_at and its rego_source, so the console’s Load in Editor shows
the historical source read-only rather than the current policy. The loader holds the 10 newest per
scope in memory; the database retains more (config.retention.policyVersionKeepCount 20,
policyVersionKeepDays 90).
norviq policy versions chatbot-prod customer-supportRollback restores a prior version as the enforcing one:
norviq policy rollback chatbot-prod customer-support 3POST /policies/{ns}/{class}/rollback is admin-only, is gated by require_target_cluster (you
cannot roll back a remote fleet cluster’s policy from the hub), and refuses reserved/managed scopes —
those are re-materialized from their own sources (__baseline__ from its seed, __pack__ from the
packs router), not version-rolled out of band. An unknown version returns 404
(NRVQ-REG-5004). In the console the same thing is the Restore button on the Versions tab,
behind a confirm dialog.
8. Common patterns
Section titled “8. Common patterns”The shipped baseline, comprehensive.rego (also inlined as webhook/presets/strict.rego), is the
reference implementation. It — and the sector packs under policies/sector/<sector>/*.rego — use the
partial-set + resolver idiom: blocks[id], escalates[id], audits[id] guards, a reasons map, and
the canonical resolver tail (block_fired/escalate_fired/audit_fired → decision/rule_id/
reason, precedence block > escalate > audit > allow, ties broken by sorted rule_id). Copy that
shape: it is what lets several rules fire on one call without a compile-time conflict while every
fired rule still carries a distinct, correct reason.
The snippets below are illustrative excerpts — read comprehensive.rego for the complete
implementation.
Deny SQL injection — a syntax-context check, not a bare substring match, so business prose (“please delete from my calendar”) is not hard-blocked:
sql_destructive_patterns = ["drop table", "delete from", "truncate table", "; drop", "xp_cmdshell", "union select"]sql_injection_detected { val := security_scan_texts[_] pattern := sql_destructive_patterns[_] contains(val, pattern) sql_syntax_context(val, pattern) # the value LEADS with the statement, or contains a ";" separator}blocks["deny_sql_injection"] { sql_injection_detected }Deny shell execution — unambiguous shell indicators are checked on every tool’s raw parameter
values; bare metacharacters (|, ;, a backtick) are checked only when the tool name itself looks
exec-shaped (exec, shell, bash, cmd, eval, and similar tokens). A third arm scans
base64-decoded values (iteratively to depth 4) for the same multi-byte indicators. The split is
deliberate: measured against the benign-traffic corpus, an unconditional bare-metacharacter match
false-positived on legitimate calls — a support ticket mentioning a pipe in a filename, a runbook
quoting kubectl get pods. The decode path is bounded by capping candidates per level (64), not by
a payload-size gate — an oversized or padded payload cannot skip the scan. See
Baseline controls for the full false-positive rationale and the rest of
the 21 detectors.
# Unconditional — any tool, any parameter:shell_patterns = ["rm -rf", "/etc/passwd", "/etc/shadow", "$("]blocks["deny_shell_execution"] { val := security_scan_texts_raw[_] pattern := shell_patterns[_] contains(val, pattern)}
# Name-gated — only when the tool name itself looks exec-shaped:shell_metachars = ["|", ";", "`"]blocks["deny_shell_execution"] { _exec_shaped_tool val := security_scan_texts_raw[_] pattern := shell_metachars[_] contains(val, pattern)}Secret egress — three angles: a sensitive-named key going to an egress tool, a secret-shaped value embedded in a param, and reading a secret through a dedicated secret-read tool:
external_tools = {"send_email", "post_webhook", "upload_file"}sensitive_keys = {"password", "secret", "api_key", "token", "private_key"}data_leakage_detected { external_tools[input.tool_name] walk(input.tool_params, [path, _]) k := path[count(path) - 1] sensitive_keys[lower(k)]}secret_value_patterns = [`api[_-]?key\s*[:=]`, `bearer\s+[a-z0-9._-]{12,}`, `sk-[a-z0-9]{8,}`, `-----begin [a-z ]*private key-----`]data_leakage_detected { external_tools[input.tool_name] val := security_scan_texts[_] some i regex.match(secret_value_patterns[i], val)}blocks["llm02_data_leakage"] { data_leakage_detected }PII / PCI — PII is SSN-shaped strings anywhere in tool_params (walk() recurses nested
objects and arrays); PCI is a card-number-named key at any depth, or a Luhn-valid 13–19 digit value:
pii_detected { walk(input.tool_params, [_, val]) is_string(val) regex.match(`\b\d{3}-\d{2}-\d{4}\b`, val)}pci_keys = {"cc_number", "card_number", "credit_card"}pci_field_detected { walk(input.tool_params, [path, _]) k := path[count(path) - 1] pci_keys[lower(k)]}blocks["pii_detection"] { pii_detected }blocks["pci_card_numbers"] { pci_field_detected }Cross-tenant access — a tenant_id param that does not match the caller’s home tenant
(agent.home_tenant when set, falling back to agent.namespace when it is not), a separate
namespace param that does not match agent.namespace, plus a SQL-specific check for a schema
qualifier that is not the caller’s. The fallback matters: comparing tenant_id directly against the
Kubernetes namespace flags every legitimate call from an agent that serves multiple tenants inside one
namespace, because a tenant id and a namespace name are not always the same string:
_home_tenant = t { t := object.get(input.agent, "home_tenant", "") t != ""}_home_tenant = t { object.get(input.agent, "home_tenant", "") == "" t := input.agent.namespace}
cross_tenant_detected { input.tool_params.tenant_id input.tool_params.tenant_id != _home_tenant}cross_tenant_detected { input.tool_params.namespace input.tool_params.namespace != input.agent.namespace}blocks["cross_tenant_access"] { cross_tenant_detected }Rate limits — there is no stateful per-minute counter authored in Rego; a policy evaluation is
stateless. The closest Rego-level control is a call-chain depth cap
(chain_depth_exceeded { input.call_depth >= 8 }, blocks["chain_depth_limit"]), which bounds
chained agent-to-agent recursion, not call volume. Real rate limiting is a separate engine layer keyed
off the caller’s SPIFFE ID (config.rateLimit, default 60 per window, overridable per namespace via
PUT /api/v1/settings). This is also why the intent generator’s rate toggle is advisory only.
If a pattern here matches your sector, check policies/sector/<sector>/*.rego and
policies/sector/_shared/horizontal.rego (the shared PCI/PII rules every sector pack composes) before
writing it from scratch — POST /api/v1/policy-packs/{id}/enable materializes the pack as a
tighten-only __pack__ overlay for the namespace, with a customization path (__pack_override__) if
you need to go further.