Skip to content

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 CatalogVisual 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 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 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["&lt;ns&gt;:&lt;agent_class&gt;"]
        w["&lt;ns&gt;:deployment:&lt;workload&gt;"]
        n["&lt;ns&gt;:namespace:&lt;ns&gt;"]
        b["&lt;ns&gt;:__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["&lt;class&gt;__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_precedence partitions audit-mode base layers out and re-applies them as tighten-only observers. Without that, trialling a rule the documented safe way (save it in audit mode 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:

Terminal window
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.

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

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.

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.

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 any param_paths.<dotted.path>) take equals / in / matches / notMatches.
  • Collection fields (data_classes, sql_tables, sql_statements, param_values, destinations.emails|urls|hosts|schemes) take subsetOf / noneOf / anyOf / maxCount.
  • Numeric fields (param_bytes, call_depth, trust_score) take max / 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.

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.

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.

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.

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 &amp; 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.

{
"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.

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.derived root (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 — so noneOf and subsetOf would 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_classes is published by this engine” failed) instead of reporting a silent no-match.
  • Derivation guards on param_paths.*. Every param_paths operator is AND-ed with “this path was derived, and not from a caller-minted key”. Without it, an absent path defaults to "" and notMatches reads as satisfied — so a rule saying “body must not mention a password” permitted {"body": {"content": "the password is hunter2"}}, because nesting the value moved the path.
Terminal window
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" | jq

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

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.

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 == read

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

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 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 reads input.derived.verb but does not obey it: a name whose leading token is a retrieval verb keeps its lead (so get_mail is 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 — any namespace/ns/tenant field in tool_params must equal the caller’s own namespace.
  • rateadvisory only. A stateless OPA policy cannot count calls per minute; this checks input.call_depth <= 8 as 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.

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 form trace(, and any data. reference outside your own declared package — most pointedly data.norviq.managed. Comments and string literals are stripped first, so a policy’s own reason text 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 binds decision on every input), or a complete rule decision = "block" { … }, or the partial-set idiom (blocks[…] / escalates[…] / audits[…]) plus the resolver that turns a fired set into a decision. Partial sets with no resolver are rejected outright: decision would 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, a decision = "block" { <condition> } whose condition never matches real input produces no binding at all, and the engine’s str(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 literally false, it is rejected with rego_source enforcement rule must be reachable. A fake block is worse than no block.
  • decision, rule_id and reason must each appear as identifiers somewhere in the source.
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 queries data.norviq.managed.<key>.decision against that (rewrite_package in norviq/engine/opa_client.py). The package name you write is cosmetic — it never collides and it does not change how the engine finds your decision.

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.

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/v1alpha1
kind: NrvqPolicy
metadata:
name: custom-sql-guard
namespace: chatbot-prod
spec:
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: 300

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

Terminal window
# validate + replay before anything is stored
norviq 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 block
Records checked: 210
Would block: 8
Would allow: 198
Recommendation: Would NEWLY block 3 of 210 recent calls (1.4%) — review the flips before deploying.

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:

  1. Author (builder, intent, or raw Rego).
  2. Dry-run it (§6) — the replay tells you how many currently-allowed calls it would newly block.
  3. Optionally save it with enforcementMode: audit and watch the audit log for policy_audit_would_block:*, or put the whole namespace in monitor mode and watch for monitor_would_block:*.
  4. Save with enforcementMode: block, then norviq policy apply (or the console’s Apply flow) if you need it on a second scope.

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.

Apply copies an already-saved policy onto a second scope. It does not accept Rego — save first.

Terminal window
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).

Terminal window
norviq policy versions chatbot-prod customer-support

Rollback restores a prior version as the enforcing one:

Terminal window
norviq policy rollback chatbot-prod customer-support 3

POST /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.

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_fireddecision/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.