Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions src/shieldlabs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from . import webhooks
from ._client import AsyncShieldLabs, ShieldLabs
from ._client_identity import ClientIdentity
from ._errors import (
APIConnectionError,
ApiError,
Expand Down Expand Up @@ -62,6 +63,7 @@
"AsyncShieldLabsManagement",
"AuthenticationError",
"BadRequestError",
"ClientIdentity",
"DetectionFlags",
"DomainProfile",
"Evaluation",
Expand Down
68 changes: 68 additions & 0 deletions src/shieldlabs/_client_identity.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions src/shieldlabs/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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 {}
),
}


Expand Down
48 changes: 48 additions & 0 deletions test-data-client-identity.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
26 changes: 26 additions & 0 deletions tests/test_client_identity.py
Original file line number Diff line number Diff line change
@@ -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()
Loading