Policy cookbook
Ready-to-adapt NrvqPolicy recipes for the situations teams actually hit, from “I just deployed an
agent and want visibility” to “this integration is read-only and I need it to stay that way”. Each
recipe says when to use it, what to fill in, and what decision it produces.
The mechanics behind them — scope keys, priority resolution, the policy contract, the validator, dry-run and rollback — are in Writing policies. A full end-to-end walkthrough is the worked example. This page is the parts list.
Every Rego module on this page was compiled with the same opa --v0-compatible the engine forks and
run through the same validate_rego_source the API applies. Where a snippet’s behaviour is stated,
it was evaluated, not assumed.
Not a Rego author? Start with the console writes it for you and stop there if it covers your case.
Start here: the console writes the Rego for you
Section titled “Start here: the console writes the Rego for you”Most teams never need to hand-author Rego. Security Operations → Policy Catalog has two entry
points, and they are not the same tool.
Visual Builder — three numbered steps
Section titled “Visual Builder — three numbered steps”Policy Catalog → Visual Builder. Later steps stay dimmed until the earlier one is valid, so there is exactly one place to start:
- Who is this policy for? — three selectable tier cards: Agent class, Namespace, Workload. This is the only builder that writes all three scope keys.
- What should it do? — Tighten-only rules (add blocks/escalates/audits on top of what is already allowed) or Allowlist (deny by default) (nothing runs unless it is listed). Rule decisions are block, escalate or audit.
- Check & enforce — Run dry-run, then Save & enforce. The compiled Rego is shown above the step the whole time; it is an output panel, not a step, so it never dims.
The key each tier writes is what the engine actually looks up:
| Tier card | Loader key written | Applies to |
|---|---|---|
| Agent class | <class> |
every agent of that class in the namespace |
| Namespace | namespace:<ns> |
every agent in the namespace, whatever its class |
| Workload | deployment:<name> |
agents of that Deployment only |
Deployments only for the workload tier. The evaluator builds exactly one workload key,
<ns>:deployment:<name>, so a StatefulSet or DaemonSet target would be created, reported healthy,
and never decide anything. The CRD’s target.kind enum now rejects everything but Deployment at
admission for that reason.
The guided “Configure Policy” sheet is agent-class only
Section titled “The guided “Configure Policy” sheet is agent-class only”The simpler sheet (target, enforcement mode, Block keywords, Generated YAML) is a fast path
for one shape of policy: a keyword-block rule for a brand-new agent-class policy. Its Workload
and Namespace pills are hard-disabled and never enable — the guided rego matches on
input.agent.agent_class, so it could not match either tier. The sheet says so: “Guided mode targets
agent classes. Use New policy (raw rego) to scope a policy to a workload or a namespace.”
Two more things about that sheet are worth knowing before you rely on it:
- Block keywords only apply when creating a brand-new class policy. For an existing policy the box is replaced with an explanation, because Apply there changes the enforcement mode and reloads the saved policy — it does not rewrite the rule set.
- The Generated YAML panel is a read-only preview in the sheet’s own shorthand
(
targetType/target/enforcement/keywords). It is not theNrvqPolicyschema — for a manifest you cankubectl apply, use the recipes below.
Hand-editing detaches a builder policy
Section titled “Hand-editing detaches a builder policy”The compiled Rego carries its own graph, base64-encoded in a # nrvq-builder-graph/v1: header
comment, plus an FNV-1a hash of the body in # nrvq-builder-hash:. That is how reopening a policy
rebuilds the exact form you filled in. Edit the Rego by hand and the hash stops matching, so the
Catalog marks it detached — “Hand-edited — detached from its visual graph” — and reopening it in
the builder would no longer reconstruct what is live. There is deliberately no round-trip from
arbitrary Rego back into a form.
Where the namespace-wide knobs actually live
Section titled “Where the namespace-wide knobs actually live”Two settings are deliberately not per-policy, and they are not in the same place:
| Setting | Where | Notes |
|---|---|---|
| Trust threshold, rate limit | Settings → General → Tuning defaults |
namespace-wide; PUT /api/v1/settings?namespace=<ns> |
| Enforcement posture (Block / Monitor) | Security Operations → Target Settings |
namespace-wide monitor mode |
Change control (enforce / dry_run_only) |
Security Operations → Target Settings |
a policy-edit lock, not a traffic mode |
dry_run_only is server-enforced, not a console nicety: POST /policies, POST /policies/{ns}/{class}/apply, PUT /baseline/controls and pack enable/disable all return 409
for that namespace. Dry-run and draft saves stay allowed.
Check it before you enforce it
Section titled “Check it before you enforce it”Security Operations → Policy Tester simulates a tool call against your active policies without an
LLM and without a running agent. Choose a tool, paste params as JSON, pick an agent class and
namespace, set a trust score and chain depth, press Evaluate. It returns the decision, the trust
change, and which signals fired — and it names the cases where the decision did not come from an
authored rule (trust_frozen, escalate_low_trust, no_policy_loaded), which is the difference
between “my policy worked” and “this agent is frozen”.
It costs nothing to run repeatedly. It is the cheapest way to find out a policy does not do what you meant.
Before you write anything: five questions
Section titled “Before you write anything: five questions”Every policy is the answer to these. Collect them first and the YAML writes itself.
-
Where does the agent run? The Kubernetes namespace — the policy’s
metadata.namespace, and (for sidecar injection) the namespace carrying thenorviq-injection=enabledlabel. -
Who is the policy for?
spec.target, exactly one of:agentClass: <class>— every agent whose pod is labelednorviq.io/agent-class: <class>,namespace: <ns>— everything in a namespace (a floor), orkind: Deployment+name: <workload>— one specific workload.Deploymentis the only acceptedkind.
The CRD requires
agentClass, ornamespace, or bothkindandname. -
Which tools does it call, and what do the params look like? Do not guess — read the real traffic first: the console’s audit log, or
norviq audit list -n <ns>/norviq audit top-blockedfrom the CLI. Your Rego matchesinput.tool_nameandinput.tool_params.<field>, so the exact names matter. -
What should a violation do?
block,audit, orescalate.escalateholds the call — the SDK’sis_allowed()returns true only forallowandaudit, so an escalated call does not run while it awaits review. It fails closed. -
Where does it sit in the stack?
priority,0–499for namespace-scoped policies. Highest priority wins among base tiers; ties break toward the most restrictive decision. See choosing a priority.
Recipe 1 — Observe first: a namespace baseline in audit mode
Section titled “Recipe 1 — Observe first: a namespace baseline in audit mode”Use when you have just onboarded a namespace and want visibility before you block anything. This
is the shipped crds/examples/policy-namespace-baseline.yaml verbatim: a whole-namespace floor at low
priority, so anything more specific you add later automatically outranks it.
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: prod-baseline namespace: chatbot-prod # ← your namespacespec: target: namespace: chatbot-prod # ← same namespace: applies to every agent in it enforcementMode: audit preset: permissive priority: 50 # low on purpose — class policies outrank itFill in: the namespace (twice).
What it does: stored at loader key chatbot-prod:namespace:chatbot-prod. The permissive preset
is short enough to read in full — it allows by default and escalates only when
input.trust_score < 0.4. Every call still lands in the audit log, which is where the visibility
comes from.
enforcementMode: audit does two things now: it softens that low-trust escalate to an audit
decision (prefix policy_audit_would_block:permissive_low_trust_escalate), and it takes this layer
out of the priority race so it cannot disarm anything that is enforcing.
Recipe 2 — The standard production guard: strict preset on an agent class
Section titled “Recipe 2 — The standard production guard: strict preset on an agent class”Use when an agent class handles untrusted input (customer chat, inbound email) and you want the
full content ruleset enforcing. This is crds/examples/policy-strict-chatbot.yaml:
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: chatbot-strict namespace: chatbot-prod # ← your namespacespec: target: agentClass: customer-support # ← your NrvqClass name / pod label value enforcementMode: block preset: strict priority: 200Fill in: namespace + agent class.
What it does: the strict preset is comprehensive.rego inlined. It blocks execute_sql and the
delete_/drop_/truncate_/destroy_/wipe_/purge_/erase_ name prefixes outright, plus
prompt injection, SQL and shell injection, PII (SSN), PCI (Luhn-valid PAN), secret egress, dangerous
URL schemes, SSRF to metadata/loopback, cross-tenant params, base64-decoded threats and a
tool-chain-depth cap. Several rules can fire on one call; the resolver picks
block > escalate > audit > allow and attributes it to the lowest sorted rule_id.
Check first: if your agent legitimately calls a tool strict blocks (a real SQL tool), do not
fight the preset — use an allowlist or a custom policy instead, and dry-run either way.
Recipe 3 — Middle ground: escalate the risky, block the destructive
Section titled “Recipe 3 — Middle ground: escalate the risky, block the destructive”Use when an agent needs a powerful tool sometimes and you want a human in the loop rather than a
hard no. This is crds/examples/policy-moderate-analyst.yaml:
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: analyst-moderate namespace: analytics # ← your namespacespec: target: agentClass: data-analyst # ← your agent class enforcementMode: block # set to `audit` to trial this policy without interrupting anything preset: moderate # ADVISORY ONLY — accepted, stored, and read by nothing. See the caution below. rules: - llm01_prompt_injection - llm06_excessive_agency - deny_sql_injection priority: 150Fill in: namespace + agent class.
What it does: the whole moderate preset is four rules. It escalates any call where
input.tool_name == "execute_sql" (held for review, not run), and blocks any call whose
input.tool_params.query contains drop — note that block keys on the query param on any tool,
not only execute_sql. Everything else allows.
To trial it, set enforcementMode: audit on this same CR. You will see
policy_audit_would_block:moderate_escalate and policy_audit_would_block:moderate_drop_block in the
audit log while nothing is interrupted. You no longer need to put the whole namespace in monitor mode
to do this.
Recipe 4 — Deny-by-default tool allowlist (the perimeter)
Section titled “Recipe 4 — Deny-by-default tool allowlist (the perimeter)”Use when you know exactly what an agent is supposed to do. This is the strongest posture available, because a tool nobody has seen yet is denied for not being listed — no classifier, no matching deny rule required.
This is the shipped policies/templates/tool-allowlist-perimeter.rego, adapted:
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: support-perimeter namespace: chatbot-prod # ← your namespacespec: target: agentClass: customer-support # ← your agent class enforcementMode: block rego: | package norviq.custom.support_perimeter
default decision = "block" default rule_id = "support_perimeter_denied" default reason = "tool is not on this agent class's approved list"
# ← your agent's real tools, taken from the audit log allowed_tools = {"search_kb", "get_order", "create_ticket"}
decision = "allow" { allowed_tools[input.tool_name] }
rule_id = "support_perimeter_allowed" { allowed_tools[input.tool_name] } reason = "registered tool for the customer-support class" { allowed_tools[input.tool_name] }
# Not a second gate — a better AUDIT LABEL for what got denied. An unlisted call whose purpose # the classifier could not determine is what probing with novel names looks like. rule_id = "support_perimeter_unclassified" { not allowed_tools[input.tool_name] input.derived.verb == "unknown" } reason = "unlisted AND unclassified tool — the call's purpose could not be determined" { not allowed_tools[input.tool_name] input.derived.verb == "unknown" } priority: 250Evaluated:
get_order (derived.verb=read) → allow support_perimeter_allowedacme_widget (derived.verb=unknown) → block support_perimeter_unclassifiedFill in: namespace, agent class, and allowed_tools. Take the names from the audit log, not from
memory — one typo denies a legitimate tool.
How to fill it in safely: run the class in monitor mode first, let the console show what it actually calls, promote the legitimate names here, and dry-run until the would-block list is empty. A non-empty list means the allowlist is not finished.
default decision = "block" is the strongest possible resolver and the validator recognises it as
one: decision is bound on every input, so the silent-allow failure mode (a decision = "block"
rule whose condition never matches, leaving decision undefined, which the engine reads as allow) is
structurally impossible here. No decision = "block" { ... } rule is needed alongside it.
Recipe 5 — Allowlist the SQL, not the tool name
Section titled “Recipe 5 — Allowlist the SQL, not the tool name”Use when an agent genuinely needs SQL but only ever runs a fixed set of statements. Blocking the
tool name is too coarse and matching tool_params.query is too fragile — a rename to run_report or
a param rename from query to sql defeats it.
input.derived exists for exactly this. This is the shipped
policies/templates/sql-allowlist-deny-by-default.rego:
package norviq.custom.sql_allowlist
default decision = "block"default rule_id = "sql_allowlist_default_deny"default reason = "query is not on the approved allowlist"
allowed_sql = { "select * from orders", "select id, status from shipments",}
# Any statement carried by this call that is NOT approved.unapproved_statement { s := input.derived.sql_statements[_] not allowed_sql[s]}
# Non-SQL tools are outside this policy's scope — fall through to the platform baseline.decision = "allow" { input.derived.tool_kind != "sql" }
# A SQL tool is allowed only when it carries at least one statement and NONE are unapproved.decision = "allow" { input.derived.tool_kind == "sql" count(input.derived.sql_statements) > 0 not unapproved_statement}Evaluated:
run_report {"sql":"SELECT * FROM orders;"} → allowrun_report {"sql":"SELECT * FROM orders; DROP TABLE users"} → blocksearch_kb {"q":"x"} → allow (not a SQL tool)derived.sql_normalized and derived.sql_statements are case-folded, whitespace-collapsed and
trailing-semicolon-stripped by the engine, so an allowlist entry written in lower case matches the
statement however it was typed. sql_statements splits stacked statements, so the allowlist can
require all of them to be approved rather than only the first. derived.tool_kind is sql or
other, resolved by name and alias, so a renamed SQL tool is still a SQL tool here.
Fill in: the allowed_sql set. Copy the normalised form out of a dry-run rather than typing it —
that is the exact string the policy will compare against.
Recipe 6 — Scope the destination, not the payload
Section titled “Recipe 6 — Scope the destination, not the payload”Use when the risk is where data goes, not what it looks like. This is the gap Recipe 2 leaves open: a detector list can never enumerate every sensitive thing, but a destination allowlist does not have to.
input.derived.destinations is extracted once by the engine, so no policy re-implements URL and email
parsing:
package norviq.custom.egress_scope
default decision = "allow"default rule_id = "default_allow"default reason = "Allowed"
approved_recipient_domains = {"acme.com", "support.acme.com"} # ← yours
is_egress { input.derived.verb == "send" }
blocks["egress_off_domain_recipient"] { is_egress d := input.derived.destinations.recipient_domains[_] not approved_recipient_domains[d]}blocks["egress_carries_secret"] { is_egress; input.derived.data_classes[_] == "secret" }blocks["egress_carries_card_data"] { is_egress; input.derived.data_classes[_] == "pci" }# A caller-minted argument name that collides with a derived path means the recipient this policy# read is not necessarily the one the tool received. Refuse rather than trust it.blocks["egress_ambiguous_path"] { is_egress; input.derived.param_paths_ambiguous[_] }
escalates["egress_no_recipient_derived"] { is_egress count(input.derived.destinations.recipient_domains) == 0}
audits["reserved"] { false }
reasons = { "egress_off_domain_recipient": "recipient is outside the approved domains", "egress_carries_secret": "this call would send a credential to an egress tool", "egress_carries_card_data": "this call would send card data to an egress tool", "egress_ambiguous_path": "a caller-supplied argument name collided with a derived path", "egress_no_recipient_derived": "an egress call with no recipient this policy can read", "default_allow": "Allowed",}
# ── canonical resolver: keep as-is ─────────────────────────────block_fired { blocks[_] }escalate_fired { escalates[_] }audit_fired { audits[_] }
decision = "block" { block_fired }decision = "escalate" { escalate_fired; not block_fired }decision = "audit" { audit_fired; not block_fired; not escalate_fired }
rule_id = sort([id | blocks[id]])[0] { block_fired }rule_id = sort([id | escalates[id]])[0] { escalate_fired; not block_fired }rule_id = sort([id | audits[id]])[0] { audit_fired; not block_fired; not escalate_fired }
reason = reasons[rule_id]Evaluated:
verb=send, recipient_domains=["attacker.example"] → block egress_off_domain_recipientverb=send, recipient_domains=["acme.com"] → allowverb=send, data_classes=["secret"] → block egress_carries_secretverb=read → allowWhy recipient_domains and not emails: param_values and destinations.emails discard the key
that held each value, which is right for a detector (“is a secret anywhere in this call”) and wrong
for a scope (“the recipient must be @acme.com”). recipient_domains is key-aware — it reads the
address out of a destination-shaped argument, so a secret quoted in the body is not mistaken for a
recipient.
Why param_paths_ambiguous is a block and not a footnote: argument names come from the caller and
the path grammar uses . and [i] as structure, so a caller-supplied key can mint a path that
looks identical to a genuinely nested one. The engine names those paths rather than silently
publishing them, and a policy that scopes on a path must refuse to hold over one it cannot trust.
Deriving nothing and deriving a lie must not be spelled the same way.
Recipe 7 — Gate on what the call does, not what it is called
Section titled “Recipe 7 — Gate on what the call does, not what it is called”Use when you want to express intent inside a system you already govern: “this agent may READ from the vector store, nothing else”. A tool-name allowlist is brittle here in the dangerous direction — under deny-by-default a missed alias does not leak, it locks out legitimate traffic, and that is what gets a policy switched off in week one.
This is the shipped policies/templates/read-only-intent-deny-by-default.rego:
package norviq.custom.vector_read_only
default decision = "block"default rule_id = "vector_read_only"default reason = "this agent class is permitted read operations only"
# Which system this policy governs. Widen or remove as needed.scoped_source { startswith(lower(input.tool_name), "milvus_") }
# Out of scope -> fall through to the platform baseline rather than being swept up by the default deny.decision = "allow" { not scoped_source }
decision = "allow" { scoped_source input.derived.verb == "read"}
# `unknown` is a FIRST-CLASS value, not a hidden default. State what happens to it.decision = "escalate" { scoped_source input.derived.verb == "unknown"}rule_id = "vector_read_only_unclassified" { scoped_source; input.derived.verb == "unknown" }reason = "unclassified tool on a read-only source — needs human review" { scoped_source input.derived.verb == "unknown"}input.derived.verb is read | write | delete | send | unknown, classified by the engine from the
tool name (falling back to the arguments when the name resolves to nothing). Evaluated against the
shipped classifier:
milvus_search → read allowmilvus_hybrid_search → read allow (an alias a name list would have missed)milvus_delete → delete blockmilvus_zzz_obscure → unknown escalateRecipe 8 — Block one dangerous pattern on one workload
Section titled “Recipe 8 — Block one dangerous pattern on one workload”Use when a single deployment needs one targeted rule and you do not want to touch its class
policy. This is crds/examples/policy-custom-rego.yaml verbatim:
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: custom-sql-guard namespace: chatbot-prod # ← your namespacespec: target: kind: Deployment name: smartsales-agent # ← the workload's name 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: 300Fill in: namespace, workload name, the tool/param condition.
Note the shape: a violation helper, a default decision, both decision branches, and a
rule_id/reason pair for each. The validator rejects a module that could leave decision
undefined.
The workload tier only fires when the caller identifies its workload — it is never guessed. The
policy is stored at chatbot-prod:deployment:smartsales-agent and the evaluator only adds that
candidate when the agent identity carries the workload.
Recipe 9 — Require human approval for one tool
Section titled “Recipe 9 — Require human approval for one tool”Use when a tool is legitimate but consequential — refunds, wire transfers, account deletion — and policy should not auto-decide either way.
apiVersion: norviq.io/v1alpha1kind: NrvqPolicymetadata: name: refund-approval namespace: chatbot-prod # ← your namespacespec: target: agentClass: customer-support # ← your agent class enforcementMode: block # `escalate` here would be identical — the Rego decides rego: | package norviq.custom.refund_cap
default decision = "allow" default rule_id = "default_allow" default reason = "Allowed"
refund { input.tool_name == "issue_refund" } # ← your sensitive tool over_cap { refund; to_number(input.tool_params.amount) > 500 } no_amount { refund; not input.tool_params.amount } # ← the fail-closed half
escalate_now { over_cap } escalate_now { no_amount }
decision = "escalate" { escalate_now } rule_id = "refund_needs_approval" { escalate_now } reason = "Refunds over 500, or with no stated amount, require approval" { escalate_now } priority: 260Evaluated:
issue_refund {"amount": 50} → allowissue_refund {"amount": 900} → escalateissue_refund {} → escalate ← the no_amount ruleFill in: namespace, agent class, tool name, threshold.
The no_amount rule is the point of this recipe. The single most common Rego mistake is a condition
on a parameter that is absent: to_number(input.tool_params.amount) > 500 is undefined
when amount is missing, so a call with no amount sails through the rule you thought was guarding it.
If a threshold check matters, always pair it with an explicit missing-param branch — or escalate the
whole tool and skip the threshold.
Setting enforcementMode: escalate on the CR does not produce this behaviour. Only the Rego
decides; escalate in the mode field behaves exactly like block.
Recipe 10 — An MCP integration guardrail
Section titled “Recipe 10 — An MCP integration guardrail”Use when agents in a namespace reach MCP servers through the Norviq MCP proxy and you want the Gate-A facts — pin status, scan verdict, which server, which plane — expressed as policy you can vary per class, attribute in the audit log, and change without redeploying the proxy.
This is a tighten-only overlay, not a class policy: load it at the reserved __guardrail__ key,
where it sits on top of every class policy in the namespace and can only make a decision stricter.
Save the module below as mcp-guardrail.rego, then load it. jq -Rs does the JSON string escaping,
so you never hand-escape newlines:
jq -n --rawfile rego ./mcp-guardrail.rego \ '{namespace:"chatbot-prod", agent_class:"__guardrail__", enforcement_mode:"block", priority:400, saved_by:"platform-team", rego_source:$rego}' \| curl -s -X POST "$NRVQ_API_URL/api/v1/policies" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" \ -H "Content-Type: application/json" -d @-package norviq.guardrail.mcp_integration
default decision = "allow"default rule_id = "default_allow"default reason = "Allowed"
# ← EDIT: every MCP server this namespace may reach at all. Ids are the proxy's --server-id.known_servers = {"postgres-prod", "mailer", "reporting-kb"}# ← EDIT: the subset you may WRITE through. Everything else is read-only.writable_servers = {"postgres-prod", "mailer"}blocking_severity = {"high", "critical"}
# Only govern calls that actually arrived over MCP — a non-MCP caller carries no input.mcp,# and this guardrail must not change its decision. That is what keeps it additive.is_mcp { input.mcp.server }
blocks["mcp_unregistered_server"] { is_mcp; not known_servers[input.mcp.server] }blocks["mcp_tool_not_approved"] { is_mcp; input.mcp.pin_status == "quarantined" }blocks["mcp_definition_flagged"] { is_mcp; blocking_severity[input.mcp.scan_severity] }blocks["mcp_unapproved_write_server"] { is_mcp not writable_servers[input.mcp.server] input.derived.verb != "read" input.derived.verb != "unknown"}# The ANSWER plane: a server may reply asking the CLIENT for more input. A credential is never# a valid answer, whatever the server claims to need it for.blocks["mcp_answer_carries_secret"] { input.direction == "answer" input.derived.data_classes[_] == "secret"}
escalates["mcp_definition_drift"] { is_mcp; input.mcp.pin_status == "drift" }escalates["mcp_definition_never_scanned"] { is_mcp; not input.mcp.definition_seen }escalates["mcp_unclassified_tool"] { is_mcp; input.derived.verb == "unknown" }
audits["reserved"] { false }
reasons = { "mcp_unregistered_server": "this MCP server is not registered for this namespace", "mcp_tool_not_approved": "this MCP tool definition has not been approved", "mcp_definition_flagged": "this MCP tool definition matched an instruction-injection pattern", "mcp_unapproved_write_server": "writes are not permitted through this MCP integration", "mcp_answer_carries_secret": "a credential may not be sent back to an MCP server", "mcp_definition_drift": "this MCP tool definition changed after it was approved (possible rug pull)", "mcp_definition_never_scanned": "this MCP tool definition was never inspected by Gate A", "mcp_unclassified_tool": "the purpose of this MCP tool could not be determined — human review", "default_allow": "Allowed",}
# ── canonical resolver: keep as-is ─────────────────────────────block_fired { blocks[_] }escalate_fired { escalates[_] }audit_fired { audits[_] }
decision = "block" { block_fired }decision = "escalate" { escalate_fired; not block_fired }decision = "audit" { audit_fired; not block_fired; not escalate_fired }
rule_id = sort([id | blocks[id]])[0] { block_fired }rule_id = sort([id | escalates[id]])[0] { escalate_fired; not block_fired }rule_id = sort([id | audits[id]])[0] { audit_fired; not block_fired; not escalate_fired }
reason = reasons[rule_id]Evaluated:
server=reporting-kb, pin_status=drift, verb=read → escalate mcp_definition_driftserver=rogue-kb, pin_status=drift, scan_severity=high → block mcp_definition_flaggednon-MCP caller (input.mcp = {}) → allowFill in: known_servers and writable_servers. The ids are the --server-id the proxy was
started with; they also key the definition pins.
Three things about this recipe are load-bearing:
- The unregistered-server rule is separate from the write rule. A rogue server’s tool can look
perfectly ordinary — clean description, so the scanner says
none; first sight, so trust-on-first-use pins it — and a read through it is exempted by the write rule by design. Reads from an unregistered server are only governed becauseknown_serversis its own set. scan_severityis"unknown", not"none", when Gate A never looked.noneis what a definition that was scanned and came back clean carries. Any allow rule guarded only byscan_severity in ["none","low"]would be satisfied by a tool nobody inspected.unknownis outside the severity vocabulary, so it matches no allow list and no high/critical block — it fails closed, and themcp_definition_never_scannedescalate above is how you handle it deliberately.input.mcpis PEP-reported, exactly likeinput.tool_name. It is a policy input and never a trust input. Identity comes from the attested SVID. Do not useinput.mcp.serverto decide who is calling — only what they are calling.
The full annotated version is policies/templates/mcp_integration_guardrail.rego.
Recipe 11 — Namespace-wide guardrails and sector packs (no YAML)
Section titled “Recipe 11 — Namespace-wide guardrails and sector packs (no YAML)”Two policy layers are materialized through the API rather than authored as NrvqPolicy objects,
and both are tighten-only overlays — they add restrictions on top of the recipes above and never relax
one (see Writing policies §1):
-
Sector packs — eight curated rulesets under
policies/sector/. The pack IDs are not the sector words: the Sector pack reference below lists every ID with what it enforces, the shared rules it composes, its compliance mappings and what you can tune — or list them live withGET /api/v1/policy-packs. Enabling or disabling a pack is admin-only and the namespace goes in a JSON body, not a query parameter:Terminal window curl -s -X POST "$NRVQ_API_URL/api/v1/policy-packs/finance-money-movement/enable" \-H "Authorization: Bearer $NRVQ_API_TOKEN" \-H "Content-Type: application/json" \-d '{"namespace": "finance-prod"}'{"namespace":"finance-prod","pack_id":"finance-money-movement","enabled":true,"enabled_packs":["finance-money-movement"]}Enabling recompiles the namespace’s whole enabled set into a single
(namespace, __pack__)policy and invalidates the namespace’s evaluation cache, so it takes effect on the next call. -
Guardrail overlay (
__guardrail__) — an opt-in per-namespace overlay created via the ordinaryPOST /api/v1/policiesendpoint against the__guardrail__agent-class key (Recipe 10).
Compliance-gap remediation overlays (<class>__remediation__) are generated from the console’s
compliance dashboard, not written by hand.
The console equivalent is Security Operations → Policy Packs. It acts on the global namespace
selector, so set that to a concrete namespace first — with it on All namespaces the page says
“Showing: all — a pack is enabled per namespace, so pick one to see which are on” and every card
reads — per namespace instead of Enabled/Off. Each pack card shows what it enforces in plain
English, its control categories, its compliance mappings, the canonical rules it composes, and a
View rego link.
Sector pack reference
Section titled “Sector pack reference”Every bundled pack and what it enforces. All of it is available live from GET /api/v1/policy-packs —
that endpoint is the source of truth if this table ever lags.
Packs materialize as a single (namespace, __pack__) policy at priority 800, and they are
tighten-only regardless of that number.
| Pack ID | Sector | What it enforces | Rules |
|---|---|---|---|
finance-money-movement |
Finance | Escalates money movement over a threshold and transfers to new/unverified beneficiaries; blocks same-identity initiate+approve (segregation of duties). | wire_over_threshold_escalate, new_beneficiary_escalate, sod_violation |
healthcare-phi |
Healthcare | Escalates clinical actions (order / prescribe / modify chart) for human sign-off; blocks bulk-PHI reads over a threshold and PHI identifiers on egress tools. | clinical_action_escalate, phi_min_necessary, phi_identifier_egress |
government-cui |
Government | Escalates rights-impacting decisions (benefit deny/approve, adjudication) for human review; blocks CUI/FTI markings (SSN/tax) on egress tools. | rights_impacting_escalate, cui_fti_egress_blocked |
energy-ot |
Energy | Fail-safe OT surface: hard-blocks control commands found in the tool name or its params (breaker/relay/recloser/setpoint, modbus write_register, dnp3 direct_operate, valve/pump/tap); escalates OT-adjacent OMS/ADMS writes and any OT-surface tool that is not a clear read. |
ot_control_command_blocked, ot_adjacent_write_escalate, ot_surface_review_escalate |
telecom-cpni |
Telecom | Escalates SIM-swap / number-port without a verified strong-auth flag; blocks bulk CPNI/location reads over a threshold. | sim_swap_escalate, cpni_bulk_blocked |
erp-crm |
ERP/CRM | Escalates financial postings, payment runs, PO approvals and vendor master-data changes; blocks vendor bank-account changes, privileged transactions, SoD violations and mass exports over a threshold. | erp_financial_posting_escalate, erp_master_data_change_escalate, erp_bank_detail_change_blocked, erp_privileged_txn_blocked, erp_sod_violation, erp_mass_export_blocked |
ecommerce |
E-commerce | Escalates over-threshold refunds and account-takeover actions; blocks mass refunds, price/discount manipulation and bulk PII export. | ecom_refund_over_threshold_escalate, ecom_mass_refund_blocked, ecom_price_manipulation_blocked, ecom_bulk_pii_export_blocked, ecom_account_takeover_escalate |
media-entertainment |
Media | Blocks pre-release/embargoed content access or export and DRM/content-key access; escalates publish/distribute/takedown changes and royalty/licensing actions; blocks bulk subscriber-PII export over a threshold. | media_prerelease_blocked, media_drm_key_access_blocked, media_distribute_escalate, media_royalty_action_escalate, media_bulk_pii_export_blocked |
Enabling a pack also enables shared rules
Section titled “Enabling a pack also enables shared rules”Some packs declare a dependency on the canonical horizontal ruleset
(policies/sector/_shared/horizontal.rego), which is composed in automatically when you enable the
pack. This is deliberate, but it means a pack enforces more than its own rule list suggests:
| Pack | Also enforces |
|---|---|
finance-money-movement |
pci_card_numbers |
ecommerce |
pci_card_numbers, pii_detection |
healthcare-phi, government-cui, erp-crm, media-entertainment |
pii_detection |
energy-ot, telecom-cpni |
(none) |
The catalog reports these as composes, so GET /api/v1/policy-packs always tells you the full
effect.
Compliance mappings
Section titled “Compliance mappings”Useful when a pack has to be justified to an auditor rather than an engineer:
| Pack | Maps to |
|---|---|
finance-money-movement |
SOX §404, PCI DSS Req.7/8/10, GLBA, SR 11-7 |
healthcare-phi |
HIPAA §164.312, HIPAA §164.514(d), FDA HITL |
government-cui |
OMB M-25-21, NIST 800-53 AC-3/AU-12/SI-4, IRS Pub 1075 |
energy-ot |
NERC CIP-005, NERC CIP-007, NIST 800-82 |
telecom-cpni |
CPNI 47 CFR §64.2010(h), 47 USC §222, §64.2011 |
erp-crm |
SOX §404 ITGC, GDPR, least privilege |
ecommerce |
PCI DSS, GDPR/CCPA, SOX |
media-entertainment |
Content licensing, DMCA, GDPR/CCPA |
Customizing a pack
Section titled “Customizing a pack”The shipped packs are starter templates — the console says so on the card: “Starter templates —
tune verbs/thresholds after enabling”. Their thresholds and verb lists are ordinary constants in the
pack’s Rego; finance-money-movement, for example, hard-codes fin_threshold = 10000. A $10,000 wire
threshold is a reasonable default and almost certainly not your threshold.
You do not edit the bundled pack — an upgrade would overwrite it. Instead you author a per-namespace override, versioned with your namespace and surviving upgrades.
Step 1 — read what you are changing
Section titled “Step 1 — read what you are changing”curl -s "$NRVQ_API_URL/api/v1/policy-packs/finance-money-movement/rego" \ -H "Authorization: Bearer $NRVQ_API_TOKEN"This returns the pack’s actual Rego source, read-only. Start here: you need the real rule names and the constant you intend to replace, and guessing them produces an override that silently matches nothing.
The values worth changing are listed per pack as tunables in GET /api/v1/policy-packs:
| Pack | Tunables |
|---|---|
finance-money-movement |
fin_money_verbs, fin_threshold |
healthcare-phi |
hc_clinical_verbs, hc_phi_fields, hc_bulk_threshold |
government-cui |
gov_rights_verbs, gov_cui_fields |
energy-ot |
energy_ot_control_verbs, energy_ot_adjacent_verbs |
telecom-cpni |
tel_simswap_verbs, tel_cpni_read_verbs, tel_bulk_threshold |
erp-crm |
erp_posting_verbs, erp_bank_verbs, erp_export_threshold |
ecommerce |
ecom_refund_threshold, ecom_mass_threshold, ecom_export_threshold |
media-entertainment |
media_prerelease_terms, media_drm_terms, media_bulk_threshold |
Step 2 — write the override
Section titled “Step 2 — write the override”An override is Rego you author, applied on top of the enabled packs for one namespace. The request
field is rego_source — the same name every other policy write uses:
curl -s -X PUT "$NRVQ_API_URL/api/v1/policy-packs/override" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "namespace": "finance-prod", "rego_source": "package norviq.custom.wire_desk\n\ndefault decision = \"allow\"\ndefault rule_id = \"default_allow\"\ndefault reason = \"Allowed\"\n\nover_desk_limit { contains(lower(input.tool_name), \"wire\"); to_number(input.tool_params.amount) > 2500 }\n\ndecision = \"escalate\" { over_desk_limit }\nrule_id = \"wire_over_2500_escalate\" { over_desk_limit }\nreason = \"Wire above the desk limit — human approval required\" { over_desk_limit }\n" }'{"namespace":"finance-prod","active":true,"mode":"tighten-only"}The endpoint is admin-only, honours the dry_run_only apply gate, and validates twice: once through
validate_rego_source (the size, line and regex caps, the forbidden-builtin and cross-package reject,
and the decision-resolver shape check), and once by compiling the module against OPA with a probe
input. A module that produces no decision is refused at PUT time with a specific error rather than
silently never enforcing.
Step 3 — check it, and revert if wrong
Section titled “Step 3 — check it, and revert if wrong”# what is currently overridden for this namespacecurl -s "$NRVQ_API_URL/api/v1/policy-packs/override?namespace=finance-prod" \ -H "Authorization: Bearer $NRVQ_API_TOKEN"{"namespace":"finance-prod","rego_source":"package norviq.custom.wire_desk…","active":true, "mode":"tighten-only"}# remove it — the pack reverts to its shipped behaviour (clears both override and weaken)curl -s -X DELETE "$NRVQ_API_URL/api/v1/policy-packs/override?namespace=finance-prod" \ -H "Authorization: Bearer $NRVQ_API_TOKEN"DELETE is why this is safer than editing a pack in place: the shipped rules are always one call
away, and nothing you wrote can be lost in an upgrade. active: false and an empty rego_source mean
no override is in place.
Reads and writes are namespace-scoped — a non-admin naming another tenant’s namespace gets a 403,
since an override is that tenant’s authored policy.
What your Rego can see
Section titled “What your Rego can see”The engine hands every policy this input (built by _build_input / _derived_input in
norviq/engine/evaluator.py). These are the only fields to match on — there is no input.action and
no input.resource.
The call
Section titled “The call”| Field | What it is |
|---|---|
input.tool_name |
the tool being called, exactly as the agent sent it |
input.tool_name_normalized |
confusable-skeleton of the name — homoglyph and zero-width evasion collapsed |
input.tool_params |
the call’s parameters, as a possibly-nested object |
input.tool_params_normalized |
the params, folded the same way, for matching |
input.direction |
which plane: call (the agent acting) or answer (replying to a question the server composed). Defaults to call |
The caller
Section titled “The caller”| Field | What it is |
|---|---|
input.agent.spiffe_id / .namespace / .agent_class |
the caller’s attested workload identity |
input.trust_score / input.trust_category |
current trust score (0–1) and band |
input.session_id / input.call_depth |
session correlation and agent-to-agent chain depth |
input.derived — precomputed, so a policy does not have to re-derive it
Section titled “input.derived — precomputed, so a policy does not have to re-derive it”These exist because the engine evaluates every policy as a single self-contained module — OPA here cannot import across packages, so a hand-written policy has no way to reach the presets’ helpers. Publishing the primitives as input closes that gap.
| Field | What it is |
|---|---|
derived.verb |
read | write | delete | send | unknown — what the call does. unknown is a first-class value, never a hidden default |
derived.tool_kind |
sql | other, by name and alias, so a renamed SQL tool is still sql |
derived.param_values / param_values_lower |
every string value anywhere in tool_params, nesting included |
derived.param_paths |
dotted-path → value map (filters.ids[0]), so a rule can scope on which argument held a value |
derived.param_paths_ambiguous |
paths that cannot be trusted — a caller-minted key aliasing another route, a zero-length key, two routes disagreeing, or a value the walk only read a prefix of |
derived.param_bytes |
total UTF-8 size of the string payload — a cheap volume guard |
derived.sql_normalized |
case-folded, whitespace-collapsed, semicolon-stripped SQL, or "" |
derived.sql_statements |
stacked statements split out and each normalised |
derived.sql_tables |
tables the SQL touches |
derived.data_classes |
classes the request carries: pci, pii, secret |
derived.destinations.emails / .urls / .hosts / .schemes |
egress targets extracted once, sorted and de-duplicated for set operations |
derived.destinations.recipient_domains |
key-aware recipient domains — who a message went to, not every address in the payload |
derived.destinations.internal |
non-public hosts grouped as metadata / loopback / private. Empty groups are dropped, so test membership (internal.private[_]) rather than count(...) |
derived.verb is deliberately the only judgement published — risk level is not, because it shifts as
the registry is updated and a policy pinned to it could change behaviour on an upgrade without the
policy changing.
input.mcp — present only on calls that arrived over MCP
Section titled “input.mcp — present only on calls that arrived over MCP”Empty ({}) otherwise, so the input document is byte-identical for every non-MCP caller.
| Field | What it is |
|---|---|
mcp.server / mcp.transport / mcp.surface |
which server served this tool, over what transport, on which surface |
mcp.pin_status |
pinned | first_seen | drift | quarantined | unknown |
mcp.scan_severity |
Gate-A scan verdict — none/low/medium/high/critical, or unknown when there is no catalog entry |
mcp.definition_seen |
whether Gate A has a catalog entry for this tool at all |
mcp.catalog_stale |
whether the cached definition is stale |
mcp.schema_enforced / .schema_closed / .schema_notes |
argument-schema conformance, as a fact rather than an assumption |
mcp.tool_digest |
first 16 chars of the approved definition digest, when there is one |
Rego building blocks
Section titled “Rego building blocks”Drop-in modules for the shapes you will actually write. All of them compile under
opa --v0-compatible (the dialect the engine runs — do not write import rego.v1 or if/
contains keyword syntax) and satisfy the write-time validator.
Several rules in one policy — the partial-set skeleton
Section titled “Several rules in one policy — the partial-set skeleton”The shape comprehensive.rego, both non-trivial presets and every sector pack use. Start here
whenever a policy needs more than one rule. Multiple rules can fire on one call without a
compile-time conflict; precedence is block > escalate > audit > allow, ties broken by sorted
rule_id, and the fired rule’s own reason comes through.
package norviq.custom.support_guard
default decision = "allow"default rule_id = "default_allow"default reason = "Allowed"
blocks["no_raw_sql"] { input.derived.tool_kind == "sql" }blocks["no_secret_params"] { walk(input.tool_params, [path, _]) lower(path[count(path) - 1]) == "api_key"}escalates["large_refund"] { input.tool_name == "issue_refund" to_number(input.tool_params.amount) > 500}audits["watch_exports"] { input.tool_name == "export_data" }
reasons = { "no_raw_sql": "Raw SQL is not available to this agent", "no_secret_params": "api_key parameters are never accepted", "large_refund": "Refunds over 500 require human approval", "watch_exports": "Exports are allowed but audited", "default_allow": "Allowed",}
# ── canonical resolver: keep as-is ─────────────────────────────block_fired { blocks[_] }escalate_fired { escalates[_] }audit_fired { audits[_] }
decision = "block" { block_fired }decision = "escalate" { escalate_fired; not block_fired }decision = "audit" { audit_fired; not block_fired; not escalate_fired }
rule_id = sort([id | blocks[id]])[0] { block_fired }rule_id = sort([id | escalates[id]])[0] { escalate_fired; not block_fired }rule_id = sort([id | audits[id]])[0] { audit_fired; not block_fired; not escalate_fired }
reason = reasons[rule_id]Two reasons to prefer this over stacking complete decision = "..." rules:
- It cannot produce a complete-rule conflict. Two complete rules binding
decisionto different values on the same input is an OPA evaluation error, which the engine reports as a fail-closed engine fault — losing the real reason. The shipped MCP guardrail template avoids that with a chain ofnot quarantined; not drifted; not flaggedguards on every rule; the skeleton makes the problem disappear instead. - It is the only admissible way to write an audit-only rule. The validator demands a
blockorescalaterule; the resolver tail provides one.
One compile gotcha, confirmed against OPA: the resolver references all three sets, so a policy with
only audits[...] rules fails to compile with var blocks is unsafe. Seed the unused sets:
blocks["reserved"] { false } # defined but never firesescalates["reserved"] { false }This is safe with the “enforcement rule must be reachable” check — that check looks at the bodies of
complete decision = "block"/"escalate" rules, and the resolver’s bodies are block_fired /
escalate_fired, not false.
Block a family of tools
Section titled “Block a family of tools”Catch every delete_*/drop_*-style tool, including ones that do not exist yet, and back it with the
classifier so a rename does not help:
package norviq.custom.no_destructive_tools
destructive_prefixes = ["delete_", "drop_", "truncate_", "destroy_", "wipe_", "purge_", "erase_"]
default decision = "allow"default rule_id = "default_allow"default reason = "Allowed"
violation { startswith(lower(input.tool_name), destructive_prefixes[_]) }violation { startswith(lower(input.tool_name_normalized), destructive_prefixes[_]) }violation { input.derived.verb == "delete" }
decision = "block" { violation }rule_id = "no_destructive_tools" { violation }reason = "Destructive tool names and delete-verb calls are blocked for this agent" { violation }Matching tool_name_normalized as well as tool_name is what stops a homoglyph delete_uѕer (Cyrillic
ѕ) walking past. Adding derived.verb == "delete" catches the rename — execute_sql and
delete_record both classify as delete.
Condition on a parameter value — and fail closed when it is missing
Section titled “Condition on a parameter value — and fail closed when it is missing”Covered in Recipe 9. Restated because it is the most
common mistake on this page: a condition on input.tool_params.amount is undefined, not false,
when the param is absent, so the rule silently does not fire. Pair every threshold with an explicit
missing-param branch.
Scan every parameter, however deeply nested
Section titled “Scan every parameter, however deeply nested”walk() recurses the whole tool_params object — use it whenever the value could hide inside a
nested object or array instead of a top-level field:
package norviq.custom.no_internal_hosts
default decision = "allow"default rule_id = "default_allow"default reason = "Allowed"
internal_ref { walk(input.tool_params, [_, val]) is_string(val) contains(lower(val), "internal.corp")}# The engine already classified non-public destinations, including the alternate encodings a# regex would miss (2130706433, 0x7f000001, 127.1, ::ffff:127.0.0.1).internal_ref { input.derived.destinations.internal.private[_] }
decision = "block" { internal_ref }rule_id = "no_internal_hosts" { internal_ref }reason = "References to internal hosts are blocked for this agent class" { internal_ref }The skeleton’s no_secret_params rule shows the sibling trick: walk the key path instead of the
value (path[count(path) - 1]) to catch a sensitive key name at any depth.
Choosing a priority
Section titled “Choosing a priority”Highest priority wins among base tiers; on a tie, the most restrictive decision
(block > escalate > audit > allow) does. Tighten-only overlays are resolved separately and
combined most-restrictive-wins, so their priority is largely irrelevant.
| Range | Who | Use for |
|---|---|---|
| 1 | the controller | where a cluster baseline CR is stored — deliberately below any real policy, so it is a fallback and never an override |
| 2 | the controls compiler | the tuned baseline controls floor (__controls__) — a floor, so the number does not decide anything |
| ~50 | you | namespace baselines and floors (Recipe 1) |
| 100 | — | the CRD default when you omit priority |
| 150–300 | you | agent-class policies and targeted custom rules |
| up to 499 | you | the ceiling for namespace-scoped policies |
| 500–1000 | admin | clusterPriority — cluster baselines and control-plane policy only |
| 800 | the packs router | where a materialized sector pack lands (+5 for an override/weaken) |
Give a more specific policy a higher number than the floors beneath it — specificity alone does not win, the number does.
The band is enforced on both paths. The CRD admission schema bounds priority to 0–499 and
clusterPriority to 500–1000, and POST /api/v1/policies runs _enforce_priority_band, which
rejects (it does not silently clamp) a non-admin write outside the namespace band:
422 priority 800 is outside the namespace band (0-499); the clusterPriority band (500-1000) is reserved for cluster administrators.GET /api/v1/policies/effective?namespace=<ns>&agent_class=<class> shows the exact ordered stack the
evaluator would resolve right now, with an overlay flag per layer, so you can see which one is
winning before you guess at a number.
Validate before you enforce
Section titled “Validate before you enforce”The same loop for every recipe. Details in Writing policies §6.
# 1. Dry-run: compile the draft, then REPLAY it against this scope's real last-24h traffic.# The number that matters is how many currently-ALLOWED calls it would NEWLY block.norviq policy dry-run -f policy.rego -n chatbot-prod -c customer-supportRecords checked: 210Would block: 8Would allow: 198Recommendation: Would NEWLY block 3 of 210 recent calls (1.4%) — review the flips before deploying.# 2. Apply the CRD and confirm the controller synced it (PHASE goes Active)kubectl apply -f my-policy.yamlkubectl get nrvqpolicy -n chatbot-prod
# 3. Prove it blocks: run the adversarial suite against the class.# --agent takes the AGENT CLASS, not a SPIFFE id.norviq redteam run --namespace chatbot-prod --agent customer-supportThree things about step 1 worth knowing:
-freads raw Rego, not the CRD YAML. Pass a standalone.regofile containing just the module — the contents ofspec.rego, starting at thepackageline. Handing the whole manifest to-fsends YAML to OPA as Rego and compilation fails.- “No recent real traffic for this scope” is not an all-clear. The dry-run says so explicitly, and it distinguishes that from “the evaluator failed on all N recent calls — impact was NOT simulated; do not read this as clean.”
- A partial replay never ends on a bare all-clear. If some records could not be decided, the recommendation carries the caveat: “Simulation is PARTIAL — the evaluator failed on N further records, whose impact is unknown.”
Two ways to watch a change land without interrupting anything:
# Trial ONE policy — set enforcementMode: audit on that policy, then watch for the prefix:norviq audit list -n chatbot-prod -d audit --range 24h# → rule_id policy_audit_would_block:<the rule that would have fired>
# Silence a WHOLE namespace while a batch of changes settles:curl -s -X PUT "$NRVQ_API_URL/api/v1/settings?namespace=chatbot-prod" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \ -d '{"enforcement_mode": "audit"}'# → rule_id monitor_would_block:<rule> (flip back with {"enforcement_mode": "block"})Prefer the per-policy mode. It is scoped to the rule you are actually trialling, it cannot disarm an enforcing layer, and it does not put the rest of the namespace’s traffic through an unenforced window.