diff --git a/README.md b/README.md index 18e67a2..a5c6166 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main/java/ai/shieldlabs/ClientIdentity.java b/src/main/java/ai/shieldlabs/ClientIdentity.java new file mode 100644 index 0000000..eb8f26c --- /dev/null +++ b/src/main/java/ai/shieldlabs/ClientIdentity.java @@ -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 raw; + private ClientIdentity(Map 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 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 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 getClaims() { + List 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 getVerified() { + List 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 getEvidenceIds() { + List 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 getAssessments() { + List 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 getEvidence() { + List 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 getCoveredAttributes() { + List 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 getCoveredComponents() { + List 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; } + } +} diff --git a/src/main/java/ai/shieldlabs/Identification.java b/src/main/java/ai/shieldlabs/Identification.java index 3446474..b85370c 100644 --- a/src/main/java/ai/shieldlabs/Identification.java +++ b/src/main/java/ai/shieldlabs/Identification.java @@ -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; @@ -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 raw() { return raw; } diff --git a/src/test/java/ai/shieldlabs/ClientIdentityTest.java b/src/test/java/ai/shieldlabs/ClientIdentityTest.java new file mode 100644 index 0000000..3a988f2 --- /dev/null +++ b/src/test/java/ai/shieldlabs/ClientIdentityTest.java @@ -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 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(); + } +} diff --git a/src/test/resources/client-identity.json b/src/test/resources/client-identity.json new file mode 100644 index 0000000..a46fadb --- /dev/null +++ b/src/test/resources/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" + ] + } + ] +}