Skip to main content

systemprompt_models/wire/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 != 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    let user_id = value.pointer("/metadata/user_id")?.as_str()?.trim();
194    if user_id.starts_with('{') {
195        // JSON: protocol boundary โ€” OpenCode's session rides as a JSON string.
196        return serde_json::from_str::<serde_json::Value>(user_id)
197            .ok()?
198            .get("session_id")
199            .is_some()
200            .then_some(NativeMarker::OpencodeSessionJson);
201    }
202    // Why: `user_<hex>_account_<uuid>_session_<uuid>` is the grammar Claude
203    // Code and Claude Desktop stamp; the checks are structural, not a match on
204    // any client-chosen text.
205    let mut parts = user_id.split('_');
206    let shape = [
207        parts.next() == Some("user"),
208        parts.next().is_some_and(|hex| !hex.is_empty()),
209        parts.next() == Some("account"),
210        parts
211            .next()
212            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
213        parts.next() == Some("session"),
214        parts
215            .next()
216            .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
217        parts.next().is_none(),
218    ];
219    shape
220        .iter()
221        .all(|ok| *ok)
222        .then_some(NativeMarker::ClaudeMetadataUserId)
223}
224
225// Why: the first product token is the only part of a User-Agent with a
226// defined grammar (RFC 9110 ยง10.1.5); everything after it is free text and is
227// never matched.
228#[must_use]
229pub fn ua_product(user_agent: Option<&str>) -> Option<(String, Option<String>)> {
230    let first = user_agent?.split_ascii_whitespace().next()?;
231    let (product, version) = first
232        .split_once('/')
233        .map_or((first, None), |(p, v)| (p, Some(v)));
234    if product.is_empty() || !product.chars().all(is_token_char) {
235        return None;
236    }
237    Some((
238        product.to_ascii_lowercase(),
239        version.filter(|v| !v.is_empty()).map(str::to_owned),
240    ))
241}
242
243fn is_token_char(c: char) -> bool {
244    c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
245}