Agent monitoring and guardrails
How PasskeyBridge scores agent delegate behavior: the seven signals it detects, when evaluation actually runs, how scopes narrow and restore, what activity recording stores, and what the Agents tab shows.
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 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 scheduled trust job and no idle decay, despite the sliders in the dashboard suggesting otherwise. Re-scoring happens only when:
shield-ingestaccepts a soft signal for the tenant, which firesevaluate_tenantfor every active delegate in that tenant, debounced to one call per tenant and signal type per 30 seconds;shield-intelligence-workerproduces anagent_trust_deltaabove 0.05 in absolute value, which firesevaluate_tenantwith that delta applied to every delegate in the tenant.
A delegate in a tenant that never ingests a soft signal keeps its creation score forever, however it behaves. 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.
| Column | Default | Set by |
|---|---|---|
trust_score | 1.000 | initial_trust at creation; the New delegate wizard sends 0.85 |
auto_revoke_below | 0.200 | auto_revoke_below at creation; the wizard allows up to 0.50 and requires it below the starting score |
original_scopes | The scopes given at creation | Copied 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 fixed in the engine and cannot be configured. The Agents tab has a Trust model panel with sliders for decay rate, signal penalties and thresholds; those values are saved in the browser's local storage for that tenant and are read by nothing on the server. They do not change how any delegate is scored.
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.
| Signal | Window | Trigger | Maximum weight |
|---|---|---|---|
velocity_spike | 5 minutes | More than 50 rows | 0.30 |
error_burst | 2 minutes | More than 10 rows whose result is not success | 0.25 |
scope_violation | 24 hours, since the watermark | Any row whose result is scope_violation | 0.35 |
ip_rotation | 10 minutes | More than 5 distinct ip_hash values | 0.20 |
extended_inactivity | 24 hours | Over 24 hours between the newest row and the eleventh, plus more than 10 rows in the 5-minute window | 0.15 flat |
ai_behavioral | Per evaluation | ai_trust_delta is non-zero | 0.30 |
normal_usage | Per evaluation | At least one activity row and no negative signal | adds 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 applied as delta * 0.3, capping the AI's influence at 30 percent of its own figure, and a positive delta raises the score. 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, apply the AI term, clamp to the range 0.000 to 1.000, round to three decimals.
What produces an activity row. Only verify on shield-agent-delegate, 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.
| Score | Action | Scopes written |
|---|---|---|
Below the delegate's auto_revoke_below (default 0.200) | recommend_revocation | Empty, and is_active set to false |
| At least 0.700 | none | Unchanged, or original_scopes restored when a narrowing is on record |
| At least 0.400 | narrow_to_read | Only ingest:read, events:read, playbook:read, intelligence:read and verify that the delegate already held, with verify always added |
| Otherwise | narrow_to_verify | Exactly ["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 0.700 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:
| Action | Written when |
|---|---|
delegate.trust_restored | Score rose with no narrowing |
delegate.trust_degraded | Score fell with no narrowing |
delegate.scope_narrowed | Scopes were rewritten |
delegate.trust_revoked | The delegate was deactivated |
The batch path that production uses 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 scope-change timeline in the Agents tab reads only the four per-delegate actions, so it stays empty for delegates that were only ever narrowed by a batch run, and fills in from manual restores.
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 successful verify 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.
{
"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" }. 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 production actually uses, because shield-ingest and shield-intelligence-worker both call it.
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.
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 state | Outcome |
|---|---|
| No pinned certificates | Passes, mTLS is opt-in per agent |
| Pins exist, no fingerprint presented | Fails |
| Fingerprint presented, no active pin matches | Fails |
Matching pin whose not_after has passed | Skipped, 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:
| Score | Label |
|---|---|
| 0.70 and above | Healthy |
| 0.40 to 0.69 | Read-only |
| 0.20 to 0.39 | Verify-only |
| Below 0.20 | Critical |
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.
Trust model panel. The Trust model button opens sliders for AI influence, decay rate, signal penalties and the three thresholds. Those values are stored in this browser for this tenant and are applied nowhere: the engine's thresholds are constants, and no decay job exists. The panel's own text says the values are saved in this browser only.
New delegate. Opens the five-step wizard that calls create. See Agent identity delegation.
Related from the blog
- How to Benchmark an Identity Verification API: Latency, Freshness, Failure Injectionengineering · 12 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- Just-in-Time Capability Tokens for Agents: Why Persistent OAuth Scopes Are the New Standing Privilegesecurity · 14 min read