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 @@ -371,3 +371,13 @@ Documentation: [docs.shieldlabs.ai](https://docs.shieldlabs.ai). Support: [conta
## License

[MIT](LICENSE). Copyright (c) 2026 ShieldLabs Inc.

### 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.
114 changes: 114 additions & 0 deletions src/main/java/ai/shieldlabs/ClientIdentity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package ai.shieldlabs;

import com.fasterxml.jackson.annotation.JsonValue;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Stored server attribution: a verified provider does not verify a claimed agent or AI mode. */
public final class ClientIdentity {
private final Map<String, Object> raw;
private ClientIdentity(Map<String, Object> raw) { this.raw = Collections.unmodifiableMap(new LinkedHashMap<>(raw)); }

static ClientIdentity fromValue(Object value) {
if (!(value instanceof Map)) return null;
Map<?, ?> object = (Map<?, ?>) value;
for (String key : new String[] {"schema_version", "availability", "registry_revision", "observed_at"}) {
if (!(object.get(key) instanceof String)) return null;
}
if (!(object.get("classification_revision") instanceof Number) || ((Number) object.get("classification_revision")).longValue() < 1) return null;
for (String key : new String[] {"claims", "verified", "assessments", "evidence"}) {
if (!(object.get(key) instanceof List)) return null;
for (Object entry : (List<?>) object.get(key)) if (!(entry instanceof Map)) return null;
}
Map<String, Object> result = new LinkedHashMap<>();
object.forEach((key, item) -> { if (key instanceof String) result.put((String) key, item); });
return new ClientIdentity(result);
}
/** Original object, including unknown future values. */
@JsonValue public Map<String, Object> raw() { return raw; }
public String getSchemaVersion() { return (String) raw.get("schema_version"); }
public long getClassificationRevision() { return ((Number) raw.get("classification_revision")).longValue(); }
public String getClassifierVersion() { Object value = raw.get("classifier_version"); return value instanceof String ? (String) value : null; }
public String getRegistryRevision() { return (String) raw.get("registry_revision"); }
public String getAvailability() { return (String) raw.get("availability"); }
public String getObservedAt() { return (String) raw.get("observed_at"); }
public String getDecidedAt() { Object value = raw.get("decided_at"); return value instanceof String ? (String) value : null; }
public List<Claim> getClaims() {
List<Claim> result = new ArrayList<>();
for (Object value : (List<?>) raw.get("claims")) result.add(new Claim((Map<?, ?>) value));
return Collections.unmodifiableList(result);
}
public static final class Claim {
private final Map<?, ?> raw;
private Claim(Map<?, ?> raw) { this.raw = Collections.unmodifiableMap(new LinkedHashMap<>(raw)); }
@JsonValue public Map<?, ?> raw() { return raw; }
public String getProfileId() { Object value=raw.get("profile_id"); return value instanceof String ? (String) value : null; }
public String getProviderId() { Object value=raw.get("provider_id"); return value instanceof String ? (String) value : null; }
public String getProviderName() { Object value=raw.get("provider_name"); return value instanceof String ? (String) value : null; }
public String getAgentName() { Object value=raw.get("agent_name"); return value instanceof String ? (String) value : null; }
public String getClientKind() { Object value=raw.get("client_kind"); return value instanceof String ? (String) value : null; }
public String getPurpose() { Object value=raw.get("purpose"); return value instanceof String ? (String) value : null; }
public String getSource() { Object value=raw.get("source"); return value instanceof String ? (String) value : null; }
}
public List<Verified> getVerified() {
List<Verified> result = new ArrayList<>();
for (Object value : (List<?>) raw.get("verified")) result.add(new Verified((Map<?, ?>) value));
return Collections.unmodifiableList(result);
}
public static final class Verified {
private final Map<?, ?> raw;
private Verified(Map<?, ?> raw) { this.raw = Collections.unmodifiableMap(new LinkedHashMap<>(raw)); }
@JsonValue public Map<?, ?> raw() { return raw; }
public String getSubject() { Object value=raw.get("subject"); return value instanceof String ? (String) value : null; }
public String getValueId() { Object value=raw.get("value_id"); return value instanceof String ? (String) value : null; }
public List<String> getEvidenceIds() {
List<String> result = new ArrayList<>(); Object value = raw.get("evidence_ids");
if (value instanceof List) for (Object item : (List<?>) value) if (item instanceof String) result.add((String) item);
return Collections.unmodifiableList(result);
}
}
public List<Assessment> getAssessments() {
List<Assessment> result = new ArrayList<>();
for (Object value : (List<?>) raw.get("assessments")) result.add(new Assessment((Map<?, ?>) value));
return Collections.unmodifiableList(result);
}
public static final class Assessment {
private final Map<?, ?> raw;
private Assessment(Map<?, ?> raw) { this.raw = Collections.unmodifiableMap(new LinkedHashMap<>(raw)); }
@JsonValue public Map<?, ?> raw() { return raw; }
public String getCandidateProfileId() { Object value=raw.get("candidate_profile_id"); return value instanceof String ? (String) value : null; }
public String getStatus() { Object value=raw.get("status"); return value instanceof String ? (String) value : null; }
public String getReason() { Object value=raw.get("reason"); return value instanceof String ? (String) value : null; }
}
public List<Evidence> getEvidence() {
List<Evidence> result = new ArrayList<>();
for (Object value : (List<?>) raw.get("evidence")) result.add(new Evidence((Map<?, ?>) value));
return Collections.unmodifiableList(result);
}
public static final class Evidence {
private final Map<?, ?> raw;
private Evidence(Map<?, ?> raw) { this.raw = Collections.unmodifiableMap(new LinkedHashMap<>(raw)); }
@JsonValue public Map<?, ?> raw() { return raw; }
public String getId() { Object value=raw.get("id"); return value instanceof String ? (String) value : null; }
public String getMethod() { Object value=raw.get("method"); return value instanceof String ? (String) value : null; }
public String getSourceId() { Object value=raw.get("source_id"); return value instanceof String ? (String) value : null; }
public String getSourceRevision() { Object value=raw.get("source_revision"); return value instanceof String ? (String) value : null; }
public String getCheckedAt() { Object value=raw.get("checked_at"); return value instanceof String ? (String) value : null; }
public String getEvaluatedAt() { Object value=raw.get("evaluated_at"); return value instanceof String ? (String) value : null; }
public List<String> getCoveredAttributes() {
List<String> result = new ArrayList<>(); Object value = raw.get("covered_attributes");
if (value instanceof List) for (Object item : (List<?>) value) if (item instanceof String) result.add((String) item);
return Collections.unmodifiableList(result);
}
public List<String> getCoveredComponents() {
List<String> result = new ArrayList<>(); Object value = raw.get("covered_components");
if (value instanceof List) for (Object item : (List<?>) value) if (item instanceof String) result.add((String) item);
return Collections.unmodifiableList(result);
}
public String getRequestBinding() { Object value=raw.get("request_binding"); return value instanceof String ? (String) value : null; }
public String getReplayPolicy() { Object value=raw.get("replay_policy"); return value instanceof String ? (String) value : null; }
}
}
7 changes: 7 additions & 0 deletions src/main/java/ai/shieldlabs/Identification.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonValue;
import java.time.Instant;
Expand Down Expand Up @@ -378,6 +379,12 @@ public Source getSource() {
*
* @return an unmodifiable map
*/
@JsonProperty("client_identity")
@JsonInclude(JsonInclude.Include.NON_NULL)
public ClientIdentity getClientIdentity() {
return ClientIdentity.fromValue(raw.get("client_identity"));
}

public Map<String, Object> raw() {
return raw;
}
Expand Down
35 changes: 35 additions & 0 deletions src/test/java/ai/shieldlabs/ClientIdentityTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package ai.shieldlabs;

import static org.junit.jupiter.api.Assertions.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;

class ClientIdentityTest {
@Test void keepsScopedProofAndUnknownValues() throws Exception {
ObjectMapper mapper = new ObjectMapper();
@SuppressWarnings("unchecked")
Map<String, Object> fixture = mapper.readValue(getClass().getResourceAsStream("/client-identity.json"), Map.class);
Identification history = Identification.fromHistoryRow(Map.of("score", 70, "client_identity", fixture));
Identification webhook = Identification.fromWebhookData(Map.of("risk_score", 70, "client_identity", fixture));
assertEquals(history.getClientIdentity().raw(), webhook.getClientIdentity().raw());
assertEquals("provider", history.getClientIdentity().getVerified().get(0).getSubject());
assertEquals("GPTBot", history.getClientIdentity().getClaims().get(0).getAgentName());
assertEquals(70, history.getRiskScore());
assertEquals(fixture, mapper.convertValue(history, Map.class).get("client_identity"));
fixture = new LinkedHashMap<>(fixture); fixture.put("availability", "future_state"); fixture.put("extra", "kept");
ClientIdentity future = Identification.fromHistoryRow(Map.of("client_identity", fixture)).getClientIdentity();
assertEquals("future_state", future.getAvailability()); assertEquals("kept", future.raw().get("extra"));
assertThrows(UnsupportedOperationException.class, () -> future.raw().put("bad", true));
assertNull(Identification.fromHistoryRow(Map.of()).getClientIdentity());
assertNull(Identification.fromHistoryRow(Map.of("client_identity", "bad")).getClientIdentity());
assertFalse(mapper.convertValue(Identification.fromHistoryRow(Map.of()), Map.class).containsKey("client_identity"));
assertNotNull(future.getSchemaVersion()); assertEquals(1, future.getClassificationRevision());
assertNotNull(future.getClassifierVersion()); assertNotNull(future.getRegistryRevision()); assertNotNull(future.getObservedAt()); assertNotNull(future.getDecidedAt());
future.getClaims().get(0).getProviderId(); future.getClaims().get(0).getProfileId(); future.getClaims().get(0).getProviderName(); future.getClaims().get(0).getClientKind(); future.getClaims().get(0).getPurpose(); future.getClaims().get(0).getSource();
future.getVerified().get(0).getValueId(); future.getVerified().get(0).getEvidenceIds(); future.getVerified().get(0).raw();
future.getAssessments().get(0).getStatus(); future.getAssessments().get(0).getReason(); future.getAssessments().get(0).getCandidateProfileId(); future.getAssessments().get(0).raw();
ClientIdentity.Evidence evidence=future.getEvidence().get(0); evidence.getId(); evidence.getMethod(); evidence.getSourceId(); evidence.getSourceRevision(); evidence.getCheckedAt(); evidence.getEvaluatedAt(); evidence.getCoveredAttributes(); evidence.getCoveredComponents(); evidence.getRequestBinding(); evidence.getReplayPolicy(); evidence.raw();
}
}
48 changes: 48 additions & 0 deletions src/test/resources/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"
]
}
]
}
Loading