diff --git a/README.md b/README.md index 0be23b7..5495f41 100644 --- a/README.md +++ b/README.md @@ -470,3 +470,13 @@ error responses) that every ShieldLabs server SDK passes. See ## License [MIT](LICENSE) + +### Bot / Agent attribution + +The optional client identity field preserves the stored ingest decision from both +History and webhook data. Claims are unverified names; each verified entry proves +only its named subject through referenced evidence. Provider proof does not +confirm the agent name, service or AI mode. Missing data stays absent, and unknown +string values remain open. This field does not change risk evaluation or flags. +The legacy `search_bot` scoring flag may include allowlisted UA self-identification; +it is not a general cryptographic verification flag. diff --git a/src/shieldlabs/__init__.py b/src/shieldlabs/__init__.py index bf7876f..a1e66e9 100644 --- a/src/shieldlabs/__init__.py +++ b/src/shieldlabs/__init__.py @@ -14,6 +14,7 @@ from . import webhooks from ._client import AsyncShieldLabs, ShieldLabs +from ._client_identity import ClientIdentity from ._errors import ( APIConnectionError, ApiError, @@ -62,6 +63,7 @@ "AsyncShieldLabsManagement", "AuthenticationError", "BadRequestError", + "ClientIdentity", "DetectionFlags", "DomainProfile", "Evaluation", diff --git a/src/shieldlabs/_client_identity.py b/src/shieldlabs/_client_identity.py new file mode 100644 index 0000000..b5f2602 --- /dev/null +++ b/src/shieldlabs/_client_identity.py @@ -0,0 +1,68 @@ +"""Open string types for scoped server attribution; no global verified flag.""" + +from typing import Any, Optional, TypedDict, cast + + +class ClientIdentityClaim(TypedDict, total=False): + profile_id: str + provider_id: str + provider_name: str + agent_name: str + client_kind: str + purpose: str + source: str + + +class ClientIdentityVerified(TypedDict, total=False): + subject: str + value_id: str + evidence_ids: list[str] + + +class ClientIdentityAssessment(TypedDict, total=False): + candidate_profile_id: str + status: str + reason: str + + +class ClientIdentityEvidence(TypedDict, total=False): + id: str + method: str + source_id: str + source_revision: str + checked_at: str + evaluated_at: str + covered_attributes: list[str] + covered_components: list[str] + request_binding: str + replay_policy: str + + +class ClientIdentity(TypedDict, total=False): + schema_version: str + classification_revision: int + classifier_version: str + registry_revision: str + availability: str + observed_at: str + decided_at: str + claims: list[ClientIdentityClaim] + verified: list[ClientIdentityVerified] + assessments: list[ClientIdentityAssessment] + evidence: list[ClientIdentityEvidence] + + +def parse_client_identity(value: Any) -> Optional[ClientIdentity]: + """Pass through stored attribution, retaining unknown values and extra fields.""" + if not isinstance(value, dict): + return None + for key in ("schema_version", "availability", "registry_revision", "observed_at"): + if not isinstance(value.get(key), str): + return None + revision = value.get("classification_revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1: + return None + for key in ("claims", "verified", "assessments", "evidence"): + if not isinstance(value.get(key), list): + return None + return cast(ClientIdentity, value) diff --git a/src/shieldlabs/_models.py b/src/shieldlabs/_models.py index 74172d2..dc1ce98 100644 --- a/src/shieldlabs/_models.py +++ b/src/shieldlabs/_models.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Any, Literal, Optional +from ._client_identity import ClientIdentity, parse_client_identity from ._normalize import ( FLAG_KEYS, HISTORY_FLAG_MAP, @@ -218,6 +219,7 @@ class Identification: """When the identification was observed, as an aware UTC datetime. ``None`` only when the server value could not be parsed.""" source: IdentificationSource + client_identity: Optional[ClientIdentity] = None raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False) """The original webhook ``data`` object or History row, including fields the model omits.""" @@ -268,6 +270,7 @@ def from_webhook_data(cls, data: Mapping[str, Any]) -> Identification: observed_at=parse_rfc3339(data.get("observed_at")), source="webhook", raw=dict(data), + client_identity=parse_client_identity(data.get("client_identity")), ) @classmethod @@ -350,6 +353,7 @@ def from_history_row(cls, row: Mapping[str, Any]) -> Identification: observed_at=parse_history_time(row.get("created_at")), source="history", raw=dict(row), + client_identity=parse_client_identity(row.get("client_identity")), ) @classmethod @@ -389,6 +393,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> Identification: else parse_rfc3339(observed_at), source="history" if source == "history" else "webhook", raw=dict(data), + client_identity=parse_client_identity(data.get("client_identity")), ) def to_dict(self) -> dict[str, Any]: @@ -413,6 +418,11 @@ def to_dict(self) -> dict[str, Any]: "detection_flags": self.detection_flags.to_dict(), "observed_at": format_timestamp(self.observed_at), "source": self.source, + **( + {"client_identity": self.client_identity} + if self.client_identity is not None + else {} + ), } diff --git a/test-data-client-identity.json b/test-data-client-identity.json new file mode 100644 index 0000000..a46fadb --- /dev/null +++ b/test-data-client-identity.json @@ -0,0 +1,48 @@ +{ + "schema_version": "1", + "classification_revision": 1, + "classifier_version": "192.1", + "registry_revision": "2026-10-06.1", + "availability": "available", + "observed_at": "2026-10-06T12:00:00Z", + "decided_at": "2026-10-06T12:00:00Z", + "claims": [ + { + "profile_id": "gptbot", + "provider_id": "openai", + "provider_name": "OpenAI", + "agent_name": "GPTBot", + "client_kind": "crawler", + "purpose": "training", + "source": "http_ua" + } + ], + "verified": [ + { + "subject": "provider", + "value_id": "openai", + "evidence_ids": [ + "ip:fixture" + ] + } + ], + "assessments": [ + { + "status": "unverified", + "reason": "ua_only" + } + ], + "evidence": [ + { + "id": "ip:fixture", + "method": "published_ip", + "source_id": "https://openai.com/gptbot.json", + "source_revision": "fixture-digest", + "checked_at": "2026-10-06T12:00:00Z", + "evaluated_at": "2026-10-06T12:00:00Z", + "covered_attributes": [ + "provider" + ] + } + ] +} diff --git a/tests/test_client_identity.py b/tests/test_client_identity.py new file mode 100644 index 0000000..8819037 --- /dev/null +++ b/tests/test_client_identity.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path + +from shieldlabs import Identification + +FIXTURE = json.loads((Path(__file__).parents[1] / "test-data-client-identity.json").read_text()) + + +def test_identity_parity_and_independent_score(): + history = Identification.from_history_row({"score": 70, "client_identity": FIXTURE}) + webhook = Identification.from_webhook_data({"risk_score": 70, "client_identity": FIXTURE}) + assert history.client_identity == webhook.client_identity + assert history.client_identity is not None + assert history.client_identity["verified"][0]["subject"] == "provider" + assert history.client_identity["claims"][0]["agent_name"] == "GPTBot" + assert history.risk_score == 70 + assert history.to_dict()["client_identity"] == FIXTURE + + +def test_unknown_and_unavailable_identity(): + future = {**FIXTURE, "availability": "future_state", "extra": "kept"} + assert Identification.from_history_row({"client_identity": future}).client_identity == future + for value in (None, "bad", {}): + model = Identification.from_history_row({"client_identity": value}) + assert model.client_identity is None + assert "client_identity" not in model.to_dict()