Skip to main content

Agent monitoring and guardrails

How agent delegate behavior is scored: the seven signals, when evaluation runs, how scopes narrow and restore, what is recorded, and the Agents tab.

Last reviewed October 19, 2026Fresh

Overview

PasskeyBridge scores the behavior of every agent delegate and narrows its scopes when the score falls. Two pieces do the work:

  • _shared/agent-trust-engine.ts, a pure function with no I/O that turns activity rows into a score, a set of signals and a narrowing action.
  • shield-agent-trust, the edge function that reads the delegate and its activity, calls the engine, writes the result and the audit row.

`shield-agent-trust` is internal. It accepts the service-role key, and the cron secret for its scheduled sweep, and nothing else, so it is not part of your integration surface. A call carrying a session JWT or an API key answers 401. You observe its output in Dashboard > Agents and in the trust_score that shield-agent-delegate verify returns.

When evaluation runs. There is no idle decay. Re-scoring happens when:

  • shield-ingest accepts a soft signal for the tenant, which fires evaluate_tenant for every active delegate in that tenant, debounced to one call per tenant and signal type per 30 seconds;
  • shield-intelligence-worker produces an agent_trust_delta below -0.05 on an assessment it did not flag, which fires evaluate_tenant with that delta applied to every delegate in the tenant;
  • a delegate's verify call is refused for scope or expiry, which is recorded and scored for that delegate as it is written;
  • the five-minute sweep runs, which scores every active delegate with activity no evaluation has judged, once that activity is 30 seconds old.

A delegate with no new activity keeps its score, however long it sits idle; one that acts is re-scored by the next sweep. Hard signals do not go through this path at all: shield-ingest deactivates every delegate in the tenant inline and zeroes its score.

Creating delegates and the token lane are covered in Agent identity delegation.

Trust score model

Every delegate carries trust_score, a numeric between 0.000 and 1.000, and auto_revoke_below, its own revocation floor.

ColumnDefaultSet by
trust_score1.000initial_trust at creation; the New delegate wizard sends 0.85
auto_revoke_below0.200auto_revoke_below at creation; the wizard allows up to 0.50 and requires it below the starting score
original_scopesThe scopes given at creationCopied from the current scopes the first time the delegate is narrowed, an empty list included

The read-only threshold (0.400) and the full-access threshold (0.700) are the platform defaults; an organization sets its own with shield-cascade update_threshold, and get_model reports the ladder in force.

Hard signals bypass the engine. Nine signal types are classified hard by _shared/cascade-classifier.ts and by the engine's own isHardSignal:

sim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover, scope_poisoning

Any of them, ingested with a subject hash, revokes every active delegate in the tenant (is_active false, trust_score 0, a narrowing reason naming the signal) and revokes every open A2A negotiation, before any playbook action runs. evaluate_tenant called with a hard signal type refuses to score and answers { "status": "hard_signal_bypass" }.

Soft signals go through the engine for a graduated response. Ten types are classified soft: velocity_anomaly, behavioral_anomaly, geo_anomaly, credential_leak, credential_stuffing_attempt, suspicious_login, failed_verification, device_change, unusual_device, number_recycle.

The soft signal itself carries no weight. It is a trigger: receiving one causes the tenant's delegates to be re-scored from their own activity rows.

Signal detection

assessTrust() reads up to 100 activity rows for the delegate, newest first, and looks for seven patterns.

SignalWindowTriggerMaximum weight
velocity_spike5 minutesMore than 50 rows0.30
error_burst2 minutesMore than 10 rows whose result is not success0.25
scope_violation24 hours, since the watermarkAny row whose result is scope_violation0.35
ip_rotation10 minutesMore than 5 distinct ip_hash values0.20
extended_inactivity24 hoursOver 24 hours between the newest row and the eleventh, plus more than 10 rows in the 5-minute window0.15 flat
ai_behavioralPer evaluationai_trust_delta is negative0.30
normal_usagePer evaluationAt least one new activity row, all of them successes, and no negative signaladds 0.05

Severity. Negative weights scale with how far past the threshold the count is, capped at the maximum. Seventy-five actions in five minutes is 25 over the threshold of 50, so the weight is 0.30 multiplied by 0.5, which is 0.15. scope_violation scales on violations divided by 5, so five or more violations reach the full 0.35.

AI delta. The intelligence worker's delta is read between -1 and 0 and applied as delta * 0.3, so one assessment lowers a score by at most 0.3 and a positive delta changes nothing. It is applied after the detectors and stops at the delegate's revocation floor, so assessments alone never revoke. Because the dispatch is per tenant, one analysis applies the same delta to every delegate in that tenant.

Arithmetic. Start from the current score, subtract each negative weight, add 0.05 for normal_usage, then apply the AI term, which goes no lower than the revocation floor, clamp to the range 0.000 to 1.000, round to three decimals.

What produces an activity row. Only verify on shield-agent-delegate, for every call of an active delegate whether it succeeded or not, and any internal record_activity call. An agent that never calls verify generates no rows, so it collects no signals and its score never moves, in either direction.

Graduated scope narrowing

The score decides one of four actions. The two middle rungs are the organization's trust ladder: 0.700 and 0.400 unless it has set its own through shield-cascade update_threshold.

ScoreActionScopes written
Below the delegate's auto_revoke_below (default 0.200)recommend_revocationEmpty, and is_active set to false
At least full_at (default 0.700)noneUnchanged, or original_scopes restored when a narrowing is on record
At least read_only_at (default 0.400)narrow_to_readOnly ingest:read, events:read, playbook:read, intelligence:read and verify that the delegate already held, with verify always added
Otherwisenarrow_to_verifyExactly ["verify"]

The revocation floor is checked before the ladder, so a delegate created with a floor of 0.45 is deactivated rather than narrowed when it reaches 0.44. At the default of 0.200 the two orderings agree.

Narrowing keeps only the five scope names the engine knows. Scopes from the New delegate wizard's vocabulary (ingest, playbooks:execute, credentials:present, spatial:attest, shadow:create) are dropped by a narrow to read-only and are recovered only by restoration.

Preservation and restoration. The first narrowing, while scope_narrowed_at is still null, copies the current scopes into original_scopes, an empty list included because an empty list means unrestricted. A recovery back to full_at (0.700 by default) or above with a narrowing on record writes the original list back automatically and clears scope_narrowed_at.

Audit rows. A single-delegate evaluation writes one row when the score moved by more than 0.05 or the scopes changed:

ActionWritten when
delegate.trust_restoredScore rose with no narrowing
delegate.trust_degradedScore fell with no narrowing
delegate.scope_narrowedScopes were rewritten
delegate.trust_revokedThe delegate was deactivated

The batch path, which soft signals and AI assessments take, writes a single delegate.batch_trust_evaluated row per run, carrying evaluated_count, narrowed_count, revoked_count, the triggering signal type and whether the cap was hit. It does not write per-delegate rows. The evaluation of a refused call and the five-minute sweep score one delegate at a time and write the per-delegate rows. The scope-change timeline in the Agents tab reads only the four per-delegate actions, so it fills in from those evaluations and from manual restores, and stays empty for a delegate only ever narrowed by a batch run.

Manual restore. Dashboard > Agents, expand a narrowed delegate, press Restore. The call goes to shield-admin-mutations action restore_agent_scopes, which reads original_scopes on the server, writes them back, sets trust_score to 1.0, clears narrowing_reason and scope_narrowed_at, and writes delegate.trust_restored.

Activity recording

Activity rows live in shield_agent_activity and are the only input the signal detector has.

What writes them. Each verify call an active delegate makes on shield-agent-delegate fires one record_activity call to shield-agent-trust with the service-role key, fire-and-forget with a 10-second timeout, so a slow write never delays the agent's answer. Refused calls are recorded too (since 2026-09-29): a scope the delegate does not hold with result scope_violation and the scope it asked for, a call after the delegation expired with result failure and metadata { "reason": "expired" }. A token that matches no active delegate is refused with nothing recorded, because there is no delegate to attribute it to.

{
  "action": "record_activity",
  "tenant_id": "<tenant uuid>",
  "delegate_id": "<uuid>",
  "action_type": "verify",
  "scope_used": "events:read",
  "result": "success",
  "ip_hash": "<keyed digest>"
}

action_type defaults to verify, result defaults to success, and scope_used is null when the caller did not name a scope. The verify path does not send latency_ms, so that column stays null for the rows the platform generates, even though the engine reads it.

IP handling. ip_hash is the HMAC-SHA-256 digest of the first X-Forwarded-For address under the server-held pepper, produced by hashIp in _shared/identifier-hash.ts. Raw addresses are never stored, and an unknown or synthetic address hashes to null rather than to a shared bucket. The ip_rotation signal counts distinct digests, so it works without the platform holding an address.

Response. { "status": "recorded" } for a success. For any other result, shield-agent-trust scores the delegate before it answers and reports what that did, for example { "status": "recorded", "evaluation": { "status": 200, "action": "none", "trust_score": 0.93, "signals": ["scope_violation"], "restored": false } }. An evaluation that could not run (another evaluation wrote first, the delegate was revoked, its history could not be read) reports its status and error there instead, and the row stays recorded for the sweep. A failed insert answers 502 with the database message, which the caller logs and discards.

Reading it back. There is no customer-facing endpoint for activity rows. Their effect is visible as the trust score in the Agents tab and in verify responses, and as the signal list inside an audit row's metadata.

Batch evaluation

evaluate_tenant scores every active delegate in one tenant in a single call. It is service-role only, and it is the path soft signals and AI assessments take, because shield-ingest and shield-intelligence-worker both call it. Refused calls and the scheduled sweep evaluate one delegate at a time.

How it runs. One query fetches up to 200 active delegates with their scores, scopes, original scopes and revocation floors. Activity is then fetched for each one, 100 rows each, 20 queries at a time. Each delegate is assessed independently, and the updates plus one audit row are written back with the same concurrency limit.

Hard signals are refused. A signal_type that classifies hard answers:

{ "status": "hard_signal_bypass", "message": "Hard signals bypass trust engine—use parametric revocation in shield-ingest" }

Response.

{
  "status": "evaluated",
  "evaluated": 12,
  "results": [
    { "delegate_id": "...", "trust_score": 0.85, "action": "none" },
    { "delegate_id": "...", "trust_score": 0.35, "action": "narrow_to_verify" }
  ]
}

A tenant with no active delegates answers { "status": "no_active_delegates", "evaluated": 0 }. When the 200-delegate cap is reached the response also carries _note and the audit row records capped: true, meaning some delegates were not scored on that run.

The AI delta applies tenant-wide. When the intelligence worker supplies ai_trust_delta, that same delta is applied to every delegate in the batch, not only to the one whose behavior prompted the analysis.

Single-delegate evaluation. evaluate with tenant_id and delegate_id scores one delegate and returns status, delegate_id, trust_score, action, signals, narrowed_scopes and reason. An unknown delegate answers 404, an already-revoked one 409. It is on the same service-role lane, so it is an operator tool rather than an API.

Scheduled sweep

Every five minutes pg_cron calls shield-agent-trust with { "action": "evaluate_due" } (job shield-agent-trust-sweep-5m, since 2026-09-29). The call carries the cron secret and nothing else, and that secret reaches no other action.

What it scores. Every active delegate with at least one activity row newer than its watermark (trust_updated_at) and at least 30 seconds old, least recently evaluated first, up to 500 a run. The list comes from shield_agent_trust_due, which only the service role may execute. Each delegate is then scored exactly as a single evaluate scores it, with the same audit rows.

Why the 30 seconds. A refused call is scored as it is recorded, and the nightly attack-vector replay scores the rows it writes within seconds. The sweep takes only activity those evaluations did not, so it never races them.

What it does not do. A delegate with no new activity is not re-scored, and nothing decays with time.

Response and heartbeat.

{ "status": "evaluated", "due": 2, "evaluated": 2, "narrowed": 1, "restored": 0, "revoked": 0, "conflicts": 0, "gone": 0, "failed": 0 }

conflicts counts delegates another evaluation wrote first, gone delegates deleted between the list and the read, and failed evaluations that could not be written; the status is partial when any failed. Their rows stay unjudged for the next run. Each run writes the heartbeat shield-agent-trust-sweep with the same counts, and the meta canary expects it every five minutes. A run that cannot read its list answers 502 and writes no heartbeat, so the canary reports it stale.

mTLS certificate pinning

A delegate can carry pinned TLS certificate fingerprints in shield_agent_certificates, which the A2A handshake checks on initiate, attest and negotiate.

How verification behaves. The presented fingerprint is read from the first header present, in this order: X-Client-Cert-Fingerprint (Cloudflare mTLS), X-SSL-Client-SHA256 (nginx), X-Agent-Cert-Fingerprint (a custom client). It is lowercased with colons and spaces removed before comparison.

Delegate stateOutcome
No pinned certificatesPasses, mTLS is opt-in per agent
Pins exist, no fingerprint presentedFails
Fingerprint presented, no active pin matchesFails
Matching pin whose not_after has passedSkipped, the remaining pins are tried

Both sides of a handshake are verified in parallel and both must pass; a failure answers 403 with code: "MTLS_FAILED" and a per-side reason.

Nothing writes a pin today. The pinning and revocation helpers exist in _shared/mtls-agent-identity.ts, but no edge function action and no dashboard control calls them, and the public API exposes no way to add a fingerprint. With the table empty for every tenant, this check passes unconditionally on every handshake. Treat it as scaffolding that is verified but not yet reachable, rather than as a control you can turn on.

Fingerprint format when the path does become reachable: exactly 64 hexadecimal characters, the SHA-256 digest of the DER certificate.

Agents tab

Dashboard > Agents is where trust scoring is visible. The tab is available on every plan.

Delegate rows. Each row shows the agent label, the first 16 characters of the token digest, a trust gauge, the current scope count and the date the score was last updated. A Revoked badge marks an inactive delegate, a Narrowed badge marks an active one whose current scopes are fewer than its original list.

Gauge labels. The gauge prints the score out of 100 and names the state:

ScoreLabel
0.70 and aboveHealthy
0.40 to 0.69Read-only
0.20 to 0.39Verify-only
Below 0.20Critical

The Critical label is drawn at a fixed 0.20 and does not follow a delegate's own auto_revoke_below, so a delegate with a higher floor is deactivated while the gauge still reads Verify-only.

Expanded row. Current scopes and original scopes as badges, the narrowing reason if one is recorded, the scope-change timeline from the audit log, and a footer with the creation date, the auto-revoke floor, last used and expiry. The Restore control appears only for a narrowed delegate that is still active.

A2A trust negotiations. A collapsible section lists the 50 newest negotiations for the tenant with both agent labels, status, trust state, coefficient gauge, transaction ceiling and per-minute rate. It is read-only; every mutation goes through shield-a2a-handshake, described in A2A trust negotiation.

New delegate. Opens the five-step wizard that calls create. See Agent identity delegation.

Related from the blog