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.
What a fresh install actually runs
Section titled “What a fresh install actually runs”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:
baselineClusterPolicy: enabled: true name: baseline-cluster-guard clusterPriority: 900 preset: strict enforcementMode: audit # evaluate and record; the call proceedsSo 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/controlsshows each control at its shippeddefault_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
enforcementModethe 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["<ns>:__baseline__<br/>priority 1 · base tier"]
C["Admin: PUT /baseline/controls"] --> K["baseline.compile(strict, effects)"]
K --> M["<ns>:__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.
The three effects, in the compiled module
Section titled “The three effects, in the compiled module”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:
strict_default_blockat Off is simply absent. Nothing evaluates it.deny_shell_executionat Monitor moved fromblocks[]toaudits[]. Same guard, same predicate, different set.- 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.
What Enforce actually does
Section titled “What Enforce actually does”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.
The two axes: surface and plane
Section titled “The two axes: surface and plane”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 readinput.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.
The full list
Section titled “The full list”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.
Tool calls — call plane (16)
Section titled “Tool calls — call plane (16)”| 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 |
MCP integrations — discovery plane (4)
Section titled “MCP integrations — discovery plane (4)”| 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 |
MCP integrations — response plane (1)
Section titled “MCP integrations — response plane (1)”| 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.
Why the defaults are not uniform
Section titled “Why the defaults are not uniform”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:
- Measured precision.
norviq/redteam/precision.pyruns 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 atdeny, and attributes each refusal by the module’s ownrule_id. A control that touches nothing legitimate is promotable. This is a build-time measurement, run bytests/redteam/test_benign_precision.py; it is not exposed through the API. - The preset author’s own expressed severity — which set the head registers into. A control the
preset authors as
audits[…]never ships atdeny.
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.
Known false-positive modes
Section titled “Known false-positive modes”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.
Reading and setting the controls
Section titled “Reading and setting the controls”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.
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.
PUTrequires theadminrole and rejects writes aimed at a remote fleet cluster. - Validation happens before any write. An unknown control id or an effect outside
off|monitor|denyreturns422and 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_onlyblocks it. If the namespace’s settings set that governance gate, thePUTreturns409with 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 atmonitor) 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
409on read. If a release removes a control you had tuned, theGETreports it rather than swallowing it, because your saved setting is no longer being honoured and you need to know which one.
Inspect what was compiled
Section titled “Inspect what was compiled”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__.
The preset is strict-only, in practice
Section titled “The preset is strict-only, in practice”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_allowanything-else (no class policy) block pii_detectionWriting 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).
Attribution when several controls fire
Section titled “Attribution when several controls fire”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.
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.
How count is computed
Section titled “How count is computed”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_errorand 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.
Reading the numbers honestly
Section titled “Reading the numbers honestly”scannedis 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. samplesare 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 withenforced > 0andcount: 0. Filter oncount > 0before 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.
A promotion workflow that works
Section titled “A promotion workflow that works”# 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.
What baseline controls are not
Section titled “What baseline controls are not”- 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_blockrefuses a legitimatedelete_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__), sonorviq policy versions <ns> __controls__shows the saves.