diff --git a/README.md b/README.md index 9838965..a89661f 100644 --- a/README.md +++ b/README.md @@ -487,3 +487,13 @@ Support: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai). ## 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/clientIdentity.ts b/src/clientIdentity.ts new file mode 100644 index 0000000..9ef5491 --- /dev/null +++ b/src/clientIdentity.ts @@ -0,0 +1,16 @@ +import type { ClientIdentity } from './types.js'; + +/** Preserve stored attribution; missing/malformed optional data stays absent. */ +export function clientIdentityField(value: unknown): { client_identity?: ClientIdentity } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const data = value as Record; + for (const key of ['schema_version', 'availability', 'registry_revision', 'observed_at']) { + if (typeof data[key] !== 'string') return {}; + } + if (typeof data.classification_revision !== 'number' || data.classification_revision < 1) + return {}; + for (const key of ['claims', 'verified', 'assessments', 'evidence']) { + if (!Array.isArray(data[key])) return {}; + } + return { client_identity: data as unknown as ClientIdentity }; +} diff --git a/src/common.ts b/src/common.ts index faafa82..fb83095 100644 --- a/src/common.ts +++ b/src/common.ts @@ -31,6 +31,7 @@ export { export type { ApiErrorOptions, HeadersLike } from './errors.js'; export type { Webhooks } from './webhooks.js'; export type { + ClientIdentity, ConnectionType, DetectionFlags, DomainProfile, diff --git a/src/normalize.ts b/src/normalize.ts index 8d85683..84e488b 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -7,6 +7,7 @@ * The only intentional differences are type guarantees: a value of the wrong JSON type becomes * "" (strings), 0 (weights) or NaN (scores) instead of being passed through. */ +import { clientIdentityField } from './clientIdentity.js'; import { FLAG_KEYS, type FlagKey } from './constants.js'; import type { DetectionFlags, @@ -391,6 +392,7 @@ export function fromHistoryRow(row: JsonObject): Identification { observed_at: parseHistoryTime(own(row, 'created_at')), source: 'history', raw: row, + ...clientIdentityField(own(row, 'client_identity')), }; } @@ -439,6 +441,7 @@ export function fromWebhookData(data: JsonObject): Identification { observed_at: parseRfc3339(own(data, 'observed_at')), source: 'webhook', raw: data, + ...clientIdentityField(own(data, 'client_identity')), }; } diff --git a/src/types.ts b/src/types.ts index 93266c4..3f0498d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,49 @@ +/** Stored scoped server attribution. Claims and verified subjects are separate. */ +export interface ClientIdentity extends Record { + schema_version: string; + classification_revision: number; + classifier_version: string; + registry_revision: string; + availability: string; + observed_at: string; + decided_at: string; + claims: Array<{ + profile_id: string; + provider_id: string; + provider_name: string; + agent_name: string; + client_kind: string; + purpose: string; + source: string; + [key: string]: unknown; + }>; + verified: Array<{ + subject: string; + value_id: string; + evidence_ids: string[]; + [key: string]: unknown; + }>; + assessments: Array<{ + candidate_profile_id?: string; + status: string; + reason: string; + [key: string]: unknown; + }>; + evidence: Array<{ + id: string; + method: string; + source_id: string; + source_revision: string; + checked_at: string; + evaluated_at: string; + covered_attributes: string[]; + covered_components?: string[]; + request_binding?: string; + replay_policy?: string; + [key: string]: unknown; + }>; +} + /** Identifier types the History API can search by. */ export type LookupType = 'ip' | 'user_hid' | 'visitor_id' | 'request_id' | 'device_id' | 'session_id' | 'cookie_id'; @@ -78,6 +124,7 @@ export interface DetectionFlags { * webhook `data` object or a History API row. Property names follow the webhook JSON. */ export interface Identification { + client_identity?: ClientIdentity; /** UUID of the identification, created in the browser. */ request_id: string; /** Server-side visitor identifier (sticky to the device). */ diff --git a/test-data-client-identity.json b/test-data-client-identity.json new file mode 100644 index 0000000..a285348 --- /dev/null +++ b/test-data-client-identity.json @@ -0,0 +1,44 @@ +{ + "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/test/clientIdentity.test.ts b/test/clientIdentity.test.ts new file mode 100644 index 0000000..3233e00 --- /dev/null +++ b/test/clientIdentity.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { fromHistoryRow, fromWebhookData } from '../src/normalize.js'; +const fixture = JSON.parse(readFileSync('test-data-client-identity.json', 'utf8')) as Record< + string, + unknown +>; +describe('scoped client identity', () => { + it('keeps both surfaces equal without promoting provider proof to agent proof', () => { + const history = fromHistoryRow({ score: 70, client_identity: fixture }); + const webhook = fromWebhookData({ risk_score: 70, client_identity: fixture }); + expect(history.client_identity).toEqual(webhook.client_identity); + expect(history.client_identity?.verified[0]?.subject).toBe('provider'); + expect(history.client_identity?.claims[0]?.agent_name).toBe('GPTBot'); + expect(history.risk_score).toBe(70); + expect(history.detection_flags.search_bot).toBe(false); + }); + it('retains future values and leaves malformed/absent data unavailable', () => { + const future = { ...fixture, availability: 'future_state', extra: 'kept' }; + expect(fromHistoryRow({ client_identity: future }).client_identity).toEqual(future); + for (const value of [undefined, null, 'bad', {}]) { + expect(fromHistoryRow({ client_identity: value }).client_identity).toBeUndefined(); + } + }); +});