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