Skip to main content

systemprompt_models/wire/
inspect.rs

1//! Inspection surface for outbound wire bodies.
2//!
3//! The gateway forwards some requests as the caller's own bytes rather than
4//! rebuilding them from
5//! [`CanonicalRequest`](super::canonical::CanonicalRequest). Governance still
6//! reasons about the canonical form, and that form is lossy by construction:
7//! the inbound parser drops any content block whose `type` it does not model,
8//! images carry no text, and `structuredContent` / `_meta` have no
9//! canonical home. Anything in one of those places would reach the provider
10//! without a scanner ever seeing it.
11//!
12//! [`string_leaves`] closes that gap by reading the bytes that are actually
13//! going upstream and collecting every string in them, whatever shape the JSON
14//! takes. Attaching the result to the canonical request makes the scan surface
15//! a superset of the forwarded surface, so "inspected" and "sent" cannot
16//! diverge.
17//!
18//! The response direction has the same gap and the same remedy, against the
19//! bytes the *client* receives rather than the bytes the provider sent:
20//! [`string_leaves`] for a buffered reply, [`sse_string_leaves`] for a
21//! streamed one, whose bytes are concatenated SSE frames and not a JSON
22//! document.
23//!
24//! Copyright (c) systemprompt.io — Business Source License 1.1.
25//! See <https://systemprompt.io> for licensing details.
26
27// JSON: protocol boundary — the walk is over an arbitrary provider wire body.
28use serde_json::Value;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SurfaceLeaf {
32    pub path: String,
33    pub value: String,
34}
35
36/// `truncated` means a budget stopped the walk, so the surface is a subset of
37/// the body and a scanner reading it may miss content that was still sent.
38/// Callers record that; it is never a silent success.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct ForwardedSurface {
41    leaves: Vec<SurfaceLeaf>,
42    truncated: bool,
43}
44
45impl ForwardedSurface {
46    #[must_use]
47    pub fn leaves(&self) -> &[SurfaceLeaf] {
48        &self.leaves
49    }
50
51    #[must_use]
52    pub const fn truncated(&self) -> bool {
53        self.truncated
54    }
55
56    #[must_use]
57    pub const fn is_empty(&self) -> bool {
58        self.leaves.is_empty()
59    }
60
61    #[must_use]
62    pub const fn len(&self) -> usize {
63        self.leaves.len()
64    }
65
66    #[must_use]
67    pub fn joined(&self) -> String {
68        let mut out = String::new();
69        for leaf in &self.leaves {
70            if !out.is_empty() {
71                out.push('\n');
72            }
73            out.push_str(&leaf.value);
74        }
75        out
76    }
77}
78
79/// A forwarded body is caller-controlled, so every dimension an attacker could
80/// grow without bound has a ceiling here.
81#[derive(Debug, Clone, Copy)]
82pub struct SurfaceBudget {
83    pub depth: usize,
84    pub leaves: usize,
85    pub total_bytes: usize,
86    pub leaf_bytes: usize,
87}
88
89impl Default for SurfaceBudget {
90    fn default() -> Self {
91        Self {
92            depth: 64,
93            leaves: 50_000,
94            total_bytes: 2 * 1024 * 1024,
95            leaf_bytes: 64 * 1024,
96        }
97    }
98}
99
100#[must_use]
101pub fn string_leaves(body: &[u8], budget: SurfaceBudget) -> ForwardedSurface {
102    let Ok(root) = serde_json::from_slice::<Value>(body) else {
103        return ForwardedSurface::default();
104    };
105    let mut surface = ForwardedSurface::default();
106    let mut total: usize = 0;
107    walk(&mut surface, &mut total, &root, budget);
108    surface
109}
110
111#[must_use]
112pub fn sse_string_leaves(frames: &[u8], budget: SurfaceBudget) -> ForwardedSurface {
113    let mut surface = ForwardedSurface::default();
114    let mut total: usize = 0;
115    let mut rest = frames;
116    while !rest.is_empty() {
117        let (frame, next) = rest.split_at(super::sse::frame_end(rest).unwrap_or(rest.len()));
118        if let Some(payload) = data_payload(frame)
119            && let Ok(root) = serde_json::from_slice::<Value>(&payload)
120            && !walk(&mut surface, &mut total, &root, budget)
121        {
122            return surface;
123        }
124        rest = next;
125    }
126    surface
127}
128
129fn data_payload(frame: &[u8]) -> Option<Vec<u8>> {
130    let mut out: Vec<u8> = Vec::new();
131    let mut found = false;
132    for line in frame.split(|b| *b == b'\n') {
133        let line = line.strip_suffix(b"\r").unwrap_or(line);
134        let Some(value) = line.strip_prefix(b"data:") else {
135            continue;
136        };
137        found = true;
138        out.extend_from_slice(value.strip_prefix(b" ").unwrap_or(value));
139    }
140    found.then_some(out)
141}
142
143fn walk(
144    surface: &mut ForwardedSurface,
145    total: &mut usize,
146    // JSON: Provider request body under inspection; vendor JSON is the contract.
147    root: &Value,
148    budget: SurfaceBudget,
149) -> bool {
150    let mut stack: Vec<(&Value, String, usize)> = vec![(root, String::from("$"), 0)];
151
152    while let Some((value, path, depth)) = stack.pop() {
153        if depth > budget.depth {
154            surface.truncated = true;
155            continue;
156        }
157        match value {
158            Value::String(s) => {
159                if !push_leaf(surface, total, &budget, &path, s) {
160                    return false;
161                }
162            },
163            Value::Array(items) => {
164                for (index, item) in items.iter().enumerate().rev() {
165                    stack.push((item, format!("{path}[{index}]"), depth + 1));
166                }
167            },
168            Value::Object(map) => {
169                for (key, item) in map.iter().rev() {
170                    if !push_leaf(surface, total, &budget, &format!("{path}.{key}.$key"), key) {
171                        return false;
172                    }
173                    stack.push((item, format!("{path}.{key}"), depth + 1));
174                }
175            },
176            Value::Null | Value::Bool(_) | Value::Number(_) => {},
177        }
178    }
179    true
180}
181
182fn push_leaf(
183    surface: &mut ForwardedSurface,
184    total: &mut usize,
185    budget: &SurfaceBudget,
186    path: &str,
187    value: &str,
188) -> bool {
189    if value.is_empty() {
190        return true;
191    }
192    if surface.leaves.len() >= budget.leaves || *total >= budget.total_bytes {
193        surface.truncated = true;
194        return false;
195    }
196    let (value, clipped) = clip(value, budget.leaf_bytes);
197    if clipped {
198        surface.truncated = true;
199    }
200    *total += value.len();
201    surface.leaves.push(SurfaceLeaf {
202        path: path.to_owned(),
203        value,
204    });
205    true
206}
207
208fn clip(value: &str, limit: usize) -> (String, bool) {
209    if value.len() <= limit {
210        return (value.to_owned(), false);
211    }
212    let half = limit / 2;
213    let head_end = crate::text::floor_char_boundary(value, half);
214    let tail_start = ceil_boundary(value, value.len() - half);
215    let mut out = String::with_capacity(limit + 1);
216    out.push_str(&value[..head_end]);
217    out.push('\n');
218    out.push_str(&value[tail_start..]);
219    (out, true)
220}
221
222const fn ceil_boundary(s: &str, mut index: usize) -> usize {
223    while index < s.len() && !s.is_char_boundary(index) {
224        index += 1;
225    }
226    index
227}