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 @@ -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.
16 changes: 16 additions & 0 deletions src/clientIdentity.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 };
}
1 change: 1 addition & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export {
export type { ApiErrorOptions, HeadersLike } from './errors.js';
export type { Webhooks } from './webhooks.js';
export type {
ClientIdentity,
ConnectionType,
DetectionFlags,
DomainProfile,
Expand Down
3 changes: 3 additions & 0 deletions src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')),
};
}

Expand Down Expand Up @@ -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')),
};
}

Expand Down
47 changes: 47 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,49 @@
/** Stored scoped server attribution. Claims and verified subjects are separate. */
export interface ClientIdentity extends Record<string, unknown> {
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';
Expand Down Expand Up @@ -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). */
Expand Down
44 changes: 44 additions & 0 deletions test-data-client-identity.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
25 changes: 25 additions & 0 deletions test/clientIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading