Skip to content

Baseline controls

A preset used to be all-or-nothing. Installing the chart put every rule in strict.rego in front of every tool call in every tenant namespace, and the only way to stop a noisy detector dropping legitimate traffic was to turn the whole preset off.

Baseline controls break that apart. Each shipped detector is one row with three settings:

Setting Wire value What happens to a matching call
Off off The control is not evaluated at all.
Monitor monitor The control evaluates, the call is recorded as non-compliant, and it proceeds.
Enforce deny The control acts. For most controls that means the call is blocked — for a few it means the call is held for approval, and for one it means “recorded, call proceeds”. See What Enforce actually does.

Effects are per namespace. They are set in the console at Security Operations → Target Settings (/policies/targets), in the Baseline controls panel, or through GET/PUT /api/v1/baseline/controls.

The mechanism is deliberately small, and that is the safety argument. A preset registers each control as a one-line head on top of a named predicate:

blocks["deny_shell_execution"] { shell_injection_detected }

A control’s effect is nothing more than which set the head registers into. The compiler (norviq/api/baseline.py) rewrites only the marked CONTROLS-BEGIN/CONTROLS-END region of the preset and passes every detector predicate above it through byte-identical. Changing what a control does cannot change how it detects.


This is the part that is easy to get wrong, because two different things are called “the baseline”.

The chart renders one NrvqPolicy per namespace in policyQuotaNamespaces, naming preset: strict. The webhook controller reads strict.rego as authored out of its own image and loads it under the key <namespace>:__baseline__ at evaluation priority 1. That policy runs in baselineClusterPolicy.enforcementMode, which defaults to audit:

helm/norviq/values.yaml
baselineClusterPolicy:
enabled: true
name: baseline-cluster-guard
clusterPriority: 900
preset: strict
enforcementMode: audit # evaluate and record; the call proceeds

So a stock install observes and does not drop. Every detector in the preset runs at the severity its author wrote, and the engine softens the result to audit with a policy_audit_would_block: prefix on the rule id.

The per-control effects are a different object. They compile into a separate reserved scope, <namespace>:__controls__, and that policy does not exist until an admin saves it. Until then:

  • GET /api/v1/baseline/controls shows each control at its shipped default_effect — that is what would be written on the first save, not what is loaded right now;
  • what is actually loaded is the raw preset at whatever enforcementMode the chart rendered.

The first PUT materialises __controls__ and from then on the two coexist: the raw preset as a base tier, the compiled controls module as a tighten-only floor on top of it.

flowchart TB
    H["Helm: baselineClusterPolicy<br/>preset: strict · enforcementMode: audit"] --> W["Webhook controller<br/>reads strict.rego from its image"]
    W --> B["&lt;ns&gt;:__baseline__<br/>priority 1 · base tier"]
    C["Admin: PUT /baseline/controls"] --> K["baseline.compile(strict, effects)"]
    K --> M["&lt;ns&gt;:__controls__<br/>priority 2 · tighten-only floor<br/>enforcement_mode always block"]
    B --> E["Evaluator: most-restrictive-wins<br/>between the base winner and the floor"]
    M --> E

__controls__ is always written with enforcement_mode="block". That is not a posture decision — the compiled module already carries each control’s effect in its own heads, so softening the whole policy on top would make Enforce unreachable and collapse three effects into two.


Here is the real output of baseline.compile("strict", …) with every control at its shipped default except strict_default_block set to off and deny_shell_execution set to monitor:

# GENERATED by norviq/api/baseline.py — edit the control effects, not this region.
blocks["llm01_prompt_injection"] { injection_detected }
blocks["deny_sql_injection"] { sql_injection_detected }
blocks["llm06_excessive_agency"] { destructive_tools[input.tool_name] }
blocks["llm05_supply_chain"] { supply_chain_tools[input.tool_name] }
blocks["dangerous_scheme"] { dangerous_scheme_detected }
blocks["ssrf_metadata"] { ssrf_internal_target }
blocks["pii_detection"] { pii_detected }
blocks["pci_card_numbers"] { pci_field_detected }
blocks["pci_card_numbers"] { pci_value_detected }
blocks["cross_tenant_access"] { cross_tenant_detected }
blocks["chain_depth_limit"] { chain_depth_exceeded }
blocks["deny_sql_multi_statement"] { _sql_metachar_only_block }
blocks["mcp_tool_not_approved"] { _mcp_quarantined }
blocks["mcp_definition_flagged"] { _mcp_flagged }
blocks["mcp_answer_carries_secret"] { _mcp_answer_carries_secret }
escalates["llm06_excessive_agency"] { elevated_tools[input.tool_name] }
escalates["mcp_definition_drift"] { _mcp_drifted }
escalates["mcp_definition_never_scanned"] { _mcp_unscanned }
audits["deny_shell_execution"] { shell_injection_detected }
audits["llm02_data_leakage"] { data_leakage_detected }
audits["llm02_data_leakage"] { secret_egress_detected }
audits["base64_decoded_threat"] { base64_decoded_threat }
audits["scope_violation_dangerous_tool"] { scope_violation_dangerous_tool }

Three things to read off it:

  1. strict_default_block at Off is simply absent. Nothing evaluates it.
  2. deny_shell_execution at Monitor moved from blocks[] to audits[]. Same guard, same predicate, different set.
  3. A control can register more than one head (pci_card_numbers, llm02_data_leakage, llm06_excessive_agency). Setting the control moves all of its heads together.

If a set ends up with no heads at all, the compiler emits a sentinel:

blocks[id] { false; id := "__never__" }

That line is load-bearing, not decoration. A partial set with zero definitions is not an empty set in Rego — the symbol is undefined and block_fired { blocks[_] } fails to compile with rego_unsafe_var_error. Without the sentinel, setting everything to Monitor would produce a module that does not compile, every evaluation would fall to evaluator_error, and an “allow by default” release would refuse everything.

deny restores the head’s original severity. The console and the API both report this per control as enforced_as:

enforced_as Controls Effect of Enforce
block 18 of 21 The call is refused.
escalate mcp_definition_drift, mcp_definition_never_scanned The call is held for human approval.
audit scope_violation_dangerous_tool Reported as observe-only, because the preset authors that control as audits[…].

A control that registers heads in two sets reports the strongest: llm06_excessive_agency has both blocks[] and escalates[] heads and reports block.


Every control declares what it governs (surface) and where in the request path it acts (plane). Both come from the server so the console and the engine cannot disagree about them.

flowchart LR
    D["Discovery<br/>tool definitions the MCP proxy read<br/>before the model saw them"] --> CA["Call<br/>the tool invocation and its parameters"]
    CA --> R["Response<br/>what goes back to the server"]
  • surface: tool — 16 controls, all on the call plane. They read the call itself — tool name, parameters, chain depth, calling agent class — so they apply to every enforcement model: SDK, sidecar, webhook and MCP proxy alike.
  • surface: mcp — 5 controls, four on discovery and one on response. These read input.mcp, which only exists when the call arrived through the Norviq MCP proxy. On an estate with no MCP they are inert, which is why they can ship enforcing without a false-positive surface: there is no legitimate traffic for them to touch.

A discovery-plane control cannot see call arguments. That is a real property of where it runs, not a grouping convenience.


Twenty-one controls, in the order the preset registers them. Default is the shipped default_effect — what GET /baseline/controls reports for a namespace that has never been saved. Enforce is what deny does for that control.

Control id Catches Default Enforce
llm01_prompt_injection Instructions in tool parameters that try to override the agent’s own instructions — “ignore previous instructions”, jailbreak phrasing, system-prompt exfiltration. deny block
deny_sql_injection Destructive or injected SQL in any tool’s parameters, not just execute_sql — a renamed tool carrying drop table users is caught too. deny block
deny_shell_execution Shell metacharacters and command-execution patterns in tool parameters. deny block
llm06_excessive_agency Tools that delete or destroy (blocks), and elevated ones (escalates). deny block
llm02_data_leakage Credentials and secrets sent to an external sink, on any tool name — including tool names it has never seen. monitor block
llm05_supply_chain Plugin/script loading from untrusted sources. deny block
dangerous_scheme file://, gopher://, dict:// and similar schemes on fetch-style tools. deny block
ssrf_metadata Tool calls that reach a cloud instance-metadata endpoint or the pod’s own loopback. deny block
pii_detection Personal data leaving via a tool call. deny block
pci_card_numbers Payment card numbers in tool parameters, by field name and by value. deny block
cross_tenant_access A call reaching for another tenant’s data. deny block
chain_depth_limit Runaway tool-calling chains beyond the configured depth. deny block
base64_decoded_threat Threats hidden behind base64, including nested encodings. monitor block
strict_default_block execute_sql outright, plus any tool whose name carries delete, drop, truncate, destroy, wipe, purge or erase as a whole token. monitor block
scope_violation_dangerous_tool An agent class using a tool outside its expected scope. monitor audit — see the caution above
deny_sql_multi_statement Stacked statements and SQL metacharacters on execute_sql. deny block
Control id Catches Default Enforce
mcp_definition_drift The rug pull: an MCP server serving a tool definition that is not the one an operator approved. Detected by content hash at discovery, before the model reads the new text. deny escalate
mcp_definition_never_scanned A tool call for a definition Gate A never read — a stateless client, or a proxy restarted since discovery. deny escalate
mcp_tool_not_approved In strict pin mode, a newly-seen MCP tool quarantined until an operator approves its definition. deny block
mcp_definition_flagged The definition itself carries instruction-injection shaped text — tool poisoning, where the description is the payload and the model reads it before the user has typed anything. deny block
Control id Catches Default Enforce
mcp_answer_carries_secret A server answering a call by asking the client for more input (the input-required pattern); this refuses to send a credential back as that answer. deny block

At the shipped defaults that is 17 enforcing, 4 monitoring, 0 off.

The global fallback (DEFAULT_EFFECT) is monitor, so a control added to a preset with no considered default ships observing. A control ships at deny only when two independent signals support it:

  1. Measured precision. norviq/redteam/precision.py runs a corpus of realistic-but-risky-looking traffic (norviq/redteam/benign.py — dates that look like SSNs, order ids that base64-decode to shell metacharacters, pagination cursors beside links) through the real compiled module with every control at deny, and attributes each refusal by the module’s own rule_id. A control that touches nothing legitimate is promotable. This is a build-time measurement, run by tests/redteam/test_benign_precision.py; it is not exposed through the API.
  2. The preset author’s own expressed severity — which set the head registers into. A control the preset authors as audits[…] never ships at deny.

The four controls that still ship at monitor are llm02_data_leakage, base64_decoded_threat, strict_default_block and scope_violation_dangerous_tool. The last is fixed by construction — the preset authors it as audits[…], and a test asserts that an audit-authored control never ships at deny. The other three are explicit monitor entries in the control table; the code records the setting, not a rationale for each one.


Eleven of the 21 controls ship a named caveat. The console renders these as an escalate-coloured marker on the row — the moment somebody clicks Enforce is the moment the caveat matters. Six of them concern ordinary tool traffic.

deny_shell_execution — historically the highest false-positive rate of any control. Through 0.2.0 it matched single characters such as | in base64-decoded parameter values, so ordinary alphanumeric identifiers (order ids, tracking codes, session tokens) decoded to random bytes and tripped it roughly 1 in 8 times at 16–24 characters. Fixed in 0.2.1: the decoded arm matches multi-byte indicators only (rm -rf, /etc/passwd, wget , …), and bare shell metacharacters fire only on execution-shaped tool names. Still the control worth observing first, since it guards the widest surface.

pii_detection — narrower than the name suggests. It always matches US SSN-shaped values. Date-of-birth-shaped (1990-01-15) and passport-number-shaped (GB1234567) values are matched too, but only when the call also carries a recognizable PII field name or context — a sibling key or value like date_of_birth, dob, passport, national_id, and a handful of others — since reading those patterns off the bare value alone previously classified every ISO-8601 date parameter as an SSN and every order id in that shape as a passport. Email addresses and phone numbers are never detected. This control on its own is not sufficient for a customer-data policy.

ssrf_metadata — matches on the destination, not the tool name, and resolves the alternate spellings (169.254.169.254, metadata.google.internal, 2130706433, 0x7f000001, 127.1, localhost) to the same address. Private RFC1918 ranges are deliberately excluded: an agent in Kubernetes reaches in-cluster services on 10.x / 172.16-31.x constantly, so blocking those would refuse ordinary traffic. Scope it yourself with derived.destinations.internal.private if you want that rule.

strict_default_block — matches on the tool name alone, with no regard to arguments. A read-only reporting tool called delete_candidates_report is blocked. This is the control most likely to need an exception for a legitimate tool.

chain_depth_limit — all five SDK adapters report call depth, and the SDK’s measurement is authoritative because it wraps tool execution: a nested call is measurably deeper and the agent cannot under-report it. Traffic arriving through a sidecar or the MCP proxy is different — a cross-process PEP can only forward the depth its caller claims, so a client that reports 0 is believed. Trust this control for SDK-instrumented workloads; treat it as advisory for proxied ones.

dangerous_scheme — these schemes read local files or speak unrelated protocols, so they are a local-file-read and SSRF primitive rather than a web fetch. Ordinary http/https is untouched. Some of these URLs were already blocked before this control existed — but as deny_shell_execution, which attributed them to a shell-execution attempt that never happened.

All five MCP controls carry a caveat as well. mcp_definition_drift and mcp_definition_never_scanned escalate rather than block, deliberately — adopting a changed definition is a legitimate operator action and a cold start is ordinary, so the safe default is a human looking at the diff rather than a silently broken agent. mcp_tool_not_approved only fires in strict pin mode: under the default tofu, a first-seen definition is pinned and allowed, and change is what gets enforced. mcp_definition_flagged’s scanner is a heuristic and evadable by construction — it is the discovery-plane complement to the deterministic call-plane checks, not a replacement for them. And mcp_answer_carries_secret depends on the response classifier recognising the value as a secret; a credential in a shape it does not know is not caught there.


Terminal window
curl -s "$NRVQ_API_URL/api/v1/baseline/controls?namespace=chatbot-prod" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" \
| jq '{namespace, preset, default_effect, counts,
controls: [.controls[] | {id, effect, default_effect, surface, plane, enforced_as}]}'

Any authenticated principal may read; the namespace is RBAC-scoped, so a tenant reading another namespace gets their own. One row looks like this:

{
"id": "deny_shell_execution",
"title": "Shell / command execution",
"description": "Catches shell metacharacters and command-execution patterns in tool parameters.",
"caveat": "Highest false-positive rate of any control. It also scans base64-DECODED parameter values and matches single characters such as '|' in the decoded bytes, so ordinary alphanumeric identifiers — order ids, tracking codes, session tokens — decode to random bytes and trip it roughly 1 in 8 times at 16-24 characters. Observe before promoting.",
"effect": "deny",
"default_effect": "deny",
"plane": "call",
"surface": "tool",
"enforced_as": "block"
}

effect is what this namespace runs; default_effect is what it would revert to.

Terminal window
curl -s -X PUT "$NRVQ_API_URL/api/v1/baseline/controls" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"chatbot-prod","preset":"strict",
"effects":{"deny_shell_execution":"monitor","strict_default_block":"off"}}' | jq
{
"namespace": "chatbot-prod",
"preset": "strict",
"effects": { "…": "every control, resolved" },
"enforcing": ["chain_depth_limit", "cross_tenant_access", ""],
"disabled": ["strict_default_block"],
"rego_lines": 1141
}

Other behaviour worth knowing before you script against it:

  • Admin only. PUT requires the admin role and rejects writes aimed at a remote fleet cluster.
  • Validation happens before any write. An unknown control id or an effect outside off|monitor|deny returns 422 and leaves the table untouched — a half-updated baseline that no longer matches what the operator was shown is worse than a rejection.
  • apply_mode: dry_run_only blocks it. If the namespace’s settings set that governance gate, the PUT returns 409 with the reason. Dry-run and draft saves stay allowed.
  • Only deviations are stored. A control sitting at its own shipped default writes no row, so a future release that changes a default reaches the namespace instead of being masked by a stored copy. A deliberate de-escalation (holding a deny-default control at monitor) is stored.
  • The whole namespace’s evaluation cache is invalidated, not just the __controls__ scope — the controls apply to every agent class, and a cached decision would keep enforcing the old effect.
  • A stale stored control id returns 409 on read. If a release removes a control you had tuned, the GET reports it rather than swallowing it, because your saved setting is no longer being honoured and you need to know which one.
Terminal window
norviq policy get chatbot-prod __controls__

__controls__ is a reserved managed scope. POST /policies against it returns 422 — a direct create would bypass the compiler entirely, and the console would then show control effects that no longer describe the module actually enforcing. DELETE /policies/<ns>/__controls__ is refused too, including with confirm_managed=true, which only covers __baseline__ and __guardrail__.

GET/PUT both accept a preset parameter defaulting to strict, but only strict.rego carries a CONTROLS-BEGIN/CONTROLS-END region. moderate.rego and permissive.rego do not, so ?preset=moderate fails on both verbs, with different status codes: GET /baseline/controls returns 409 with the message stored baseline is stale: preset has no # >>> CONTROLS-BEGIN / # >>> CONTROLS-END region (that “stale” prefix is boilerplate meant for a different case — a saved control id that no longer exists in the preset — so on this path it misleadingly implies the namespace’s own settings are the problem, when it’s the preset choice); PUT /baseline/controls returns 422 with the bare message preset has no # >>> CONTROLS-BEGIN / # >>> CONTROLS-END region. An unknown preset name returns 404 on both verbs. Leave the parameter alone.


Precedence: the controls module is a floor, not a tier

Section titled “Precedence: the controls module is a floor, not a tier”

The compiled __controls__ module is collected as a tighten-only overlay, not as a base tier. The evaluator takes it only when it is stricter than whatever the base tier decided, and it lands in the hard partition of overlay resolution, so a __pack_weaken__ cannot relax it either.

This matters because base tiers resolve by highest priority outright. When __controls__ was a base tier, an agent-class policy authored at priority 100 beat it at priority 2 and its decision was discarded. Measured on a live cluster with pii_detection set to Enforce and one SSN payload:

r2-support (has a class policy) allow cde_default_allow
anything-else (no class policy) block pii_detection

Writing a single class policy silently switched every promoted detector off for that class, while Target Settings still read “1 enforcing” — truthfully, about a control that no longer applied to the class the operator cared most about. As a floor, priority is irrelevant: a detector you promoted to Enforce cannot be removed as a side effect of authoring an unrelated policy.

The same appender handles two sibling reserved scopes on the same terms: __egress__ (the compiled destination rules) and __mcp__ (the compiled MCP server registry).

Within one module, block wins over escalate wins over audit. Among the controls that fired at the winning severity, rule_id is the alphabetically first one:

rule_id = sort([id | blocks[id]; not _shell_shadowed_by_sql(id)])[0] { block_fired }

So an audit row names one control, chosen by sort order — not the most specific one, and not all of them. Blast-radius counts inherit this: a call that trips three controls is counted once, against whichever id sorts first.

There is one deliberate exception. deny_shell_execution is shadowed when SQL injection also fired, or when an execute_sql call was blocked purely on SQL metacharacters, so a blocked multi-statement query is attributed to deny_sql_injection / deny_sql_multi_statement rather than reporting a shell-execution attempt that never happened. Genuine shell markers ($(, backtick, rm -rf, /etc/passwd, /etc/shadow, nc -e) still win.


The two MCP controls that are not in the preset

Section titled “The two MCP controls that are not in the preset”

mcp_unregistered_server and mcp_unapproved_write_server cannot live in the CONTROLS region, because “is this server registered” and “may we write through it” are questions about a per-namespace list an operator maintains in the console. They are compiled from that registry into the reserved scope __mcp__ by norviq/api/mcp_controls.py, and they are not settable through PUT /baseline/controls.

Rule id Fires when Default decision
mcp_unregistered_server The call came through the MCP proxy and names a server that is not in the namespace’s registry. audit
mcp_unapproved_write_server The server is registered but not marked writable, and derived.verb is neither read nor unknown. block

The two default differently on purpose. Registration is housekeeping that lags reality, so blocking on it would break an estate every time somebody stands up a new integration before telling the console. An unapproved write is different: the operator has already said which servers may be written through, so a write anywhere else is a statement about an integration they considered and declined.

Both are inert on an empty registry — not “everything is unregistered”. A fresh install would otherwise flag every MCP call and the first thing an operator would do is switch the control off. The registry fills as servers are discovered, and registering one is what gives the control something to say. writable is intersected with registered rather than trusted as given.

Neither control sees non-MCP traffic: every guard requires input.mcp.server != "", which no SDK, sidecar or webhook call carries.


Blast radius: what promoting a control would cost

Section titled “Blast radius: what promoting a control would cost”

“Promote this to Enforce” is a question about what will break, and it is answered on the same screen. The console fetches GET /api/v1/policy-compliance over a 7-day window and renders each control’s projected impact beside its row.

Terminal window
curl -s "$NRVQ_API_URL/api/v1/policy-compliance?namespace=chatbot-prod&range=7d" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" \
| jq '{scanned, excluded_synthetic,
controls: [.controls[] | {control_id, origin, count, enforced,
classes: [.agent_classes[].name],
top_tool: .tools[0].name}]}'

range accepts 1h, 6h, 24h, 7d, 30d.

The endpoint scans audit rows in the window and buckets them by the control they are evidence about. Two row shapes count, and missing the second is how this number once reported 7 of 33 would-blocks on a live cluster:

Shape Example rule_id Decision Meaning
Prefixed monitor_would_block:deny_sql_injection audit A hard block softened by namespace monitor posture.
Prefixed policy_audit_would_block:deny_sql_injection audit A hard block softened by the policy’s own audit mode — this is the stock install.
Bare deny_sql_injection audit The rego itself decided audit, because the control is set to Monitor and its head is in audits[]. Nothing softened it, so nothing prefixed it.

Both prefixes fold to the same control: they record why the block was softened, which matters for debugging but not for “what would this control break”, and splitting them would show one control twice with a divided count.

What is deliberately excluded:

  • Rows that already blocked. A call the control already refused is not evidence about a prospective promotion. Those are reported separately as enforced, and the two numbers are never summed by the endpoint — the callers choose.
  • Red-team and synthetic traffic. A probe that trips a control is not a customer workload about to break. The suppressed count is reported as excluded_synthetic, and it counts only rows that would actually have landed in a control.
  • Infrastructure rules. evaluator_error and friends are minted by the engine when it fails, not by any policy, and monitor mode softens them with the same prefix as a genuine control. Reporting them here would read an availability incident as a policy decision. They live on /system-health.
  • A bare audit id that is not a shipped control, for the baseline rows. Those are your own monitor-mode policies; they still appear, tagged "origin": "custom", so a policy you are trialling is visible rather than silently discarded.
  • scanned is what makes an empty list readable. Zero non-compliant calls out of zero traffic means “nothing has happened here yet”. Zero out of 40,000 means “genuinely compliant”. Without this number you cannot tell them apart, and rendering an idle namespace as a clean bill of health is the exact lie it exists to prevent.
  • The unit is the control, not the call. A control with 4,000 hits across one agent class is a very different decision from one with 12 hits across nine. The first is probably a false positive worth investigating; the second is probably real.
  • The impact verb follows enforced_as. For an escalating control the flagged calls “would have been held for approval”, not blocked.
  • samples are the five newest, not the first five in database order — a sample that misrepresents the pattern is worse than no sample, because it is the part an operator reads instead of the aggregate. Up to five per control; the Audit Log console page (/audit) has the full list.
  • A control can be returned with count: 0. A control whose only hits in the window were calls it already blocked gets a row with enforced > 0 and count: 0. Filter on count > 0 before rendering a blast-radius figure, or you will show “0 would have been blocked · 2 classes” — noise, and self-contradictory noise. A control that saw nothing at all is simply absent from the list.

Terminal window
# 1. Install observing — the chart default. Confirm the baseline is loaded.
norviq policy list | grep __baseline__
# 2. Run real traffic for a week.
# 3. Ask what enforcing would have cost, per control.
curl -s "$NRVQ_API_URL/api/v1/policy-compliance?namespace=chatbot-prod&range=7d" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" \
| jq -r '.controls[] | select(.count > 0)
| "\(.count)\t\(.control_id)\t\([.agent_classes[].name] | join(","))"' \
| sort -rn
# 4. Read the current effects, so you can send the full map back.
curl -s "$NRVQ_API_URL/api/v1/baseline/controls?namespace=chatbot-prod" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" \
| jq '[.controls[] | {(.id): .effect}] | add' > effects.json
# 5. Edit effects.json, then send everything back.
jq -n --slurpfile e effects.json \
'{namespace:"chatbot-prod", preset:"strict", effects:$e[0]}' \
| curl -s -X PUT "$NRVQ_API_URL/api/v1/baseline/controls" \
-H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \
--data-binary @- | jq '{enforcing, disabled, rego_lines}'
# 6. Verify what actually compiled.
norviq policy get chatbot-prod __controls__

What promotion does and does not interact with

Section titled “What promotion does and does not interact with”

A promoted control is not softened by baselineClusterPolicy.enforcementMode: audit. Per-policy audit mode only applies to base and floor candidates, and the controls module is an overlay written in block mode. The two layers are compared on their effective strictness, precisely so that a control you set to Enforce wins the tie against an audit-mode baseline saying the same thing — otherwise the row would come back attributed as policy_audit_would_block: and enforcement would silently lose to a tiebreak.

What does still soften it is the namespace posture. If Target Settings sets this namespace’s enforcement_mode to audit, every block — including a promoted control’s — is recorded as monitor_would_block:<control_id> and the call proceeds. That is the switch to check when a control reads “enforcing” and traffic is still flowing. See Configuration for config.enforcementMode and baselineClusterPolicy.enforcementMode.


  • Not a replacement for policy. They are shipped detectors with a tri-state, not an authoring surface. To express “this agent class may only call these three tools”, write a policy — see Writing policies and the policy cookbook.
  • Not per agent class. Effects are per namespace and apply to every class in it.
  • Not exception-capable. There is no allowlist inside a control. If strict_default_block refuses a legitimate delete_candidates_report, your options are Monitor, Off, or a policy that decides before the floor is consulted — and the floor is tighten-only, so it cannot be relaxed by a policy.
  • Not versioned separately. The compiled module lands in the normal policy version history for (namespace, __controls__), so norviq policy versions <ns> __controls__ shows the saves.