Skip to main content

systemprompt_models/origin/
mod.rs

1//! Where a persisted AI request came from: the client that produced it, the
2//! inbound wire protocol it spoke, and how strongly the client is evidenced.
3//!
4//! All three halves are closed enums so `ai_requests.client_kind`,
5//! `ai_requests.wire_protocol` and `ai_requests.client_attestation` are
6//! CHECK-constrained columns, never free text. This module is the normative
7//! specification of client attribution; the hosted gateway documentation
8//! restates it for operators.
9//!
10//! # Evidence tiers
11//!
12//! [`classify`] answers "which client sent this request?" by walking a ladder
13//! of evidence, strongest first. The winning tier sets `client_kind` and
14//! `client_attestation`; everything the wire carried is kept in
15//! [`ClientEvidence`] so a classification can be audited or corrected later.
16//!
17//! | tier | header / signal | trust |
18//! |------|-----------------|-------|
19//! | `host-token` | bridge principal with `x-systemprompt-client-attestation: host-token`; the bridge verified a per-host HMAC token on its loopback and stamped `x-systemprompt-client` itself | cryptographic on the device, channel-bound to the bridge |
20//! | `bridge-secret` | bridge principal with `x-systemprompt-client-attestation: bridge-secret`; the caller presented the raw loopback secret, so the host is taken from the lower tiers | channel verified, host not |
21//! | `declared` | `x-systemprompt-client: <kind>` from any principal (or passed through on the secret path) | client-asserted, closed vocabulary |
22//! | `native-marker` | a structural marker of one harness in the body ([`NativeMarker`]): Claude Code's billing entrypoint or `metadata.user_id` grammars, Codex turn metadata | structural, unforged in practice |
23//! | `user-agent` | the exact first product token of `User-Agent` ([`ua_product`]) | weakest tier kept |
24//! | `none` | nothing matched; `client_kind` is `other` | honest fallback |
25//!
26//! `internal` and `unknown` are never produced from live gateway traffic:
27//! `internal` pairs with server-side producers and `unknown` with rows written
28//! before attribution existed.
29//!
30//! A conflict between tiers (a host token naming `opencode` under a
31//! `claude-cli` User-Agent) is never rejected: the strongest tier wins and the
32//! evidence row records the rest. Two inputs are rejected with `400` instead:
33//! an `x-systemprompt-client` value outside the vocabulary, and an
34//! `x-systemprompt-client-attestation` header from anything but the bridge.
35//!
36//! The one wire vocabulary is [`ClientKind::as_str`]. A bridge host
37//! ([`HostKind`]) names a packaging of a product; the wire cannot tell a
38//! desktop app from its CLI, so both packagings map onto one `ClientKind`
39//! through [`From<HostKind>`], and [`ClientKind::hosts`] lists the packagings
40//! a wire value may stand for.
41//!
42//! Copyright (c) systemprompt.io — Business Source License 1.1.
43//! See <https://systemprompt.io> for licensing details.
44
45mod attestation;
46mod classify;
47mod evidence;
48mod harness;
49
50pub use attestation::{ClientAttestation, NativeMarker};
51pub use classify::{
52    ClassificationInput, ClassificationRejection, Classified, StainlessHeaders, classify,
53    native_marker, ua_product,
54};
55pub use evidence::ClientEvidence;
56
57use serde::{Deserialize, Serialize};
58
59use crate::bridge::host::{HostAppKind, HostKind, HostProduct};
60
61/// The client that produced an AI request.
62///
63/// `Other` is a real HTTP caller the gateway could not name (a raw SDK, curl,
64/// a chat UI); its evidence row still records what it presented. `Internal`
65/// is a request the server made for itself with no HTTP ingress. `Unknown`
66/// exists only for rows written before attribution was recorded and for an
67/// old binary inserting during a deploy window; live code never constructs it.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "kebab-case")]
70pub enum ClientKind {
71    ClaudeCode,
72    ClaudeDesktop,
73    Codex,
74    #[serde(rename = "opencode")]
75    OpenCode,
76    Hermes,
77    Pi,
78    Other,
79    Internal,
80    Unknown,
81}
82
83/// The protocol a request arrived on. `Internal` pairs with
84/// [`ClientKind::Internal`]; `Unknown` pairs with [`ClientKind::Unknown`].
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub enum InboundWireProtocol {
87    #[serde(rename = "anthropic.messages")]
88    AnthropicMessages,
89    #[serde(rename = "openai.chat")]
90    OpenAiChat,
91    #[serde(rename = "openai.responses")]
92    OpenAiResponses,
93    #[serde(rename = "internal")]
94    Internal,
95    #[serde(rename = "unknown")]
96    Unknown,
97}
98
99/// Client, wire protocol and attestation of one request, carried together so
100/// a producer cannot record one without the others.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub struct RequestOrigin {
103    pub client: ClientKind,
104    pub wire: InboundWireProtocol,
105    pub attestation: ClientAttestation,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
109pub enum OriginParseError {
110    #[error("unknown client kind: {0}")]
111    ClientKind(String),
112    #[error("unknown inbound wire protocol: {0}")]
113    WireProtocol(String),
114    #[error("unknown client attestation: {0}")]
115    Attestation(String),
116    #[error("unknown native marker: {0}")]
117    NativeMarker(String),
118    #[error("client kind {0} is not an evaluator harness")]
119    NotAHarness(&'static str),
120}
121
122impl ClientKind {
123    pub const ALL: [Self; 9] = [
124        Self::ClaudeCode,
125        Self::ClaudeDesktop,
126        Self::Codex,
127        Self::OpenCode,
128        Self::Hermes,
129        Self::Pi,
130        Self::Other,
131        Self::Internal,
132        Self::Unknown,
133    ];
134
135    // Why: the vocabulary a client may declare excludes the two server-only
136    // values; `other` is declarable so a client can say "nothing you know".
137    pub const DECLARABLE: [Self; 7] = [
138        Self::ClaudeCode,
139        Self::ClaudeDesktop,
140        Self::Codex,
141        Self::OpenCode,
142        Self::Hermes,
143        Self::Pi,
144        Self::Other,
145    ];
146
147    #[must_use]
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::ClaudeCode => "claude-code",
151            Self::ClaudeDesktop => "claude-desktop",
152            Self::Codex => "codex",
153            Self::OpenCode => "opencode",
154            Self::Hermes => "hermes",
155            Self::Pi => "pi",
156            Self::Other => "other",
157            Self::Internal => "internal",
158            Self::Unknown => "unknown",
159        }
160    }
161
162    #[must_use]
163    pub const fn label(self) -> &'static str {
164        match self {
165            Self::ClaudeCode => "Claude Code",
166            Self::ClaudeDesktop => "Claude Desktop",
167            Self::Codex => "Codex",
168            Self::OpenCode => "OpenCode",
169            Self::Hermes => "Hermes",
170            Self::Pi => "Pi",
171            Self::Other => "API client",
172            Self::Internal => "Internal",
173            Self::Unknown => "Unknown",
174        }
175    }
176
177    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
178        Self::ALL
179            .into_iter()
180            .find(|kind| kind.as_str() == value)
181            .ok_or_else(|| OriginParseError::ClientKind(value.to_owned()))
182    }
183
184    pub fn hosts(self) -> impl Iterator<Item = HostKind> {
185        HostKind::ALL
186            .into_iter()
187            .filter(move |host| Self::from(*host) == self)
188    }
189
190    #[must_use]
191    pub const fn product(self) -> Option<HostProduct> {
192        match self {
193            Self::ClaudeCode | Self::ClaudeDesktop => Some(HostProduct::Claude),
194            Self::Codex => Some(HostProduct::Codex),
195            Self::OpenCode => Some(HostProduct::OpenCode),
196            Self::Hermes => Some(HostProduct::Hermes),
197            Self::Pi | Self::Other | Self::Internal | Self::Unknown => None,
198        }
199    }
200
201    // Why: only the exact, lower-cased first product token is consulted —
202    // `contains` would let any client name a harness by mentioning it.
203    #[must_use]
204    pub fn from_ua_product(product: &str) -> Option<Self> {
205        match product {
206            "claude-cli" | "claude-code" => Some(Self::ClaudeCode),
207            "claude-desktop" => Some(Self::ClaudeDesktop),
208            "codex_cli_rs" => Some(Self::Codex),
209            "opencode" => Some(Self::OpenCode),
210            "hermes-agent" => Some(Self::Hermes),
211            _ => None,
212        }
213    }
214
215    // Why: Claude Desktop runs the Claude Code runtime, so its requests wear
216    // the `claude-cli` User-Agent and the CLI's metadata grammar; a signal
217    // naming either packaging of the Claude product is agreement, not a
218    // conflict.
219    #[must_use]
220    pub const fn same_runtime(self, other: Self) -> bool {
221        (self as u8) == (other as u8)
222            || matches!(
223                (self.product(), other.product()),
224                (Some(HostProduct::Claude), Some(HostProduct::Claude))
225            )
226    }
227}
228
229impl From<HostKind> for ClientKind {
230    fn from(host: HostKind) -> Self {
231        match host.product() {
232            HostProduct::Claude => match host.kind() {
233                HostAppKind::DesktopApp => Self::ClaudeDesktop,
234                HostAppKind::CliTool => Self::ClaudeCode,
235            },
236            HostProduct::Codex => Self::Codex,
237            HostProduct::OpenCode => Self::OpenCode,
238            HostProduct::Hermes => Self::Hermes,
239        }
240    }
241}
242
243impl InboundWireProtocol {
244    pub const ALL: [Self; 5] = [
245        Self::AnthropicMessages,
246        Self::OpenAiChat,
247        Self::OpenAiResponses,
248        Self::Internal,
249        Self::Unknown,
250    ];
251
252    #[must_use]
253    pub const fn as_str(self) -> &'static str {
254        match self {
255            Self::AnthropicMessages => "anthropic.messages",
256            Self::OpenAiChat => "openai.chat",
257            Self::OpenAiResponses => "openai.responses",
258            Self::Internal => "internal",
259            Self::Unknown => "unknown",
260        }
261    }
262
263    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
264        Self::ALL
265            .into_iter()
266            .find(|wire| wire.as_str() == value)
267            .ok_or_else(|| OriginParseError::WireProtocol(value.to_owned()))
268    }
269}
270
271impl RequestOrigin {
272    pub const INTERNAL: Self = Self {
273        client: ClientKind::Internal,
274        wire: InboundWireProtocol::Internal,
275        attestation: ClientAttestation::Internal,
276    };
277
278    #[must_use]
279    pub const fn gateway(
280        client: ClientKind,
281        wire: InboundWireProtocol,
282        attestation: ClientAttestation,
283    ) -> Self {
284        Self {
285            client,
286            wire,
287            attestation,
288        }
289    }
290}