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("claude-desktop") => Some(NativeMarker::ClaudeDesktopEntrypoint),
218        _ => None,
219    }
220}
221
222// JSON: Anthropic Messages request — probed for Claude Code markers.
223fn claude_metadata_user_id(value: &serde_json::Value) -> Option<NativeMarker> {
224    let user_id = value.pointer("/metadata/user_id")?.as_str()?.trim();
225    if user_id.starts_with('{') {
226        // JSON: protocol boundary — Claude Code ≥ 2.1.25x stamps its session
227        // as a JSON string; `device_id` beside `session_id` is the shape.
228        let metadata: serde_json::Value = serde_json::from_str(user_id).ok()?;
229        return (metadata
230            .get("device_id")
231            .is_some_and(serde_json::Value::is_string)
232            && metadata
233                .get("session_id")
234                .is_some_and(serde_json::Value::is_string))
235        .then_some(NativeMarker::ClaudeMetadataJson);
236    }
237    // Why: `user_<hex>_account_<uuid>_session_<uuid>` is the grammar Claude
238    // Code and Claude Desktop stamp; the checks are structural, not a match on
239    // any client-chosen text.
240    let mut parts = user_id.split('_');
241    let shape = [
242        parts.next() == Some("user"),
243        parts.next().is_some_and(|hex| !hex.is_empty()),
244        parts.next() == Some("account"),
245        parts
246            .next()
247            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
248        parts.next() == Some("session"),
249        parts
250            .next()
251            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
252        parts.next().is_none(),
253    ];
254    shape
255        .iter()
256        .all(|ok| *ok)
257        .then_some(NativeMarker::ClaudeMetadataUserId)
258}
259
260// Why: the first product token is the only part of a User-Agent with a
261// defined grammar (RFC 9110 §10.1.5); everything after it is free text and is
262// never matched.
263#[must_use]
264pub fn ua_product(user_agent: Option<&str>) -> Option<(String, Option<String>)> {
265    let first = user_agent?.split_ascii_whitespace().next()?;
266    let (product, version) = first
267        .split_once('/')
268        .map_or((first, None), |(p, v)| (p, Some(v)));
269    if product.is_empty() || !product.chars().all(is_token_char) {
270        return None;
271    }
272    Some((
273        product.to_ascii_lowercase(),
274        version.filter(|v| !v.is_empty()).map(str::to_owned),
275    ))
276}
277
278fn is_token_char(c: char) -> bool {
279    c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
280}