Skip to main content

systemprompt_models/origin/
classify.rs

1//! The evidence ladder: one pure function from what the wire carried to a
2//! `client_kind`, its attestation tier, and the evidence row.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use super::evidence::{
8    DECLARED_CLIENT_MAX, SDK_FIELD_MAX, UA_PRODUCT_MAX, UA_VERSION_MAX, bounded,
9};
10use super::{ClientAttestation, ClientEvidence, ClientKind, NativeMarker};
11
12/// The `x-stainless-*` headers every Stainless-generated SDK sends.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub struct StainlessHeaders<'a> {
15    pub lang: Option<&'a str>,
16    pub package_version: Option<&'a str>,
17    pub runtime: Option<&'a str>,
18    pub runtime_version: Option<&'a str>,
19    pub os: Option<&'a str>,
20    pub arch: Option<&'a str>,
21}
22
23/// Raw inputs to [`classify`]. `principal_is_bridge` is the only fact that
24/// comes from authentication rather than the request itself.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct ClassificationInput<'a> {
27    pub principal_is_bridge: bool,
28    pub declared_client: Option<&'a str>,
29    pub declared_attestation: Option<&'a str>,
30    pub user_agent: Option<&'a str>,
31    pub stainless: StainlessHeaders<'a>,
32    pub body: &'a [u8],
33}
34
35/// `conflicting` is set when a weaker signal named a different client than
36/// the winning tier; the caller logs it, nothing rejects it.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Classified {
39    pub client: ClientKind,
40    pub attestation: ClientAttestation,
41    pub evidence: ClientEvidence,
42    pub conflicting: bool,
43}
44
45/// A request the gateway refuses with `400`. The evidence gathered before the
46/// rejection rides along so the rejected row still records what was sent.
47#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48pub enum ClassificationRejection {
49    #[error("x-systemprompt-client must be one of {}", declarable_vocabulary())]
50    MalformedDeclaredClient { evidence: Box<ClientEvidence> },
51    #[error("x-systemprompt-client-attestation is set by the bridge only")]
52    AttestationNotFromBridge { evidence: Box<ClientEvidence> },
53    #[error("x-systemprompt-client-attestation must be host-token or bridge-secret")]
54    MalformedAttestation { evidence: Box<ClientEvidence> },
55    #[error("host-token attestation requires x-systemprompt-client")]
56    HostTokenWithoutClient { evidence: Box<ClientEvidence> },
57}
58
59impl ClassificationRejection {
60    #[must_use]
61    pub const fn evidence(&self) -> &ClientEvidence {
62        match self {
63            Self::MalformedDeclaredClient { evidence }
64            | Self::AttestationNotFromBridge { evidence }
65            | Self::MalformedAttestation { evidence }
66            | Self::HostTokenWithoutClient { evidence } => evidence,
67        }
68    }
69}
70
71fn declarable_vocabulary() -> String {
72    ClientKind::DECLARABLE
73        .iter()
74        .map(|kind| kind.as_str())
75        .collect::<Vec<_>>()
76        .join(", ")
77}
78
79enum Channel {
80    HostToken,
81    BridgeSecret,
82}
83
84fn initial_evidence(
85    input: &ClassificationInput<'_>,
86    marker: Option<NativeMarker>,
87    ua: Option<&(String, Option<String>)>,
88) -> ClientEvidence {
89    ClientEvidence {
90        kind_source: ClientAttestation::None,
91        attested_host: None,
92        declared_client: bounded(input.declared_client, DECLARED_CLIENT_MAX),
93        native_marker: marker,
94        ua_product: ua.and_then(|(p, _)| bounded(Some(p), UA_PRODUCT_MAX)),
95        ua_version: ua.and_then(|(_, v)| bounded(v.as_deref(), UA_VERSION_MAX)),
96        sdk_lang: bounded(input.stainless.lang, SDK_FIELD_MAX),
97        sdk_package_version: bounded(input.stainless.package_version, SDK_FIELD_MAX),
98        sdk_runtime: bounded(input.stainless.runtime, SDK_FIELD_MAX),
99        sdk_runtime_version: bounded(input.stainless.runtime_version, SDK_FIELD_MAX),
100        sdk_os: bounded(input.stainless.os, SDK_FIELD_MAX),
101        sdk_arch: bounded(input.stainless.arch, SDK_FIELD_MAX),
102    }
103}
104
105pub fn classify(input: &ClassificationInput<'_>) -> Result<Classified, ClassificationRejection> {
106    let marker = native_marker(input.body);
107    let ua = ua_product(input.user_agent);
108    let ua_client = ua
109        .as_ref()
110        .and_then(|(product, _)| ClientKind::from_ua_product(product));
111    let mut evidence = initial_evidence(input, marker, ua.as_ref());
112
113    let channel = match (input.principal_is_bridge, input.declared_attestation) {
114        (_, None) => None,
115        (false, Some(_)) => {
116            return Err(ClassificationRejection::AttestationNotFromBridge {
117                evidence: Box::new(evidence),
118            });
119        },
120        (true, Some(value)) => match ClientAttestation::parse(value) {
121            Ok(ClientAttestation::HostToken) => Some(Channel::HostToken),
122            Ok(ClientAttestation::BridgeSecret) => Some(Channel::BridgeSecret),
123            _ => {
124                return Err(ClassificationRejection::MalformedAttestation {
125                    evidence: Box::new(evidence),
126                });
127            },
128        },
129    };
130
131    let declared = match input.declared_client {
132        None => None,
133        Some(value) => match ClientKind::DECLARABLE
134            .into_iter()
135            .find(|kind| kind.as_str() == value)
136        {
137            Some(kind) => Some(kind),
138            None => {
139                return Err(ClassificationRejection::MalformedDeclaredClient {
140                    evidence: Box::new(evidence),
141                });
142            },
143        },
144    };
145
146    let (client, source) = match channel {
147        Some(Channel::HostToken) => {
148            let Some(host) = declared else {
149                return Err(ClassificationRejection::HostTokenWithoutClient {
150                    evidence: Box::new(evidence),
151                });
152            };
153            evidence.attested_host = Some(host);
154            (host, ClientAttestation::HostToken)
155        },
156        _ => declared
157            .map(|kind| (kind, ClientAttestation::Declared))
158            .or_else(|| marker.map(|m| (m.client(), ClientAttestation::NativeMarker)))
159            .or_else(|| ua_client.map(|kind| (kind, ClientAttestation::UserAgent)))
160            .unwrap_or((ClientKind::Other, ClientAttestation::None)),
161    };
162    evidence.kind_source = source;
163    let attestation = match channel {
164        Some(Channel::BridgeSecret) => ClientAttestation::BridgeSecret,
165        _ => source,
166    };
167    let conflicting = [declared, marker.map(NativeMarker::client), ua_client]
168        .into_iter()
169        .flatten()
170        .any(|named| !named.same_runtime(client));
171
172    Ok(Classified {
173        client,
174        attestation,
175        evidence,
176        conflicting,
177    })
178}
179
180// JSON: protocol boundary — the inference body is any host's wire shape.
181#[must_use]
182pub fn native_marker(body: &[u8]) -> Option<NativeMarker> {
183    if body.is_empty() {
184        return None;
185    }
186    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
187    if value
188        .pointer("/client_metadata/x-codex-turn-metadata")
189        .is_some()
190    {
191        return Some(NativeMarker::CodexTurnMetadata);
192    }
193    claude_entrypoint(&value).or_else(|| claude_metadata_user_id(&value))
194}
195
196// Why: Claude Code puts the billing header in the first system block on a
197// third-party gateway; a Cowork session runs the same runtime as the CLI and
198// this prefix is the one place the body says which of the two it is.
199// JSON: Anthropic Messages request — probed for Claude Code markers.
200fn claude_entrypoint(value: &serde_json::Value) -> Option<NativeMarker> {
201    let system = value.get("system")?;
202    let text = match system {
203        serde_json::Value::String(text) => text.as_str(),
204        serde_json::Value::Array(blocks) => blocks.first()?.get("text")?.as_str()?,
205        _ => return None,
206    };
207    let rest = text
208        .trim_start()
209        .strip_prefix("x-anthropic-billing-header:")?;
210    let entrypoint = rest
211        .split(';')
212        .map(str::trim)
213        .find_map(|field| field.strip_prefix("cc_entrypoint="))?;
214    match entrypoint {
215        "cli" => Some(NativeMarker::ClaudeCliEntrypoint),
216        "local-agent" => Some(NativeMarker::ClaudeDesktopEntrypoint),
217        entry if entry.starts_with(ClientKind::ClaudeDesktop.as_str()) => {
218            Some(NativeMarker::ClaudeDesktopEntrypoint)
219        },
220        _ => None,
221    }
222}
223
224// JSON: Anthropic Messages request — probed for Claude Code markers.
225fn claude_metadata_user_id(value: &serde_json::Value) -> Option<NativeMarker> {
226    let user_id = value.pointer("/metadata/user_id")?.as_str()?.trim();
227    if user_id.starts_with('{') {
228        // JSON: protocol boundary — Claude Code ≥ 2.1.25x stamps its session
229        // as a JSON string; `device_id` beside `session_id` is the shape.
230        let metadata: serde_json::Value = serde_json::from_str(user_id).ok()?;
231        return (metadata
232            .get("device_id")
233            .is_some_and(serde_json::Value::is_string)
234            && metadata
235                .get("session_id")
236                .is_some_and(serde_json::Value::is_string))
237        .then_some(NativeMarker::ClaudeMetadataJson);
238    }
239    // Why: `user_<hex>_account_<uuid>_session_<uuid>` is the grammar Claude
240    // Code and Claude Desktop stamp; the checks are structural, not a match on
241    // any client-chosen text.
242    let mut parts = user_id.split('_');
243    let shape = [
244        parts.next() == Some("user"),
245        parts.next().is_some_and(|hex| !hex.is_empty()),
246        parts.next() == Some("account"),
247        parts
248            .next()
249            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
250        parts.next() == Some("session"),
251        parts
252            .next()
253            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
254        parts.next().is_none(),
255    ];
256    shape
257        .iter()
258        .all(|ok| *ok)
259        .then_some(NativeMarker::ClaudeMetadataUserId)
260}
261
262// Why: the first product token is the only part of a User-Agent with a
263// defined grammar (RFC 9110 §10.1.5); everything after it is free text and is
264// never matched.
265#[must_use]
266pub fn ua_product(user_agent: Option<&str>) -> Option<(String, Option<String>)> {
267    let first = user_agent?.split_ascii_whitespace().next()?;
268    let (product, version) = first
269        .split_once('/')
270        .map_or((first, None), |(p, v)| (p, Some(v)));
271    if product.is_empty() || !product.chars().all(is_token_char) {
272        return None;
273    }
274    Some((
275        product.to_ascii_lowercase(),
276        version.filter(|v| !v.is_empty()).map(str::to_owned),
277    ))
278}
279
280fn is_token_char(c: char) -> bool {
281    c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
282}