Skip to main content

aacode_rs/
observation.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Observation sanitisation — the single choke point between a tool's raw
5//! output and the model/session.
6//!
7//! Root invariant: **message content never contains an unbounded binary
8//! payload.** Any binary blob (inline `base64`, a `data:` URI, a high-entropy
9//! long string, or a field whose key marks it as binary) is written to an
10//! [`ArtifactStore`] and replaced in-place by a small [`ArtifactRef`] plus a
11//! *bounded, labelled* hex preview.
12//!
13//! This deliberately lives in aacode-rs (tool layer) and knows nothing about
14//! fastshell / fastbrowser internals — it operates purely on the tool's
15//! returned string/JSON.
16
17use crate::artifacts::{hex_preview, ArtifactStore};
18use base64::Engine;
19use serde_json::{json, Map, Value};
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22/// Tunables for [`sanitize`]. All have conservative defaults.
23#[derive(Debug, Clone)]
24pub struct SanitizeLimits {
25    /// Hard cap on the final observation string handed to the model.
26    pub max_observation_chars: usize,
27    /// Hard cap on preview bytes per artifact (regardless of what a tool wants).
28    pub preview_bytes: usize,
29    /// A string at least this long that is pure base64 is treated as binary.
30    pub min_base64_len: usize,
31    /// Per-task cumulative budget for preview hex (chars). Stops a peek loop
32    /// from exfiltrating a whole file through many small samples.
33    pub budget_chars: usize,
34}
35
36impl Default for SanitizeLimits {
37    fn default() -> Self {
38        SanitizeLimits {
39            // 0 = do NOT truncate plain text; only binary/base64 is externalized.
40            // (`run_shell` length is the caller's choice via `max_output`.)
41            max_observation_chars: 0,
42            preview_bytes: 64,
43            min_base64_len: 256,
44            budget_chars: 16_384,
45        }
46    }
47}
48
49/// A tiny per-task budget for preview bytes (shared across tool calls).
50#[derive(Debug)]
51pub struct PreviewBudget(AtomicUsize);
52
53impl PreviewBudget {
54    pub fn new(chars: usize) -> Self {
55        PreviewBudget(AtomicUsize::new(chars))
56    }
57    pub fn default_for(limits: &SanitizeLimits) -> Self {
58        PreviewBudget::new(limits.budget_chars)
59    }
60    /// Reserve `want` chars of budget; returns how many were granted.
61    fn take(&self, want: usize) -> usize {
62        self.0
63            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
64                Some(cur.saturating_sub(want))
65            })
66            .map(|prev| want.min(prev))
67            .unwrap_or(0)
68    }
69}
70
71fn is_base64_alphabet(c: char) -> bool {
72    c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '-' || c == '_'
73}
74
75/// Heuristic: is `s` a single base64 blob (ignoring whitespace/newlines)?
76pub fn is_probably_base64(s: &str, min_len: usize) -> bool {
77    let t = s.trim();
78    if t.len() < min_len {
79        return false;
80    }
81    if t.contains(' ') {
82        return false;
83    }
84    let core: String = t.chars().filter(|c| !c.is_whitespace()).collect();
85    if core.len() < min_len || core.len() % 4 != 0 {
86        return false;
87    }
88    core.chars().all(is_base64_alphabet)
89}
90
91fn unique_chars(s: &str) -> usize {
92    let mut set = std::collections::HashSet::new();
93    for c in s.chars() {
94        if !c.is_whitespace() {
95            set.insert(c);
96        }
97    }
98    set.len()
99}
100
101/// Stricter, non-keyed backstop: a long base64-looking blob that also carries
102/// base64 punctuation and high character diversity. Avoids mangling ordinary
103/// repeated/plain text (e.g. a long run of `x`) into an artifact.
104fn looks_like_b64_blob(s: &str, min_len: usize) -> bool {
105    is_probably_base64(s, min_len)
106        && (s.contains('+') || s.contains('/') || s.contains('='))
107        && unique_chars(s) >= 12
108}
109
110fn key_is_binary(k: &str) -> bool {
111    let k = k.to_ascii_lowercase();
112    k == "base64"
113        || k == "b64"
114        || k == "data"
115        || k == "blob"
116        || k == "image"
117        || k == "audio"
118        || k == "video"
119        || k.ends_with("_base64")
120        || k.ends_with("_b64")
121        || k.contains("base64")
122}
123
124fn decode_data_uri(s: &str) -> Option<(String, Vec<u8>)> {
125    let rest = s.strip_prefix("data:")?;
126    let (meta, b64) = rest.split_once(',')?;
127    let mime = meta.split(';').next().unwrap_or("").to_string();
128    if !meta.contains("base64") {
129        // Percent-encoded data URIs are rare here; only handle base64.
130        return None;
131    }
132    let bytes = base64::engine::general_purpose::STANDARD
133        .decode(b64.trim())
134        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(b64.trim()))
135        .ok()?;
136    Some((
137        if mime.is_empty() {
138            "application/octet-stream".to_string()
139        } else {
140            mime
141        },
142        bytes,
143    ))
144}
145
146fn looks_binary(bytes: &[u8]) -> bool {
147    if bytes.is_empty() {
148        return false;
149    }
150    if bytes.contains(&0) {
151        return true;
152    }
153    let nontext = bytes
154        .iter()
155        .filter(|&&b| b < 0x09 || (b > 0x0d && b < 0x20) || b >= 0x7f)
156        .count();
157    nontext * 100 / bytes.len() >= 10
158}
159
160fn take_preview(
161    store: &ArtifactStore,
162    budget: &PreviewBudget,
163    limits: &SanitizeLimits,
164    bytes: &[u8],
165) -> Option<String> {
166    let (kind, _) = ArtifactStore::sniff(bytes);
167    let want_bytes = kind
168        .default_preview_bytes()
169        .min(limits.preview_bytes)
170        .max(1);
171    let want_chars = want_bytes * 3; // "xx " per byte
172    let granted = budget.take(want_chars);
173    if granted == 0 {
174        return None;
175    }
176    let grant_bytes = (granted / 3).max(1);
177    Some(hex_preview(bytes, grant_bytes))
178}
179
180/// Turn one binary blob into `{artifact, mime, bytes, kind, preview_hex…}`.
181fn blob_to_ref_json(
182    store: &ArtifactStore,
183    budget: &PreviewBudget,
184    limits: &SanitizeLimits,
185    bytes: &[u8],
186    mime_hint: Option<&str>,
187) -> Value {
188    match store.put_bytes(bytes, mime_hint, "obs") {
189        Ok(r) => {
190            let preview = take_preview(store, budget, limits, bytes);
191            r.to_model_json(preview.as_deref())
192        }
193        Err(_) => {
194            // Could not persist (disk error) → degrade to a bounded sample only.
195            let preview = hex_preview(bytes, limits.preview_bytes);
196            json!({"binary_omitted": true, "bytes": bytes.len(), "preview_hex": preview})
197        }
198    }
199}
200
201/// Handle a single string value that may embed binary.
202fn sanitize_string_value(
203    s: &str,
204    key: Option<&str>,
205    store: &ArtifactStore,
206    budget: &PreviewBudget,
207    limits: &SanitizeLimits,
208) -> Value {
209    if let Some((mime, bytes)) = decode_data_uri(s) {
210        if !bytes.is_empty() {
211            return blob_to_ref_json(store, budget, limits, &bytes, Some(&mime));
212        }
213    }
214    let key_binary = key.map(key_is_binary).unwrap_or(false);
215    let maybe_b64 = key_binary || looks_like_b64_blob(s, limits.min_base64_len);
216    if maybe_b64 {
217        let core: String = s.chars().filter(|c| !c.is_whitespace()).collect();
218        let decoded = base64::engine::general_purpose::STANDARD
219            .decode(core.as_bytes())
220            .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(core.as_bytes()))
221            .ok();
222        if let Some(bytes) = decoded {
223            // For a non-keyed match, only treat it as binary if it really looks
224            // binary (avoid mangling ordinary long text that happens to decode).
225            if key_binary || looks_binary(&bytes) {
226                return blob_to_ref_json(store, budget, limits, &bytes, None);
227            }
228        }
229    }
230    Value::String(s.to_string())
231}
232
233fn sanitize_value(
234    v: &Value,
235    key: Option<&str>,
236    store: &ArtifactStore,
237    budget: &PreviewBudget,
238    limits: &SanitizeLimits,
239) -> Value {
240    match v {
241        Value::String(s) => sanitize_string_value(s, key, store, budget, limits),
242        Value::Array(a) => Value::Array(
243            a.iter()
244                .map(|x| sanitize_value(x, None, store, budget, limits))
245                .collect(),
246        ),
247        Value::Object(o) => {
248            let mut m = Map::new();
249            for (k, val) in o {
250                m.insert(
251                    k.clone(),
252                    sanitize_value(val, Some(k), store, budget, limits),
253                );
254            }
255            Value::Object(m)
256        }
257        other => other.clone(),
258    }
259}
260
261fn cap_text(s: String, max: usize) -> String {
262    if max == 0 || s.chars().count() <= max {
263        return s;
264    }
265    let head: String = s.chars().take(max).collect();
266    format!("{head}\n…[truncated, {} chars total]", s.chars().count())
267}
268
269/// Sanitize a raw tool observation. Idempotent and safe for any tool.
270pub fn sanitize(
271    raw: &str,
272    store: &ArtifactStore,
273    budget: &PreviewBudget,
274    limits: &SanitizeLimits,
275) -> String {
276    // JSON path: walk and replace embedded blobs.
277    if let Ok(v) = serde_json::from_str::<Value>(raw) {
278        // Fast exit: a plain short JSON without any binary-ish token.
279        let has_token = raw.contains("base64")
280            || raw.contains("data:")
281            || raw.contains("b64")
282            || raw.len() > limits.min_base64_len;
283        if !has_token {
284            return cap_text(raw.to_string(), limits.max_observation_chars);
285        }
286        let out = sanitize_value(&v, None, store, budget, limits);
287        // No substitution happened → return the ORIGINAL text verbatim so we
288        // don't needlessly re-serialize (compact / reorder keys) every result.
289        if out == v {
290            return cap_text(raw.to_string(), limits.max_observation_chars);
291        }
292        let s = serde_json::to_string(&out).unwrap_or_else(|_| raw.to_string());
293        return cap_text(s, limits.max_observation_chars);
294    }
295
296    // Plain-text path: a bare base64 blob / data URI / binary-ish text.
297    if let Some((mime, bytes)) = decode_data_uri(raw.trim()) {
298        if !bytes.is_empty() {
299            let j = blob_to_ref_json(store, budget, limits, &bytes, Some(&mime));
300            return j.to_string();
301        }
302    }
303    let trimmed = raw.trim();
304    if looks_like_b64_blob(trimmed, limits.min_base64_len) {
305        let core: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
306        if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(core.as_bytes()) {
307            if looks_binary(&bytes) {
308                let j = blob_to_ref_json(store, budget, limits, &bytes, None);
309                return j.to_string();
310            }
311        }
312    }
313    cap_text(raw.to_string(), limits.max_observation_chars)
314}
315
316fn count_artifacts(v: &Value) -> usize {
317    match v {
318        Value::Object(m) => {
319            let here = usize::from(m.contains_key("artifact"));
320            here + m.values().map(count_artifacts).sum::<usize>()
321        }
322        Value::Array(a) => a.iter().map(count_artifacts).sum(),
323        _ => 0,
324    }
325}
326
327/// Replace every binary-ish string in `value` with an artifact-ref object and
328/// return `(redacted, artifact_count)`. No preview is emitted (budget 0), so
329/// this is safe to run *before* a preview/truncation step (e.g. browser compact
330/// mode) — the raw payload is archived, not truncated away.
331pub fn redact_binary(value: &Value, store: &ArtifactStore) -> (Value, usize) {
332    let budget = PreviewBudget::new(0);
333    let limits = SanitizeLimits::default();
334    let out = sanitize_value(value, None, store, &budget, &limits);
335    let n = count_artifacts(&out);
336    (out, n)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::path::PathBuf;
343
344    fn store() -> (ArtifactStore, PathBuf) {
345        let dir = std::env::temp_dir().join(format!("aacode_obs_{}", uuid::Uuid::new_v4()));
346        std::fs::create_dir_all(&dir).unwrap();
347        (ArtifactStore::new(&dir), dir)
348    }
349
350    fn lim() -> SanitizeLimits {
351        SanitizeLimits::default()
352    }
353
354    #[test]
355    fn inline_base64_field_becomes_artifact_ref() {
356        let (st, _d) = store();
357        let budget = PreviewBudget::new(lim().budget_chars);
358        // 400x800 raw RGBA-ish payload (binary, not PNG) encoded as base64.
359        let raws: Vec<u8> = (0..100_000u32).map(|i| (i * 7 % 256) as u8).collect();
360        let b64 = base64::engine::general_purpose::STANDARD.encode(&raws);
361        let raw = json!({"result": {"base64": b64, "format": "rgba", "width": 400, "height": 800}, "success": true}).to_string();
362        let out = sanitize(&raw, &st, &budget, &lim());
363        assert!(out.len() < 2_000, "sanitized len={}", out.len());
364        assert!(out.contains("artifact"), "out={out}");
365        assert!(out.contains("preview_hex"), "out={out}");
366        assert!(!out.contains(&"A".repeat(200)), "no bulk base64 remains");
367        let v: Value = serde_json::from_str(&out).unwrap();
368        assert_eq!(v["result"]["base64"]["bytes"], 100_000);
369    }
370
371    #[test]
372    fn data_uri_image_is_detected_and_extracted() {
373        let (st, _d) = store();
374        let budget = PreviewBudget::new(lim().budget_chars);
375        let png: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4];
376        let uri = format!(
377            "data:image/png;base64,{}",
378            base64::engine::general_purpose::STANDARD.encode(&png)
379        );
380        let raw = json!({"img": uri}).to_string();
381        let out = sanitize(&raw, &st, &budget, &lim());
382        assert!(out.contains("image/png"), "out={out}");
383        assert!(out.contains("artifact"), "out={out}");
384    }
385
386    #[test]
387    fn budget_limits_repeated_previews() {
388        let (st, _d) = store();
389        let mut l = lim();
390        l.preview_bytes = 64;
391        let budget = PreviewBudget::new(9); // only 3 bytes of preview total
392        let blob = base64::engine::general_purpose::STANDARD.encode(vec![0u8; 4096]);
393        let raw = json!({"a": {"base64": blob.clone()}, "b": {"base64": blob}}).to_string();
394        let out = sanitize(&raw, &st, &budget, &l);
395        // Second artifact reuses the exhausted budget → no preview.
396        let count = out.matches("preview_hex").count();
397        assert!(count <= 1, "preview budget not enforced: {out}");
398    }
399
400    #[test]
401    fn plain_text_not_truncated_by_default() {
402        let (st, _d) = store();
403        let budget = PreviewBudget::new(lim().budget_chars);
404        let raw = "line of text\n".repeat(20_000); // ~280k chars
405        let out = sanitize(&raw, &st, &budget, &lim());
406        assert_eq!(
407            out.len(),
408            raw.len(),
409            "plain text must pass through unbounded"
410        );
411    }
412
413    #[test]
414    fn plain_text_is_only_capped() {
415        let (st, _d) = store();
416        let budget = PreviewBudget::new(lim().budget_chars);
417        let raw = "hello world\n".repeat(10);
418        let out = sanitize(&raw, &st, &budget, &lim());
419        assert!(out.starts_with("hello world"));
420    }
421
422    #[test]
423    fn long_text_is_truncated() {
424        let (st, _d) = store();
425        let budget = PreviewBudget::new(lim().budget_chars);
426        let mut l = lim();
427        l.max_observation_chars = 100;
428        let raw = "x".repeat(5000);
429        let out = sanitize(&raw, &st, &budget, &l);
430        assert!(out.len() < 200, "len={}", out.len());
431        assert!(out.contains("truncated"));
432    }
433}