File size: 26,921 Bytes
18cb2e2
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
 
 
683ec2d
 
 
 
 
 
 
 
18cb2e2
 
99b813e
18cb2e2
99b813e
 
18cb2e2
683ec2d
99b813e
18cb2e2
 
 
 
 
 
 
 
 
683ec2d
 
 
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
 
 
 
 
683ec2d
 
 
 
 
 
18cb2e2
 
99b813e
 
 
 
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
99b813e
 
 
 
 
 
18cb2e2
99b813e
 
 
 
 
 
 
 
 
 
 
 
 
683ec2d
 
 
 
 
 
 
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
99b813e
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
99b813e
 
 
 
 
 
18cb2e2
 
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683ec2d
99b813e
 
 
 
 
 
 
 
683ec2d
 
 
99b813e
 
 
683ec2d
99b813e
 
 
 
 
 
 
 
683ec2d
 
 
99b813e
 
18cb2e2
 
 
 
 
 
 
 
 
99b813e
 
18cb2e2
 
 
 
 
 
 
 
 
 
 
 
 
99b813e
18cb2e2
99b813e
18cb2e2
99b813e
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
99b813e
18cb2e2
 
99b813e
 
 
 
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99b813e
18cb2e2
99b813e
18cb2e2
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99b813e
18cb2e2
99b813e
18cb2e2
99b813e
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18cb2e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99b813e
18cb2e2
99b813e
18cb2e2
 
99b813e
18cb2e2
 
 
 
99b813e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
"""RelayShield security tools for smolagents.

Nine agent-specific security checks from RelayShield (https://api.relayshield.net/developers),
a live threat-intelligence API. Mirrors the tools shipped as the "RelayShield Agentic Attack
Surface" MCP server on Hugging Face Spaces
(https://huggingface.co/spaces/relayshieldadmin/relayshield-agentic-attack-surface).

v2 (2026-07-19) β€” response to community feedback on the v1 release: added five new tools
(oauth_watchlist, supply_chain, session_risk, nhi_exposure, secret_scan β€” grouped as an
"agent authority / credential exposure" family alongside the original four), and switched
every tool from a human-formatted string to a typed structured result:

    {
      "outcome": "finding" | "no_known_finding" | "error",
      "recommended_action": "allow" | "review" | "deny" | "defer",
      "reason_codes": [...],
      "evidence": [...],
      "coverage": {"complete": bool, "scope": "what was actually checked"},
      "freshness": {"observed_at": "...", "expires_at": null},
      "error": {"kind": "...", "message": "...", "retryable": bool} | null
    }

"no_known_finding" deliberately does not mean "safe" β€” it means nothing was found in the
sources and scope actually queried. A caller using these as a pre-action gate should check
"error" before trusting "outcome", since a failed check is not the same as a clean one.

Setup:
    pip install smolagents requests

    No signup required to try these tools: if RELAYSHIELD_API_KEY isn't set, they
    fall back automatically to a shared, quota-capped demo key (20 calls/day,
    shared across everyone using the default β€” plenty to try a few tools, not
    enough for production use). Every result carries a "_demo_notice" field
    while the fallback is active, pointing at real signup.

    export RELAYSHIELD_API_KEY="your-key-here"   # optional β€” unlocks your own quota

Usage:
    from relayshield_smolagents_tool import RelayShieldSupplyChainTool

    tool = RelayShieldSupplyChainTool()
    result = tool.forward(vendor_domains=["vendor.example.com"])

Get a key at https://api.relayshield.net/developers?source=hf-smolagents ($499/mo for 10,000 calls,
or pay-per-call via x402 USDC with no key required for the equivalent /v1/payg/* routes).
"""

import os

import requests
from smolagents import Tool

API_BASE_URL: str = "https://api.relayshield.net"

# Shared, quota-capped (20 calls/day, all callers combined) fallback key β€” lets
# these tools work with zero signup. Deliberately a separate, low-privilege key
# from any server-side-only demo key elsewhere in RelayShield's stack: this one
# lives in a public pip package and public HF Space file, so it must be safe to
# have scraped and reused by anyone, hence the shared daily cap rather than an
# unlimited key. Get your own uncapped key at
# https://api.relayshield.net/developers?source=hf-smolagents
_DEMO_API_KEY = "rs_demo_729639c084f7a638"
_DEMO_NOTICE = (
    "This call used RelayShield's free shared demo key (20 calls/day, shared across "
    "everyone using the default β€” no signup required). Get your own key for reliable, "
    "unshared access: https://api.relayshield.net/developers?source=hf-smolagents"
)


def _relayshield_headers(api_key: str) -> dict[str, str]:
    return {"Content-Type": "application/json", "X-RS-API-KEY": api_key}


def _get_api_key() -> str:
    return os.environ.get("RELAYSHIELD_API_KEY") or _DEMO_API_KEY


def _using_demo_key() -> bool:
    return not bool(os.environ.get("RELAYSHIELD_API_KEY"))


def _post(path: str, payload: dict) -> tuple[dict | None, dict | None]:
    """POSTs to a RelayShield metered endpoint. Returns (data, error).

    error is None on success, or a schema-shaped
    {"kind": ..., "message": ..., "retryable": bool} dict on failure β€” distinguishing
    auth/rate_limited/timeout/upstream/malformed_response/other so a caller using these
    tools as a policy gate can tell "the check failed" apart from "the check found nothing."
    """
    api_key = _get_api_key()

    try:
        resp = requests.post(
            f"{API_BASE_URL}{path}",
            json=payload,
            headers=_relayshield_headers(api_key),
            timeout=15,
        )
    except requests.Timeout:
        return None, {
            "kind": "timeout",
            "message": f"RelayShield API call to {path} timed out after 15s.",
            "retryable": True,
        }
    except requests.RequestException as exc:
        return None, {
            "kind": "upstream",
            "message": f"RelayShield API call to {path} failed: {exc}",
            "retryable": True,
        }

    if resp.status_code in (401, 403):
        return None, {
            "kind": "auth",
            "message": f"RelayShield API call to {path} returned {resp.status_code} β€” check RELAYSHIELD_API_KEY.",
            "retryable": False,
        }
    if resp.status_code == 429:
        message = f"RelayShield API call to {path} was rate-limited."
        if _using_demo_key():
            message += (
                " The shared demo key's daily quota (20 calls/day, shared across everyone "
                "using the default) is exhausted for today. Get your own key: "
                "https://api.relayshield.net/developers?source=hf-smolagents"
            )
        return None, {"kind": "rate_limited", "message": message, "retryable": True}
    if resp.status_code >= 500:
        return None, {
            "kind": "upstream",
            "message": f"RelayShield API call to {path} returned {resp.status_code}.",
            "retryable": True,
        }
    if resp.status_code >= 400:
        return None, {
            "kind": "other",
            "message": f"RelayShield API call to {path} returned {resp.status_code}: {resp.text[:200]}",
            "retryable": False,
        }

    try:
        response_json = resp.json()
    except ValueError:
        return None, {
            "kind": "malformed_response",
            "message": f"RelayShield API call to {path} returned non-JSON content.",
            "retryable": False,
        }

    # A valid-but-non-dict JSON body (list/string) is treated as malformed rather than
    # crashing on .get() β€” same class of bug CodeRabbit caught in the CrewAI PR's first
    # review pass on this project's earlier tools.
    if not isinstance(response_json, dict):
        return None, {
            "kind": "malformed_response",
            "message": f"RelayShield API call to {path} returned an unexpected response shape.",
            "retryable": False,
        }

    return response_json.get("data", {}), None


def _outcome_for_severity(highest_severity: str | None, found: bool = True) -> tuple[str, str]:
    """Maps a RelayShield severity string to (outcome, recommended_action).

    CRITICAL escalates to a deny recommendation; HIGH/MEDIUM/LOW are surfaced as findings
    for human/agent review rather than an automatic block, since these are enrichment
    signals, not a certified detection.
    """
    if not found or highest_severity in (None, "NONE", "CLEAN"):
        return "no_known_finding", "allow"
    if highest_severity == "CRITICAL":
        return "finding", "deny"
    return "finding", "review"


def _structured_result(
    outcome: str,
    recommended_action: str,
    *,
    reason_codes: list[str] | None = None,
    evidence: list | None = None,
    scope: str,
    coverage_complete: bool = True,
    observed_at: str | None = None,
) -> dict:
    result = {
        "outcome": outcome,
        "recommended_action": recommended_action,
        "reason_codes": reason_codes or [],
        "evidence": evidence or [],
        "coverage": {"complete": coverage_complete, "scope": scope},
        "freshness": {"observed_at": observed_at, "expires_at": None},
        "error": None,
    }
    if _using_demo_key():
        result["_demo_notice"] = _DEMO_NOTICE
    return result


def _error_result(error: dict) -> dict:
    result = {
        "outcome": "error",
        "recommended_action": "defer",
        "reason_codes": [],
        "evidence": [],
        "coverage": {"complete": False, "scope": ""},
        "freshness": {"observed_at": None, "expires_at": None},
        "error": error,
    }
    if _using_demo_key():
        result["_demo_notice"] = _DEMO_NOTICE
    return result


class RelayShieldMCPRiskTool(Tool):
    """Typosquat / reputation / registration-age risk check for MCP servers
    and agent tool registries, backed by RelayShield's live threat-intel API."""

    name = "relayshield_mcp_registry_risk"
    description = (
        "Checks an MCP server URL or package name for typosquat risk against known MCP "
        "ecosystem domains, presence in RelayShield's criminal IOC corpus, and domain-"
        "registration age. Use this before connecting an agent to an unfamiliar MCP server "
        "or tool registry. Returns a structured result with outcome/recommended_action, "
        "not just a verdict string."
    )
    inputs = {
        "server_url": {
            "type": "string",
            "description": "Full URL of the MCP server to check, e.g. 'https://example.com/mcp'. Provide this or package_name.",
            "nullable": True,
        },
        "package_name": {
            "type": "string",
            "description": "Package name of the MCP server if no server_url is available. Checks are more limited without a server_url.",
            "nullable": True,
        },
    }
    output_type = "object"

    def forward(self, server_url: str | None = None, package_name: str | None = None) -> dict:
        if not server_url and not package_name:
            return _error_result({
                "kind": "other",
                "message": "Provide either server_url or package_name.",
                "retryable": False,
            })

        payload: dict[str, str] = {}
        if server_url:
            payload["server_url"] = server_url
        if package_name:
            payload["package_name"] = package_name

        data, error = _post("/v1/metered/mcp-registry-risk", payload)
        if error:
            return _error_result(error)

        findings = data.get("findings", [])
        outcome, action = _outcome_for_severity(data.get("verdict"), found=bool(findings))
        return _structured_result(
            outcome, action,
            reason_codes=[f.get("type", "unknown") for f in findings],
            evidence=findings,
            scope=f"Checked {data.get('queried', server_url or package_name)!r} against known-MCP-domain typosquat "
                  "distance, RelayShield's criminal IOC corpus, and RDAP registration age.",
        )


class RelayShieldPromptInjectionBreachTool(Tool):
    """Checks whether an email's credentials were exposed via a breach specifically
    sourced from a prompt-injection attack against an AI agent, as opposed to
    traditional phishing/malware-sourced breaches."""

    name = "relayshield_prompt_injection_breach"
    description = (
        "Checks an email address for credential exposure sourced specifically from "
        "prompt-injection attacks against AI agents (distinct from ordinary breach/phishing "
        "sources). Use this to vet an agent identity or user account before granting it "
        "elevated trust or access."
    )
    inputs = {
        "email": {
            "type": "string",
            "description": "Email address to check for credential exposure sourced from prompt-injection attacks against AI agents.",
        },
    }
    output_type = "object"

    def forward(self, email: str) -> dict:
        data, error = _post("/v1/metered/prompt-injection-breach", {"email": email})
        if error:
            return _error_result(error)

        sessions = data.get("sessions", [])
        severities = [s.get("severity") for s in sessions if s.get("severity")]
        highest = max(severities, key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0), default=None)
        outcome, action = _outcome_for_severity(highest, found=data.get("found", False))
        return _structured_result(
            outcome, action,
            reason_codes=["prompt_injection_sourced_breach"] if data.get("found") else [],
            evidence=sessions,
            scope=f"Checked {email} against stolen-session records flagged as prompt-injection-sourced "
                  "(heuristic keyword classifier over dump-announcement text, not confirmed attribution).",
        )


class RelayShieldTechStackCVETool(Tool):
    """Checks a declared technology stack (or a domain's stored stack) against
    actively-exploited CVEs and high-EPSS-score vulnerabilities."""

    name = "relayshield_tech_stack_cve"
    description = (
        "Checks a declared technology stack (e.g. nginx, WordPress, LangChain, CrewAI, n8n) "
        "against actively-exploited CVEs (CISA KEV) and high-EPSS-score vulnerabilities. Covers "
        "AI agent orchestration frameworks and their common companion infrastructure. Use this "
        "before deploying or continuing to run a given technology stack in production."
    )
    inputs = {
        "tech_stack": {
            "type": "array",
            "description": "List of declared technology product names, e.g. ['nginx', 'wordpress', 'cisco ios']. Provide this or domain.",
            "nullable": True,
        },
        "domain": {
            "type": "string",
            "description": "Alternative: pull the declared tech_stack from a domain's stored profile instead of listing it inline.",
            "nullable": True,
        },
    }
    output_type = "object"

    def forward(self, tech_stack: list[str] | None = None, domain: str | None = None) -> dict:
        if not tech_stack and not domain:
            return _error_result({
                "kind": "other",
                "message": "Provide either tech_stack or domain.",
                "retryable": False,
            })

        payload: dict = {}
        if tech_stack:
            payload["tech_stack"] = tech_stack
        if domain:
            payload["domain"] = domain

        data, error = _post("/v1/metered/tech-stack-cve", payload)
        if error:
            return _error_result(error)

        # v1 shipped tool read data["matched_cves"], a key the API has never returned β€”
        # the real keys are all_matches/critical_cves. Fixed here.
        matched = data.get("all_matches", [])
        critical = data.get("critical_cves", [])
        highest = "CRITICAL" if critical else ("HIGH" if matched else None)
        outcome, action = _outcome_for_severity(highest, found=bool(matched))
        return _structured_result(
            outcome, action,
            reason_codes=[c.get("cve_id", "unknown") for c in critical] if critical else [],
            evidence=matched,
            scope=f"Checked {data.get('tech_stack_queried', tech_stack or domain)} against CISA KEV and "
                  "high-EPSS-score CVEs.",
        )


class RelayShieldBulkIdentityRiskTool(Tool):
    """Scores up to 10 organizational domains, each with up to 5 associated agent/
    employee emails, for combined breach/infostealer/session/CVE risk in one call."""

    name = "relayshield_bulk_identity_risk"
    description = (
        "Scores up to 10 organizational domains, each with up to 5 associated agent or "
        "employee emails, for combined breach/infostealer/session/CVE risk in one call. Built "
        "for AI agent governance and identity-posture use cases β€” the entry point for scoring "
        "many identities per organization in one pass rather than N sequential calls."
    )
    inputs = {
        "targets": {
            "type": "array",
            "description": (
                "Up to 10 objects, each shaped like "
                "{'domain': 'acme.com', 'agents': ['ceo@acme.com']} β€” 'agents' is optional, up to 5 per domain."
            ),
        },
    }
    output_type = "object"

    def forward(self, targets: list[dict]) -> dict:
        data, error = _post("/v1/metered/bulk-identity-risk", {"targets": targets})
        if error:
            return _error_result(error)

        results = data.get("results", [])
        critical = data.get("critical_count", 0)
        high = data.get("high_count", 0)
        highest = "CRITICAL" if critical else ("HIGH" if high else None)
        outcome, action = _outcome_for_severity(highest, found=bool(critical or high))
        return _structured_result(
            outcome, action,
            reason_codes=[r.get("domain", "unknown") for r in results
                          if r.get("domain_risk") in ("CRITICAL", "HIGH") or r.get("highest_agent_risk") in ("CRITICAL", "HIGH")],
            evidence=results,
            scope=f"Scored {data.get('queried', len(results))} domain(s) for combined breach/infostealer/session/CVE risk.",
        )


class RelayShieldOAuthWatchlistTool(Tool):
    """Checks an email for OAuth-app exposure via breach history and active
    stealer-log corpus matches β€” connected SaaS apps and delegated access risk."""

    name = "relayshield_oauth_watchlist"
    description = (
        "Checks an email address for OAuth-connected-app exposure: apps matched against breach "
        "history, plus stolen OAuth/session tokens found in criminal stealer logs. Use this to "
        "assess risk inherited from an agent or user's connected SaaS applications and delegated "
        "access before trusting that identity's current authority."
    )
    inputs = {
        "email": {
            "type": "string",
            "description": "Email address to check for OAuth app exposure.",
        },
    }
    output_type = "object"

    def forward(self, email: str) -> dict:
        data, error = _post("/v1/metered/oauth-watchlist", {"email": email})
        if error:
            return _error_result(error)

        matched_apps = data.get("matched_apps", [])
        stolen_tokens = data.get("stolen_tokens", [])
        outcome, action = _outcome_for_severity(
            data.get("highest_severity"), found=bool(matched_apps or stolen_tokens)
        )
        return _structured_result(
            outcome, action,
            reason_codes=(["oauth_app_breach_match"] if matched_apps else [])
                         + (["stolen_oauth_token"] if stolen_tokens else []),
            evidence=matched_apps + stolen_tokens,
            scope=f"Checked {email} against HIBP breach history for OAuth-app matches and RelayShield's "
                  "stealer-log corpus for stolen session/OAuth tokens.",
            observed_at=data.get("checked_at"),
        )


class RelayShieldSupplyChainTool(Tool):
    """Checks up to 10 vendor domains for breach/infostealer exposure β€” third-party
    and supply-chain risk inherited through vendor relationships."""

    name = "relayshield_supply_chain"
    description = (
        "Checks up to 10 vendor domains (or vendor emails) for breach and infostealer exposure, "
        "returning a per-vendor risk level and an aggregate dark-web exposure score. Use this to "
        "vet third-party vendors, MCP server operators, or supply-chain dependencies before an "
        "agent integrates with or grants access to them."
    )
    inputs = {
        "vendor_domains": {
            "type": "array",
            "description": "Up to 10 vendor domains to check, e.g. ['vendor.example.com']. Provide this or vendor_emails.",
            "nullable": True,
        },
        "vendor_emails": {
            "type": "array",
            "description": "Alternative: vendor email addresses β€” the domain portion is extracted automatically.",
            "nullable": True,
        },
    }
    output_type = "object"

    def forward(self, vendor_domains: list[str] | None = None, vendor_emails: list[str] | None = None) -> dict:
        if not vendor_domains and not vendor_emails:
            return _error_result({
                "kind": "other",
                "message": "Provide either vendor_domains or vendor_emails.",
                "retryable": False,
            })

        payload: dict = {}
        if vendor_domains:
            payload["vendor_domains"] = vendor_domains
        if vendor_emails:
            payload["vendor_emails"] = vendor_emails

        data, error = _post("/v1/metered/supply-chain", payload)
        if error:
            return _error_result(error)

        results = data.get("results", [])
        highest = data.get("highest_risk")
        outcome, action = _outcome_for_severity(highest if highest != "CLEAN" else None, found=highest not in (None, "CLEAN"))
        return _structured_result(
            outcome, action,
            reason_codes=data.get("critical_vendors", []) + data.get("high_risk_vendors", []),
            evidence=results,
            scope=f"Checked {data.get('domains_checked', 0)} vendor domain(s) for breach and infostealer exposure.",
            observed_at=data.get("checked_at"),
        )


class RelayShieldSessionRiskTool(Tool):
    """Checks an email for active/reusable stolen session material found in
    RelayShield's stealer-log corpus β€” session hijack and AiTM (adversary-in-the-middle)
    exposure that can bypass normal authentication controls."""

    name = "relayshield_session_risk"
    description = (
        "Checks an email address for active or reusable stolen session material (cookies, "
        "tokens) found in criminal stealer logs β€” exposure that can bypass MFA/authentication "
        "entirely, not just a password. Use this to assess whether an agent or user identity "
        "currently has hijackable sessions in circulation."
    )
    inputs = {
        "email": {
            "type": "string",
            "description": "Email address to check for active session/AiTM exposure.",
        },
    }
    output_type = "object"

    def forward(self, email: str) -> dict:
        data, error = _post("/v1/metered/session-risk", {"email": email})
        if error:
            return _error_result(error)

        sessions = data.get("sessions", [])
        outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
        return _structured_result(
            outcome, action,
            reason_codes=[s.get("service_category", "unknown") for s in sessions],
            evidence=sessions,
            scope=f"Checked {email} against RelayShield's stolen-session corpus for reusable session/cookie material.",
        )


class RelayShieldNHIExposureTool(Tool):
    """Checks a domain (or vendor domains) for exposed non-human-identity credentials β€”
    API keys, service-account tokens, PATs, and other machine credentials found in
    criminal stealer logs."""

    name = "relayshield_nhi_exposure"
    description = (
        "Checks a domain, or up to 10 vendor domains, for exposed non-human-identity (NHI) "
        "credentials β€” API keys, service-account tokens, personal access tokens, and other "
        "machine identities found in criminal stealer logs. Use this to assess exposure of the "
        "machine credentials an agent or its supply chain actually runs on, not just human logins."
    )
    inputs = {
        "domain": {
            "type": "string",
            "description": "Your own domain to check. Provide this or vendor_domains (or both).",
            "nullable": True,
        },
        "vendor_domains": {
            "type": "array",
            "description": "Up to 10 vendor/supply-chain domains to check.",
            "nullable": True,
        },
    }
    output_type = "object"

    def forward(self, domain: str | None = None, vendor_domains: list[str] | None = None) -> dict:
        if not domain and not vendor_domains:
            return _error_result({
                "kind": "other",
                "message": "Provide either domain or vendor_domains.",
                "retryable": False,
            })

        payload: dict = {}
        if domain:
            payload["domain"] = domain
        if vendor_domains:
            payload["vendor_domains"] = vendor_domains

        data, error = _post("/v1/metered/nhi-exposure", payload)
        if error:
            return _error_result(error)

        findings = data.get("findings", [])
        outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
        return _structured_result(
            outcome, action,
            reason_codes=[f.get("type", "unknown") for f in findings],
            evidence=findings,
            scope=f"Checked {data.get('domains_checked', 0)} domain(s) against RelayShield's stealer-log "
                  "corpus for exposed non-human-identity credentials.",
            observed_at=data.get("checked_at"),
        )


class RelayShieldSecretScanTool(Tool):
    """Checks a domain (or vendor domains) for secrets/credentials exposed in
    public GitHub/GitLab repositories."""

    name = "relayshield_secret_scan"
    description = (
        "Checks a domain, or up to 5 vendor domains, for secrets and credentials exposed in "
        "public GitHub/GitLab repositories. Use this to assess whether an agent's own domain, or "
        "a vendor/supply-chain dependency's domain, has leaked credentials sitting in indexed "
        "public source code."
    )
    inputs = {
        "domain": {
            "type": "string",
            "description": "Your own domain to check. Provide this or vendor_domains (or both).",
            "nullable": True,
        },
        "vendor_domains": {
            "type": "array",
            "description": "Up to 5 vendor domains to check.",
            "nullable": True,
        },
    }
    output_type = "object"

    def forward(self, domain: str | None = None, vendor_domains: list[str] | None = None) -> dict:
        if not domain and not vendor_domains:
            return _error_result({
                "kind": "other",
                "message": "Provide either domain or vendor_domains.",
                "retryable": False,
            })

        payload: dict = {}
        if domain:
            payload["domain"] = domain
        if vendor_domains:
            payload["vendor_domains"] = vendor_domains

        data, error = _post("/v1/metered/secret-scan", payload)
        if error:
            return _error_result(error)

        findings = data.get("findings", [])
        outcome, action = _outcome_for_severity(data.get("highest_severity"), found=data.get("found", False))
        return _structured_result(
            outcome, action,
            reason_codes=[f.get("severity", "unknown") for f in findings],
            evidence=findings,
            scope=f"Checked {data.get('domains_checked', 0)} domain(s) against public GitHub/GitLab "
                  "repositories for exposed secrets.",
            observed_at=data.get("checked_at"),
        )