systemprompt_models/wire/origin/
classify.rs1use 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#[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#[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#[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#[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#[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 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 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#[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}