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            // Why: legitimate provider content nests a handful of levels; this
93            // is far above that and far below what would exhaust the walk.
94            depth: 64,
95            leaves: 50_000,
96            total_bytes: 2 * 1024 * 1024,
97            leaf_bytes: 64 * 1024,
98        }
99    }
100}
101
102/// Collects every string in `body`, including object keys. Invalid JSON yields
103/// an empty, non-truncated surface — the caller rejects such a body separately
104/// rather than having this guess at its content.
105///
106/// The walk is iterative: a recursive one over caller-controlled JSON is a
107/// stack-overflow primitive well inside the 8 MiB body limit.
108#[must_use]
109pub fn string_leaves(body: &[u8], budget: SurfaceBudget) -> ForwardedSurface {
110    let Ok(root) = serde_json::from_slice::<Value>(body) else {
111        return ForwardedSurface::default();
112    };
113    let mut surface = ForwardedSurface::default();
114    let mut total: usize = 0;
115    walk(&mut surface, &mut total, &root, budget);
116    surface
117}
118
119/// Collects every string in a stream of SSE frames, merging the frames into one
120/// surface under a single shared budget.
121///
122/// A frame whose `data:` payload is not JSON contributes nothing and does not
123/// stop the walk: one malformed frame in a stream must not blind the scanner to
124/// every other frame the client received.
125#[must_use]
126pub fn sse_string_leaves(frames: &[u8], budget: SurfaceBudget) -> ForwardedSurface {
127    let mut surface = ForwardedSurface::default();
128    let mut total: usize = 0;
129    let mut rest = frames;
130    while !rest.is_empty() {
131        let (frame, next) = rest.split_at(super::sse::frame_end(rest).unwrap_or(rest.len()));
132        if let Some(payload) = data_payload(frame)
133            && let Ok(root) = serde_json::from_slice::<Value>(&payload)
134            && !walk(&mut surface, &mut total, &root, budget)
135        {
136            return surface;
137        }
138        rest = next;
139    }
140    surface
141}
142
143fn data_payload(frame: &[u8]) -> Option<Vec<u8>> {
144    let mut out: Vec<u8> = Vec::new();
145    let mut found = false;
146    for line in frame.split(|b| *b == b'\n') {
147        let line = line.strip_suffix(b"\r").unwrap_or(line);
148        let Some(value) = line.strip_prefix(b"data:") else {
149            continue;
150        };
151        found = true;
152        out.extend_from_slice(value.strip_prefix(b" ").unwrap_or(value));
153    }
154    found.then_some(out)
155}
156
157// Why: `false` means a budget is exhausted and no further root may be walked,
158// which is what lets one budget span every frame of a stream.
159fn walk(
160    surface: &mut ForwardedSurface,
161    total: &mut usize,
162    root: &Value,
163    budget: SurfaceBudget,
164) -> bool {
165    let mut stack: Vec<(&Value, String, usize)> = vec![(root, String::from("$"), 0)];
166
167    while let Some((value, path, depth)) = stack.pop() {
168        if depth > budget.depth {
169            surface.truncated = true;
170            continue;
171        }
172        match value {
173            Value::String(s) => {
174                if !push_leaf(surface, total, &budget, &path, s) {
175                    return false;
176                }
177            },
178            Value::Array(items) => {
179                // Why: pushed in reverse so popping yields document order.
180                for (index, item) in items.iter().enumerate().rev() {
181                    stack.push((item, format!("{path}[{index}]"), depth + 1));
182                }
183            },
184            Value::Object(map) => {
185                for (key, item) in map.iter().rev() {
186                    // Why: a credential used as an object key is pathological
187                    // but costs nothing to cover, and skipping it would be a
188                    // blind spot chosen on the basis of shape.
189                    if !push_leaf(surface, total, &budget, &format!("{path}.{key}.$key"), key) {
190                        return false;
191                    }
192                    stack.push((item, format!("{path}.{key}"), depth + 1));
193                }
194            },
195            Value::Null | Value::Bool(_) | Value::Number(_) => {},
196        }
197    }
198    true
199}
200
201// Why: `false` means a budget is exhausted and the whole walk must stop, not
202// that this one leaf was skipped; `surface.truncated` is set in that case.
203fn push_leaf(
204    surface: &mut ForwardedSurface,
205    total: &mut usize,
206    budget: &SurfaceBudget,
207    path: &str,
208    value: &str,
209) -> bool {
210    if value.is_empty() {
211        return true;
212    }
213    if surface.leaves.len() >= budget.leaves || *total >= budget.total_bytes {
214        surface.truncated = true;
215        return false;
216    }
217    let (value, clipped) = clip(value, budget.leaf_bytes);
218    if clipped {
219        surface.truncated = true;
220    }
221    *total += value.len();
222    surface.leaves.push(SurfaceLeaf {
223        path: path.to_owned(),
224        value,
225    });
226    true
227}
228
229// Why: both ends are kept because a credential in a large blob sits at one end
230// far more often than in the middle, and keeping both costs the same as one.
231fn clip(value: &str, limit: usize) -> (String, bool) {
232    if value.len() <= limit {
233        return (value.to_owned(), false);
234    }
235    let half = limit / 2;
236    let head_end = floor_boundary(value, half);
237    let tail_start = ceil_boundary(value, value.len() - half);
238    let mut out = String::with_capacity(limit + 1);
239    out.push_str(&value[..head_end]);
240    out.push('\n');
241    out.push_str(&value[tail_start..]);
242    (out, true)
243}
244
245const fn floor_boundary(s: &str, mut index: usize) -> usize {
246    while index > 0 && !s.is_char_boundary(index) {
247        index -= 1;
248    }
249    index
250}
251
252const fn ceil_boundary(s: &str, mut index: usize) -> usize {
253    while index < s.len() && !s.is_char_boundary(index) {
254        index += 1;
255    }
256    index
257}