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    root: &Value,
147    budget: SurfaceBudget,
148) -> bool {
149    let mut stack: Vec<(&Value, String, usize)> = vec![(root, String::from("$"), 0)];
150
151    while let Some((value, path, depth)) = stack.pop() {
152        if depth > budget.depth {
153            surface.truncated = true;
154            continue;
155        }
156        match value {
157            Value::String(s) => {
158                if !push_leaf(surface, total, &budget, &path, s) {
159                    return false;
160                }
161            },
162            Value::Array(items) => {
163                for (index, item) in items.iter().enumerate().rev() {
164                    stack.push((item, format!("{path}[{index}]"), depth + 1));
165                }
166            },
167            Value::Object(map) => {
168                for (key, item) in map.iter().rev() {
169                    if !push_leaf(surface, total, &budget, &format!("{path}.{key}.$key"), key) {
170                        return false;
171                    }
172                    stack.push((item, format!("{path}.{key}"), depth + 1));
173                }
174            },
175            Value::Null | Value::Bool(_) | Value::Number(_) => {},
176        }
177    }
178    true
179}
180
181fn push_leaf(
182    surface: &mut ForwardedSurface,
183    total: &mut usize,
184    budget: &SurfaceBudget,
185    path: &str,
186    value: &str,
187) -> bool {
188    if value.is_empty() {
189        return true;
190    }
191    if surface.leaves.len() >= budget.leaves || *total >= budget.total_bytes {
192        surface.truncated = true;
193        return false;
194    }
195    let (value, clipped) = clip(value, budget.leaf_bytes);
196    if clipped {
197        surface.truncated = true;
198    }
199    *total += value.len();
200    surface.leaves.push(SurfaceLeaf {
201        path: path.to_owned(),
202        value,
203    });
204    true
205}
206
207fn clip(value: &str, limit: usize) -> (String, bool) {
208    if value.len() <= limit {
209        return (value.to_owned(), false);
210    }
211    let half = limit / 2;
212    let head_end = crate::text::floor_char_boundary(value, half);
213    let tail_start = ceil_boundary(value, value.len() - half);
214    let mut out = String::with_capacity(limit + 1);
215    out.push_str(&value[..head_end]);
216    out.push('\n');
217    out.push_str(&value[tail_start..]);
218    (out, true)
219}
220
221const fn ceil_boundary(s: &str, mut index: usize) -> usize {
222    while index < s.len() && !s.is_char_boundary(index) {
223        index += 1;
224    }
225    index
226}