Risk engine
Thirteen named signals, weighted into an explainable score that policy can test — with no model you cannot interrogate.
Risk runs as stage 8 of the pipeline, before approval, because a policy rule can test the risk score and the signals that produced it. The output is a level, a score from 0 to 100, and the list of signals that fired with their individual contributions.
Explainable by construction
There is no opaque model here. Every score decomposes into named signals, each with a weight you set and a human-readable explanation of what it observed.
That is a requirement, not a preference. A refusal an operator cannot explain to the person it affected is a refusal that gets overridden, and then switched off. "Blocked, risk score 87" is useless. "Blocked: amount is 4.2× this agent's trailing median, and the destination has never been contacted before" is something you can act on.
#The signals
| Signal | Fires when |
|---|---|
UNUSUAL_AMOUNT |
The amount is far outside this agent's own history |
UNUSUAL_DESTINATION |
The destination is rare or newly seen for this agent |
VELOCITY_ANOMALY |
Action rate is well above the agent's baseline |
TIME_ANOMALY |
Activity at an hour this agent does not normally act |
NEW_TOOL |
First use of this tool by this agent |
NEW_RESOURCE |
First touch of this resource |
NEW_RECIPIENT |
First time acting against this recipient |
IDENTITY_ANOMALY |
Something inconsistent about the identity presentation |
CREDENTIAL_ANOMALY |
Unusual credential use — stale, over-leased, unexpected scope |
DELEGATION_ANOMALY |
An unusual chain: unexpected depth, or a path not seen before |
POLICY_VIOLATION |
Recent refusals by this agent |
TRUST_EVIDENCE |
Required trust evidence is missing, expiring or expired |
BEHAVIORAL_DEVIATION |
The overall pattern departs from the agent's established behaviour |
Several of these are baseline signals — they compare against what this agent
has done before, not against a global average. A finance agent that moves large
amounts all day has a high baseline and does not trip UNUSUAL_AMOUNT; a
read-only agent that suddenly moves one does.
That also means a brand-new agent trips the "new" signals on almost everything
it does. That is correct behaviour and worth planning for: run new agents in
OBSERVE or SIMULATE long enough to establish a baseline before promoting.
#What a risk assessment returns
json{
"level": "HIGH",
"score": 72,
"signals": [
{
"type": "UNUSUAL_AMOUNT",
"weight": 30,
"explanation": "Amount 120000 is 4.2× this agent's trailing median of 28500."
},
{
"type": "UNUSUAL_DESTINATION",
"weight": 22,
"explanation": "payments-eu.partner.example.com has not been contacted by this agent before."
},
{
"type": "TIME_ANOMALY",
"weight": 20,
"explanation": "03:14 UTC is outside this agent's usual 08:00–19:00 activity."
}
]
}
On a decision, explanation.risk.top_signals carries the three
highest-weighted explanations, so the reason a request was escalated is in the
response the agent already received.
#Using risk in policy
This is the point of computing it.
json{
"id": "rule-high-risk-approval",
"name": "High risk needs a human",
"priority": 600,
"enabled": true,
"when": {
"kind": "condition",
"id": "c-risk-score",
"field": "risk.score",
"operator": "gte",
"value": 70
},
"then": {
"decision": "REQUIRE_APPROVAL",
"reason": "Risk score is at or above the review threshold.",
"approval": { "routingMode": "SINGLE", "requiredApprovals": 1, "ttlSeconds": 900 }
}
}
Testing a specific signal is often better than testing the score, because it says what you actually mean:
json{
"id": "rule-new-destination-large-amount",
"name": "Block large amounts to unseen destinations",
"priority": 820,
"enabled": true,
"when": {
"kind": "group",
"id": "g-new-dest-large",
"operator": "and",
"children": [
{
"kind": "condition",
"id": "c-signal-new-dest",
"field": "risk.signals",
"operator": "contains",
"value": "UNUSUAL_DESTINATION"
},
{ "kind": "condition", "id": "c-large", "field": "amount", "operator": "gt", "value": 50000 }
]
},
"then": {
"decision": "BLOCK",
"reason": "Large amounts to a destination this agent has never used are refused."
}
}
Three fields are available: risk.level, risk.score and risk.signals. The
velocity and history counters the signals are built from are exposed separately
too — velocity.actions_last_minute, history.tool_used_before and the rest —
so you can write a rule on the raw observation rather than on the derived
signal when that is clearer.
#Tuning
httpPUT /v1/risk/configuration
Signal weights and level thresholds are yours. Tuning is an audited change
requiring risk:configure.
Approach it from the evidence rather than from intuition:
- Run in
OBSERVEorSIMULATEand let real traffic accumulate. - Open the Risk Center and look at the score distribution and the signal breakdown.
- A signal firing on nearly everything is not discriminating — lower its weight or fix the underlying baseline.
- A signal that never fires is either weighted too low or genuinely not relevant to your traffic.
- Set thresholds where the distribution has a natural gap.
Raising a threshold is a security change
Moving the HIGH threshold from 70 to 85 silently removes approval
requirements from every rule that tests it. It is audited for exactly that
reason. Simulate before and after, and look at what stops being escalated.
#Risk events
Every assessment that crosses a threshold writes a risk event with its signal evidence attached.
httpGET /v1/risk/events
GET /v1/risk/overview
The overview is what the Risk Center renders: counts by level, the signal
breakdown, the highest-risk tools, the destinations in play, policy violations
and credential anomalies. Events can be acknowledged with risk:acknowledge, so
"we have looked at this" is recorded rather than remembered.