Enforcement
Parameter guardrails
Gate 5 reasons about the values inside a payload — an amount, a row count, a destructive command. Seven conditions, three actions, a built-in catalogue of dangerous operations, and a statistical baseline that catches what no rule was written for.
The gap they close#
Policy says billing-agent may call POST /transfers. That is correct and it is not enough, because it says nothing about whether this transfer should happen.
An agent that has moved £100 a hundred times and now tries to move £500,000 is doing something policy permits, DLP has no opinion about, and a human would stop instantly. A guardrail is the rule that stops it.
{
"error": "Blocked by parameter guardrail",
"status": "BLOCKED_BY_PARAMETER",
"transferLogId": "clx8m2p4k0006abcd",
"severity": "critical",
"findings": [
{
"ruleId": "clx9n3q5l0002efgh",
"label": "Cap single transfers",
"field": "data.amount",
"path": "data.amount",
"condition": "greater_than",
"expected": 10000,
"observed": 500000,
"severity": "high",
"reason": "data.amount is 500000, above the 10000 limit"
}
]
}Anatomy of a rule#
| Field | Required | Notes |
|---|---|---|
field | Yes | 1–200 chars. A dotted path into the payload — see Field paths. |
condition | Yes | One of the seven below. |
value | Depends | The threshold, bounds, scalar or keyword list. Omitted for matches_destructive. |
action | Yes | block, hold_for_approval or flag_only. |
senderAgentId | No | Which agent this applies to. Defaults to * — every agent. |
minSeverity | No | Only valid with matches_destructive. Narrows the catalogue. |
label | Yes | 1–120 chars. Shown in findings, logs and approvals — write it for the person reading the alert. |
enabled | No | Defaults to true. Disabled rules do not count against your tier limit. |
curl -sS -X POST "$ARCUS_URL/v1/admin/parameter-rules" \
-H "Authorization: Bearer <clerk-session-token>" \
-H "Content-Type: application/json" \
-d '{
"label": "Cap single transfers",
"field": "data.amount",
"condition": "greater_than",
"value": 10000,
"action": "hold_for_approval",
"senderAgentId": "billing-agent"
}'The seven conditions#
| Condition | value | Fires when |
|---|---|---|
greater_than | number | The field exceeds it |
less_than | number | The field is below it |
outside_range | [min, max] | The field is outside the inclusive bounds |
equals | string / number / boolean | The field equals it |
not_equals | string / number / boolean | The field does not equal it |
contains_keyword | array of strings | The field's text contains any listed keyword, case-insensitively |
matches_destructive | none | The field matches the built-in destructive-operation catalogue |
Validation is strict, and the error messages tell you the shape:
| Mistake | Response |
|---|---|
greater_than with no value | needs a numeric threshold, e.g. { "value": 1000 } |
outside_range with one number | needs two numeric bounds, e.g. { "value": [0, 1000] } |
equals with an object | needs a scalar to compare against (string, number or boolean) |
contains_keyword with an empty array | Rejected — the list must be non-empty |
matches_destructive with a value | takes no value — it scans the built-in catalogue. Use minSeverity to narrow it, or contains_keyword for your own terms. |
minSeverity on any other condition | Rejected — it is only meaningful with matches_destructive |
Numeric strings are coerced#
{"amount": "1500"}, {"amount": "$1,500"} and {"amount": 1500} all satisfy greater_than: 1000. Currency symbols and thousands separators are stripped before comparison.
This is not convenience — it closed a real bypass. An agent serialising an amount as a string would otherwise sail past a numeric threshold, and JSON from real systems is inconsistent about which numbers are quoted.
Booleans are deliberately not coerced
"true" is not true. A rule of equals: true on data.confirm matches the boolean and not the string, because those are genuinely different assertions and silently conflating them would make the rule mean something you did not write.
Field paths#
A dotted path, with array support:
| Path | Matches |
|---|---|
data.amount | data.amount exactly |
data.items[0].price | The first item's price |
data.items[*].price | Every item's price — fires if any one matches |
data.items[*].qty | Any array index at that position |
amount | A key named amount at any depth |
The bare-name form is the pragmatic one. command matches data.command, data.job.command and data.steps[3].command alike — useful when you do not control the exact shape of what your agent sends and you care about the field wherever it appears.
data.items[*].price fires if any element matches. A rule capping line-item prices does not need to know how many line items there are.
If the path is absent from the payload, the rule does not fire. A guardrail is a check on a value that is present, not an assertion that it must be.
The three actions#
Severity order — most to least severe:
block#
422 BLOCKED_BY_PARAMETER. Nothing delivered. Audit: dispatch.blocked_by_parameter.
For operations that are never acceptable. A DROP TABLE in a command field has no legitimate version that a human should be asked about at 3am.
hold_for_approval#
202 with status: PENDING_APPROVAL and queued: false. The payload is retained; nothing is delivered until a human decides. Audit: dispatch.held_for_parameter.
For operations that are legitimate but consequential. A £50,000 transfer is not wrong — it is something a person should confirm. See Approval workflow.
flag_only#
Delivered, with flagged: true and the findings attached. Audit: dispatch.flagged_by_parameter.
{
"ok": true,
"transferLogId": "clx8m2p4k0007abcd",
"queued": true,
"flagged": true,
"findings": [ { "label": "Unusual hour", "field": "data.hour", "severity": "low", "…": "…" } ]
}For observation. Deploy a new rule as flag_only first, watch what it catches for a week, then promote it to hold_for_approval or block once you know its real false-positive rate. This is the single most useful habit with guardrails — it means a new rule never causes an outage.
When several rules match#
All matching rules are collected, and the strongest action decides the outcome. One block outranks any number of holds; a hold outranks any number of flags.
Every finding is reported regardless, because two rules tripping on different fields are two separate facts a reviewer needs. Suppressing the flag because a block already fired would hide half of what happened.
The reported severity is the highest among the findings.
The destructive catalogue#
matches_destructive scans a field against 16 built-in patterns covering the operations that are catastrophic and recognisable:
| Id | Label | Severity |
|---|---|---|
shell.rm_recursive | Recursive force delete (rm -rf) | critical |
shell.disk_overwrite | Raw disk overwrite (dd / mkfs) | critical |
shell.fork_bomb | Fork bomb | critical |
shell.privilege_escalation | Privilege escalation / world-writable permissions | high |
shell.host_shutdown | Host shutdown or reboot | medium |
sql.drop_object | DROP TABLE / DATABASE / SCHEMA | critical |
sql.delete_without_where | DELETE without a WHERE clause | critical |
sql.update_without_where | UPDATE without a WHERE clause | high |
sql.truncate | TRUNCATE | high |
sql.grant_all | GRANT ALL | high |
store.drop_or_flush | Datastore drop / flush | critical |
finance.transfer_all | Transfer or withdraw entire balance | critical |
bulk.delete_all | Delete-all / remove-all operation | critical |
bulk.wipe | Wipe / purge / obliterate | high |
control.disable_safeguard | Disabling authentication, auditing or encryption | critical |
vcs.force_push | Force push (history rewrite) | high |
{
"label": "No destructive commands",
"field": "command",
"condition": "matches_destructive",
"action": "block",
"minSeverity": "high"
}minSeverity narrows the catalogue: high matches high and critical only, so a shutdown (medium) passes while a DROP TABLE does not. Levels are low, medium, high, critical.
Two entries deserve specific attention. sql.delete_without_where and sql.update_without_where are the classic agent failure — a generated statement whose WHERE clause was dropped because the value it depended on was null. control.disable_safeguard catches an agent trying to turn off the thing that is watching it.
The patterns themselves are not published
The API returns each pattern's id, label and severity — not its regex. Publishing the exact strings would hand an agent author the precise text to write around, which converts a safety net into a documented bypass list. If you need something more specific than the catalogue provides, contains_keyword lets you supply your own terms.
Your own keywords#
{
"label": "Protected tables",
"field": "query",
"condition": "contains_keyword",
"value": ["patients", "billing_accounts", "audit_events"],
"action": "hold_for_approval"
}Case-insensitive substring matching. Suggested starting points, if you want a general net: delete, drop, wipe, purge, transfer_all, rm -rf, truncate, shutdown, revoke, disable_audit.
Statistical baselines#
A rule catches what you thought of. A baseline catches what you did not.
For each (agent, numeric field) Arcus keeps a rolling window of the last 50 values and the mean and standard deviation over it. An observation is flagged when it is 5× the rolling mean or 4 standard deviations out.
| Setting | Value |
|---|---|
| Window | Last 50 observations |
| Minimum samples before judging | 5 |
| Multiple threshold | 5× the rolling mean |
| Sigma threshold | 4σ |
| Tracked fields per agent | 10 |
The minimum-sample rule matters: a brand-new agent is not flagged for its first four requests, because "unusual" is meaningless without a normal. The ten-field cap bounds the cost — the ten fields an agent actually sends numbers in, not every key it has ever emitted.
Baselines live in PostgreSQL, not Redis. A lost rate-limit counter costs one window of enforcement; a lost baseline silently resets every agent to "no established normal" and the check goes quiet exactly when it is least obvious that it has.
Baselines require the guardrails.baseline capability (Pro and above). Read the learned statistics at /v1/admin/baselines.
Baselines are the answer to "we did not know to write a rule for that"
Every real incident of this kind looks obvious afterwards and was invisible before. A baseline does not need you to predict the field or the threshold — it needs the agent to have behaved consistently, which agents do.
Rule limits per plan#
| Plan | Enabled rules | Baselines |
|---|---|---|
| Free | 2 | — |
| Pro | Unlimited | Yes |
| Max | Unlimited | Yes |
Only enabled rules count. Disabling a rule frees the slot and keeps the definition, so on Free you can maintain a library of five rules and run two.
Exceeding the limit returns 403 with everything you need to act on:
{
"error": "Free accounts can keep 2 parameter rules enabled",
"code": "PARAMETER_RULE_LIMIT_REACHED",
"tier": "free",
"limit": 2,
"activeRules": 2,
"upgradeAvailable": true,
"requiredTier": "pro"
}Discovering the vocabulary#
/v1/admin/parameter-rules returns your rules and the full vocabulary — so a UI never needs to hardcode the condition list or the catalogue:
{
"rules": [ /* … */ ],
"limit": { "tier": "free", "max": 2, "enabled": 2, "canCreate": false },
"baseline": { "enabled": false, "requiredTier": "pro",
"window": 50, "minSamples": 5, "multiple": 5, "sigma": 4,
"maxTrackedFields": 10 },
"vocabulary": {
"conditions": ["greater_than", "less_than", "outside_range", "equals",
"not_equals", "contains_keyword", "matches_destructive"],
"actions": ["block", "hold_for_approval", "flag_only"],
"suggestedKeywords": ["delete", "drop", "wipe", "purge", "transfer_all",
"rm -rf", "truncate", "shutdown", "revoke", "disable_audit"],
"destructivePatterns": [
{ "id": "sql.drop_object", "label": "DROP TABLE / DATABASE / SCHEMA", "severity": "critical" }
]
}
}Managing rules#
| Action | Endpoint |
|---|---|
| List + vocabulary | /v1/admin/parameter-rules |
| Create | /v1/admin/parameter-rules |
| Update | /v1/admin/parameter-rules/:id |
| Delete | /v1/admin/parameter-rules/:id |
| Learned baselines | /v1/admin/baselines |
Creating, updating and deleting append guardrail.created, guardrail.updated and guardrail.deleted to your audit chain with the actor. Disabling a guardrail is itself a governance event, sealed alongside the decisions it affects.
A worked rule set#
[
{
"label": "Never run destructive commands",
"field": "command",
"condition": "matches_destructive",
"action": "block",
"senderAgentId": "*"
},
{
"label": "Cap single transfers",
"field": "data.amount",
"condition": "greater_than",
"value": 10000,
"action": "hold_for_approval",
"senderAgentId": "billing-agent"
},
{
"label": "Bulk operations need a human",
"field": "data.records[*].id",
"condition": "contains_keyword",
"value": ["*", "all"],
"action": "hold_for_approval"
},
{
"label": "Watch protected tables",
"field": "query",
"condition": "contains_keyword",
"value": ["patients", "billing_accounts"],
"action": "flag_only"
}
]Reading top to bottom: destructive commands never happen; large transfers happen with a human's consent; bulk deletes are held; touching a sensitive table is delivered but recorded loudly. That is a governance posture expressed in four rules, and every one of them is enforced at the edge rather than trusted to the agent.
Rolling one out safely#
- Start as
flag_only. The rule is live and recording, and nothing breaks. - Watch for a week. Filter your logs for
flagged: trueand read what it caught. - Tune the threshold. Findings carry
observed, so you can see the real distribution of values rather than guessing at one. - Promote.
hold_for_approvalif a human should decide,blockif the answer is always no. - Bind the sender. A rule scoped to
billing-agentis much less likely to have surprising effects than one scoped to*.
A `block` rule on `*` affects every agent you have
senderAgentId defaults to *. That is usually right for matches_destructive and usually wrong for a numeric threshold — data.amount > 10000 applied to every agent will eventually stop something you did not have in mind. Scope numeric rules to the agent whose amounts you actually mean.