Arcus Docs

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.

json
{
  "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#

FieldRequiredNotes
fieldYes1–200 chars. A dotted path into the payload — see Field paths.
conditionYesOne of the seven below.
valueDependsThe threshold, bounds, scalar or keyword list. Omitted for matches_destructive.
actionYesblock, hold_for_approval or flag_only.
senderAgentIdNoWhich agent this applies to. Defaults to * — every agent.
minSeverityNoOnly valid with matches_destructive. Narrows the catalogue.
labelYes1–120 chars. Shown in findings, logs and approvals — write it for the person reading the alert.
enabledNoDefaults to true. Disabled rules do not count against your tier limit.
bash
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#

ConditionvalueFires when
greater_thannumberThe field exceeds it
less_thannumberThe field is below it
outside_range[min, max]The field is outside the inclusive bounds
equalsstring / number / booleanThe field equals it
not_equalsstring / number / booleanThe field does not equal it
contains_keywordarray of stringsThe field's text contains any listed keyword, case-insensitively
matches_destructivenoneThe field matches the built-in destructive-operation catalogue

Validation is strict, and the error messages tell you the shape:

MistakeResponse
greater_than with no valueneeds a numeric threshold, e.g. { "value": 1000 }
outside_range with one numberneeds two numeric bounds, e.g. { "value": [0, 1000] }
equals with an objectneeds a scalar to compare against (string, number or boolean)
contains_keyword with an empty arrayRejected — the list must be non-empty
matches_destructive with a valuetakes no value — it scans the built-in catalogue. Use minSeverity to narrow it, or contains_keyword for your own terms.
minSeverity on any other conditionRejected — 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:

PathMatches
data.amountdata.amount exactly
data.items[0].priceThe first item's price
data.items[*].priceEvery item's price — fires if any one matches
data.items[*].qtyAny array index at that position
amountA 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.

json
{
  "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:

IdLabelSeverity
shell.rm_recursiveRecursive force delete (rm -rf)critical
shell.disk_overwriteRaw disk overwrite (dd / mkfs)critical
shell.fork_bombFork bombcritical
shell.privilege_escalationPrivilege escalation / world-writable permissionshigh
shell.host_shutdownHost shutdown or rebootmedium
sql.drop_objectDROP TABLE / DATABASE / SCHEMAcritical
sql.delete_without_whereDELETE without a WHERE clausecritical
sql.update_without_whereUPDATE without a WHERE clausehigh
sql.truncateTRUNCATEhigh
sql.grant_allGRANT ALLhigh
store.drop_or_flushDatastore drop / flushcritical
finance.transfer_allTransfer or withdraw entire balancecritical
bulk.delete_allDelete-all / remove-all operationcritical
bulk.wipeWipe / purge / obliteratehigh
control.disable_safeguardDisabling authentication, auditing or encryptioncritical
vcs.force_pushForce push (history rewrite)high
json
{
  "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#

json
{
  "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.

SettingValue
WindowLast 50 observations
Minimum samples before judging5
Multiple threshold5× the rolling mean
Sigma threshold
Tracked fields per agent10

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#

PlanEnabled rulesBaselines
Free2
ProUnlimitedYes
MaxUnlimitedYes

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:

json
{
  "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:

json
{
  "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#

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

json
[
  {
    "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#

  1. Start as flag_only. The rule is live and recording, and nothing breaks.
  2. Watch for a week. Filter your logs for flagged: true and read what it caught.
  3. Tune the threshold. Findings carry observed, so you can see the real distribution of values rather than guessing at one.
  4. Promote. hold_for_approval if a human should decide, block if the answer is always no.
  5. Bind the sender. A rule scoped to billing-agent is 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.