Skip to main content

systemprompt_models/wire/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`]) | 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`]. The bridge's own host
37//! ids (`codex-cli`) are baked into HMAC labels and rendered host configs, so
38//! they stay bridge-internal and map through
39//! [`ClientKind::from_bridge_host_id`] / [`ClientKind::bridge_host_id`].
40//!
41//! Copyright (c) systemprompt.io — Business Source License 1.1.
42//! See <https://systemprompt.io> for licensing details.
43
44mod attestation;
45mod classify;
46mod evidence;
47mod harness;
48
49pub use attestation::{ClientAttestation, NativeMarker};
50pub use classify::{
51    ClassificationInput, ClassificationRejection, Classified, StainlessHeaders, classify,
52    native_marker, ua_product,
53};
54pub use evidence::ClientEvidence;
55
56use serde::{Deserialize, Serialize};
57
58/// The client that produced an AI request.
59///
60/// `Other` is a real HTTP caller the gateway could not name (a raw SDK, curl,
61/// a chat UI); its evidence row still records what it presented. `Internal`
62/// is a request the server made for itself with no HTTP ingress. `Unknown`
63/// exists only for rows written before attribution was recorded and for an
64/// old binary inserting during a deploy window; live code never constructs it.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case")]
67pub enum ClientKind {
68    ClaudeCode,
69    ClaudeDesktop,
70    Codex,
71    #[serde(rename = "opencode")]
72    OpenCode,
73    Hermes,
74    Pi,
75    Other,
76    Internal,
77    Unknown,
78}
79
80/// The protocol a request arrived on. `Internal` pairs with
81/// [`ClientKind::Internal`]; `Unknown` pairs with [`ClientKind::Unknown`].
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83pub enum InboundWireProtocol {
84    #[serde(rename = "anthropic.messages")]
85    AnthropicMessages,
86    #[serde(rename = "openai.chat")]
87    OpenAiChat,
88    #[serde(rename = "openai.responses")]
89    OpenAiResponses,
90    #[serde(rename = "internal")]
91    Internal,
92    #[serde(rename = "unknown")]
93    Unknown,
94}
95
96/// Client, wire protocol and attestation of one request, carried together so
97/// a producer cannot record one without the others.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
99pub struct RequestOrigin {
100    pub client: ClientKind,
101    pub wire: InboundWireProtocol,
102    pub attestation: ClientAttestation,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
106pub enum OriginParseError {
107    #[error("unknown client kind: {0}")]
108    ClientKind(String),
109    #[error("unknown inbound wire protocol: {0}")]
110    WireProtocol(String),
111    #[error("unknown client attestation: {0}")]
112    Attestation(String),
113    #[error("unknown native marker: {0}")]
114    NativeMarker(String),
115    #[error("client kind {0} is not an evaluator harness")]
116    NotAHarness(&'static str),
117}
118
119impl ClientKind {
120    pub const ALL: [Self; 9] = [
121        Self::ClaudeCode,
122        Self::ClaudeDesktop,
123        Self::Codex,
124        Self::OpenCode,
125        Self::Hermes,
126        Self::Pi,
127        Self::Other,
128        Self::Internal,
129        Self::Unknown,
130    ];
131
132    // Why: the vocabulary a client may declare excludes the two server-only
133    // values; `other` is declarable so a client can say "nothing you know".
134    pub const DECLARABLE: [Self; 7] = [
135        Self::ClaudeCode,
136        Self::ClaudeDesktop,
137        Self::Codex,
138        Self::OpenCode,
139        Self::Hermes,
140        Self::Pi,
141        Self::Other,
142    ];
143
144    #[must_use]
145    pub const fn as_str(self) -> &'static str {
146        match self {
147            Self::ClaudeCode => "claude-code",
148            Self::ClaudeDesktop => "claude-desktop",
149            Self::Codex => "codex",
150            Self::OpenCode => "opencode",
151            Self::Hermes => "hermes",
152            Self::Pi => "pi",
153            Self::Other => "other",
154            Self::Internal => "internal",
155            Self::Unknown => "unknown",
156        }
157    }
158
159    #[must_use]
160    pub const fn label(self) -> &'static str {
161        match self {
162            Self::ClaudeCode => "Claude Code",
163            Self::ClaudeDesktop => "Claude Desktop",
164            Self::Codex => "Codex",
165            Self::OpenCode => "OpenCode",
166            Self::Hermes => "Hermes",
167            Self::Pi => "Pi",
168            Self::Other => "API client",
169            Self::Internal => "Internal",
170            Self::Unknown => "Unknown",
171        }
172    }
173
174    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
175        Self::ALL
176            .into_iter()
177            .find(|kind| kind.as_str() == value)
178            .ok_or_else(|| OriginParseError::ClientKind(value.to_owned()))
179    }
180
181    #[must_use]
182    pub fn from_bridge_host_id(host_id: &str) -> Option<Self> {
183        match host_id {
184            "claude-code" => Some(Self::ClaudeCode),
185            "claude-desktop" => Some(Self::ClaudeDesktop),
186            "codex-cli" => Some(Self::Codex),
187            "opencode" => Some(Self::OpenCode),
188            "hermes" => Some(Self::Hermes),
189            _ => None,
190        }
191    }
192
193    // Why: only the exact, lower-cased first product token is consulted —
194    // `contains` would let any client name a harness by mentioning it.
195    #[must_use]
196    pub fn from_ua_product(product: &str) -> Option<Self> {
197        match product {
198            "claude-cli" | "claude-code" => Some(Self::ClaudeCode),
199            "claude-desktop" => Some(Self::ClaudeDesktop),
200            "codex_cli_rs" => Some(Self::Codex),
201            "opencode" => Some(Self::OpenCode),
202            "hermes-agent" => Some(Self::Hermes),
203            _ => None,
204        }
205    }
206
207    #[must_use]
208    pub const fn bridge_host_id(self) -> Option<&'static str> {
209        match self {
210            Self::ClaudeCode => Some("claude-code"),
211            Self::ClaudeDesktop => Some("claude-desktop"),
212            Self::Codex => Some("codex-cli"),
213            Self::OpenCode => Some("opencode"),
214            Self::Hermes => Some("hermes"),
215            Self::Pi | Self::Other | Self::Internal | Self::Unknown => None,
216        }
217    }
218}
219
220impl InboundWireProtocol {
221    pub const ALL: [Self; 5] = [
222        Self::AnthropicMessages,
223        Self::OpenAiChat,
224        Self::OpenAiResponses,
225        Self::Internal,
226        Self::Unknown,
227    ];
228
229    #[must_use]
230    pub const fn as_str(self) -> &'static str {
231        match self {
232            Self::AnthropicMessages => "anthropic.messages",
233            Self::OpenAiChat => "openai.chat",
234            Self::OpenAiResponses => "openai.responses",
235            Self::Internal => "internal",
236            Self::Unknown => "unknown",
237        }
238    }
239
240    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
241        Self::ALL
242            .into_iter()
243            .find(|wire| wire.as_str() == value)
244            .ok_or_else(|| OriginParseError::WireProtocol(value.to_owned()))
245    }
246}
247
248impl RequestOrigin {
249    pub const INTERNAL: Self = Self {
250        client: ClientKind::Internal,
251        wire: InboundWireProtocol::Internal,
252        attestation: ClientAttestation::Internal,
253    };
254
255    #[must_use]
256    pub const fn gateway(
257        client: ClientKind,
258        wire: InboundWireProtocol,
259        attestation: ClientAttestation,
260    ) -> Self {
261        Self {
262            client,
263            wire,
264            attestation,
265        }
266    }
267}