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 @@ -461,3 +461,13 @@ docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && com
## 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.
69 changes: 69 additions & 0 deletions src/Model/ClientIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace ShieldLabs\Model;

/** Scoped server attribution. Provider proof does not verify a claimed agent. */
final class ClientIdentity implements \JsonSerializable
{
/**
* @param list<array<mixed>> $claims
* @param list<array<mixed>> $verified Each subject/value has its own evidence_ids.
* @param list<array<mixed>> $assessments
* @param list<array<mixed>> $evidence
* @param array<mixed> $raw Original object, including future fields.
*/
private function __construct(
public readonly string $schema_version,
public readonly int $classification_revision,
public readonly string $availability,
public readonly string $registry_revision,
public readonly string $observed_at,
public readonly array $claims,
public readonly array $verified,
public readonly array $assessments,
public readonly array $evidence,
public readonly array $raw,
) {}

public static function fromValue(mixed $value): ?self
{
if (!\is_array($value)) {
return null;
}
foreach (['schema_version', 'availability', 'registry_revision', 'observed_at'] as $key) {
if (!isset($value[$key]) || !\is_string($value[$key])) {
return null;
}
}
if (!isset($value['classification_revision']) || !\is_int($value['classification_revision']) || $value['classification_revision'] < 1) {
return null;
}
foreach (['claims', 'verified', 'assessments', 'evidence'] as $key) {
if (!isset($value[$key]) || !\is_array($value[$key]) || !array_is_list($value[$key])) {
return null;
}
foreach ($value[$key] as $entry) {
if (!\is_array($entry)) {
return null;
}
}
}
/** @var list<array<mixed>> $claims */
$claims = $value['claims'];
/** @var list<array<mixed>> $verified */
$verified = $value['verified'];
/** @var list<array<mixed>> $assessments */
$assessments = $value['assessments'];
/** @var list<array<mixed>> $evidence */
$evidence = $value['evidence'];
return new self($value['schema_version'], $value['classification_revision'], $value['availability'], $value['registry_revision'], $value['observed_at'], $claims, $verified, $assessments, $evidence, $value);
}

/** @return array<mixed> */
public function jsonSerialize(): array
{
return $this->raw;
}
}
3 changes: 3 additions & 0 deletions src/Model/Identification.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function __construct(
public readonly ?\DateTimeImmutable $observed_at,
public readonly string $source,
public readonly array $raw = [],
public readonly ?ClientIdentity $client_identity = null,
) {}

/**
Expand Down Expand Up @@ -132,6 +133,7 @@ public function toArray(): array
'detection_flags' => $this->detection_flags->toArray(),
'observed_at' => Time::format($this->observed_at),
'source' => $this->source,
...($this->client_identity !== null ? ['client_identity' => $this->client_identity->jsonSerialize()] : []),
];
}

Expand Down Expand Up @@ -193,6 +195,7 @@ private static function fromNormalized(array $normalized, array $raw): self
observed_at: $normalized['observed_at'],
source: $normalized['source'],
raw: $raw,
client_identity: ClientIdentity::fromValue($raw['client_identity'] ?? null),
);
}
}
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"
]
}
]
}
31 changes: 31 additions & 0 deletions tests/ClientIdentityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace ShieldLabs\Tests;

use PHPUnit\Framework\TestCase;
use ShieldLabs\Model\Identification;

final class ClientIdentityTest extends TestCase
{
public function testScopeParityAndUnknownValues(): void
{
$fixture = json_decode(file_get_contents(__DIR__ . '/../test-data-client-identity.json'), true, 512, \JSON_THROW_ON_ERROR);
$history = Identification::fromHistoryRow(['score' => 70, 'client_identity' => $fixture]);
$webhook = Identification::fromWebhookData(['risk_score' => 70, 'client_identity' => $fixture]);
self::assertEquals($history->client_identity, $webhook->client_identity);
self::assertSame('provider', $history->client_identity->verified[0]['subject']);
self::assertSame('GPTBot', $history->client_identity->claims[0]['agent_name']);
self::assertSame(70, $history->risk_score);
self::assertSame($fixture, $history->toArray()['client_identity']);
$fixture['availability'] = 'future_state';
$fixture['extra'] = 'kept';
self::assertSame($fixture, Identification::fromHistoryRow(['client_identity' => $fixture])->toArray()['client_identity']);
foreach ([null, 'bad', []] as $value) {
$id = Identification::fromHistoryRow(['client_identity' => $value]);
self::assertNull($id->client_identity);
self::assertArrayNotHasKey('client_identity', $id->toArray());
}
}
}
Loading