Skip to content

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.

Policy Catalog → Visual Builder. Later steps stay dimmed until the earlier one is valid, so there is exactly one place to start:

  1. Who is this policy for? — three selectable tier cards: Agent class, Namespace, Workload. This is the only builder that writes all three scope keys.
  2. 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.
  3. Check & enforceRun 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 the NrvqPolicy schema — for a manifest you can kubectl apply, use the recipes below.

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 → GeneralTuning 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.

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.

Every policy is the answer to these. Collect them first and the YAML writes itself.

  1. Where does the agent run? The Kubernetes namespace — the policy’s metadata.namespace, and (for sidecar injection) the namespace carrying the norviq-injection=enabled label.

  2. Who is the policy for? spec.target, exactly one of:

    • agentClass: <class> — every agent whose pod is labeled norviq.io/agent-class: <class>,
    • namespace: <ns> — everything in a namespace (a floor), or
    • kind: Deployment + name: <workload> — one specific workload. Deployment is the only accepted kind.

    The CRD requires agentClass, or namespace, or both kind and name.

  3. 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-blocked from the CLI. Your Rego matches input.tool_name and input.tool_params.<field>, so the exact names matter.

  4. What should a violation do? block, audit, or escalate. escalate holds the call — the SDK’s is_allowed() returns true only for allow and audit, so an escalated call does not run while it awaits review. It fails closed.

  5. Where does it sit in the stack? priority, 0–499 for 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/v1alpha1
kind: NrvqPolicy
metadata:
name: prod-baseline
namespace: chatbot-prod # ← your namespace
spec:
target:
namespace: chatbot-prod # ← same namespace: applies to every agent in it
enforcementMode: audit
preset: permissive
priority: 50 # low on purpose — class policies outrank it

Fill 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/v1alpha1
kind: NrvqPolicy
metadata:
name: chatbot-strict
namespace: chatbot-prod # ← your namespace
spec:
target:
agentClass: customer-support # ← your NrvqClass name / pod label value
enforcementMode: block
preset: strict
priority: 200

Fill 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/v1alpha1
kind: NrvqPolicy
metadata:
name: analyst-moderate
namespace: analytics # ← your namespace
spec:
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: 150

Fill 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/v1alpha1
kind: NrvqPolicy
metadata:
name: support-perimeter
namespace: chatbot-prod # ← your namespace
spec:
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: 250

Evaluated:

get_order (derived.verb=read) → allow support_perimeter_allowed
acme_widget (derived.verb=unknown) → block support_perimeter_unclassified

Fill 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;"} → allow
run_report {"sql":"SELECT * FROM orders; DROP TABLE users"} → block
search_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_recipient
verb=send, recipient_domains=["acme.com"] → allow
verb=send, data_classes=["secret"] → block egress_carries_secret
verb=read → allow

Why 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 allow
milvus_hybrid_search → read allow (an alias a name list would have missed)
milvus_delete → delete block
milvus_zzz_obscure → unknown escalate

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

Fill 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/v1alpha1
kind: NrvqPolicy
metadata:
name: refund-approval
namespace: chatbot-prod # ← your namespace
spec:
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: 260

Evaluated:

issue_refund {"amount": 50} → allow
issue_refund {"amount": 900} → escalate
issue_refund {} → escalate ← the no_amount rule

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

Terminal window
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_drift
server=rogue-kb, pin_status=drift, scan_severity=high → block mcp_definition_flagged
non-MCP caller (input.mcp = {}) → allow

Fill 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 because known_servers is its own set.
  • scan_severity is "unknown", not "none", when Gate A never looked. none is what a definition that was scanned and came back clean carries. Any allow rule guarded only by scan_severity in ["none","low"] would be satisfied by a tool nobody inspected. unknown is outside the severity vocabulary, so it matches no allow list and no high/critical block — it fails closed, and the mcp_definition_never_scanned escalate above is how you handle it deliberately.
  • input.mcp is PEP-reported, exactly like input.tool_name. It is a policy input and never a trust input. Identity comes from the attested SVID. Do not use input.mcp.server to 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 with GET /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 ordinary POST /api/v1/policies endpoint 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.

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

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.

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

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.

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

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:

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

Terminal window
# what is currently overridden for this namespace
curl -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"}
Terminal window
# 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.

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.

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

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:

  1. It cannot produce a complete-rule conflict. Two complete rules binding decision to 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 of not quarantined; not drifted; not flagged guards on every rule; the skeleton makes the problem disappear instead.
  2. It is the only admissible way to write an audit-only rule. The validator demands a block or escalate rule; 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 fires
escalates["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.

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.

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.

The same loop for every recipe. Details in Writing policies §6.

Terminal window
# 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-support
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.
Terminal window
# 2. Apply the CRD and confirm the controller synced it (PHASE goes Active)
kubectl apply -f my-policy.yaml
kubectl 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-support

Three things about step 1 worth knowing:

  • -f reads raw Rego, not the CRD YAML. Pass a standalone .rego file containing just the module — the contents of spec.rego, starting at the package line. Handing the whole manifest to -f sends 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:

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