Skip to main content

ssh_cli/
agent_shape.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-AGENT-01: pure module — no `unsafe`.
3#![forbid(unsafe_code)]
4//! Agent-native payload shaping applied at the single JSON serialization funnel.
5//!
6//! # Why this exists
7//!
8//! `ssh-cli` emits structured JSON for fan-out commands (`vps list`,
9//! `health-check --all`, `exec --all`, the SCP/SFTP batches, `sftp ls`). Without a
10//! reduction surface the agent receives the *whole* envelope and has to shell out to
11//! `jaq` to cut it down — which means the large payload is built, serialized, written
12//! and then thrown away. The tokens are already spent by then.
13//!
14//! Shaping therefore happens **before** serialization, inside
15//! [`crate::json_wire::print_json_line`], so the oversized envelope never reaches
16//! stdout in the first place.
17//!
18//! # Operation order is fixed
19//!
20//! `filter` → `sort` → `dedupe` → `limit` → `select` → `count-only` →
21//! `truncate-content` → `max-output-bytes`
22//!
23//! The order matters: filtering before limiting keeps the first N *matching* records
24//! rather than filtering an arbitrary prefix, and selecting after sorting lets a run
25//! sort by a field it does not intend to emit.
26//!
27//! # Cost when unused
28//!
29//! [`crate::agent_shape::is_active`] short-circuits the whole path. When no shaping flag was passed the
30//! caller serializes exactly as before, so the default path pays no
31//! `serde_json::to_value` round-trip.
32//!
33//! # Truncation is never silent
34//!
35//! Any run that drops or shortens data reports it under the `agent_shape` key, so an
36//! agent can tell "three hosts" apart from "three hosts shown out of ninety".
37
38use serde_json::{Map, Value};
39use std::sync::Mutex;
40
41/// Envelope keys inspected, in order, when locating the shapeable array.
42///
43/// Fan-out payloads in this crate name their collection differently per command;
44/// probing a fixed list keeps shaping generic without each call site opting in.
45const ARRAY_KEYS: &[&str] = &[
46    "results", "items", "hosts", "entries", "vps", "matches", "rows", "data", "steps",
47];
48
49/// Key holding the shaping report added to a reduced envelope.
50const REPORT_KEY: &str = "agent_shape";
51
52/// A single `--filter` predicate.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Filter {
55    /// Dotted path into each element.
56    pub path: String,
57    /// Comparison to apply.
58    pub op: FilterOp,
59    /// Right-hand side, compared as a string.
60    pub value: String,
61}
62
63/// Comparison used by a [`Filter`].
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum FilterOp {
66    /// `key=value` (also accepted as `key==value`).
67    Equals,
68    /// `key!=value`.
69    NotEquals,
70    /// `key~substring`.
71    Contains,
72}
73
74impl Filter {
75    /// Parses `key=value`, `key!=value` or `key~substring`.
76    ///
77    /// # Errors
78    /// Returns a human-readable message when no operator is present or the key is
79    /// empty. Failing loudly matters: a typo that silently matched nothing would look
80    /// exactly like a legitimately empty result.
81    pub fn parse(raw: &str) -> Result<Self, String> {
82        if let Some((k, v)) = raw.split_once("!=") {
83            return Self::build(k, FilterOp::NotEquals, v);
84        }
85        if let Some((k, v)) = raw.split_once("==") {
86            return Self::build(k, FilterOp::Equals, v);
87        }
88        if let Some((k, v)) = raw.split_once('~') {
89            return Self::build(k, FilterOp::Contains, v);
90        }
91        if let Some((k, v)) = raw.split_once('=') {
92            return Self::build(k, FilterOp::Equals, v);
93        }
94        Err(format!(
95            "invalid --filter `{raw}`: expected key=value, key!=value or key~substring"
96        ))
97    }
98
99    fn build(key: &str, op: FilterOp, value: &str) -> Result<Self, String> {
100        let key = key.trim();
101        if key.is_empty() {
102            return Err("invalid --filter: empty key".to_string());
103        }
104        Ok(Self {
105            path: key.to_string(),
106            op,
107            value: value.to_string(),
108        })
109    }
110
111    fn matches(&self, element: &Value) -> bool {
112        let actual = lookup(element, &self.path).map(scalar_to_string);
113        match (&self.op, actual) {
114            // A missing field never satisfies a predicate, not even `!=`. Treating
115            // absence as "different" would silently promote incomplete records.
116            (_, None) => false,
117            (FilterOp::Equals, Some(a)) => a == self.value,
118            (FilterOp::NotEquals, Some(a)) => a != self.value,
119            (FilterOp::Contains, Some(a)) => a.contains(&self.value),
120        }
121    }
122}
123
124/// Shaping options resolved from global CLI flags.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ShapeConfig {
127    /// Dotted paths to keep in each element (`--select` / `--fields`).
128    pub select: Vec<String>,
129    /// Conjunctive predicates (`--filter`, repeatable).
130    pub filters: Vec<Filter>,
131    /// Max elements emitted (`--limit`).
132    pub limit: Option<usize>,
133    /// Dotted path to sort ascending by (`--sort`).
134    pub sort: Option<String>,
135    /// Dotted path to deduplicate by (`--dedupe-by`).
136    pub dedupe_by: Option<String>,
137    /// Replace the payload with `{"count": N}` (`--count-only`).
138    pub count_only: bool,
139    /// Shorten strings above this many characters (`--truncate-content`).
140    pub truncate_content: Option<usize>,
141    /// Drop trailing elements until the envelope fits (`--max-output-bytes`).
142    pub max_output_bytes: Option<usize>,
143}
144
145impl ShapeConfig {
146    /// Whether any shaping was requested.
147    #[must_use]
148    pub fn is_active(&self) -> bool {
149        !self.select.is_empty()
150            || !self.filters.is_empty()
151            || self.limit.is_some()
152            || self.sort.is_some()
153            || self.dedupe_by.is_some()
154            || self.count_only
155            || self.truncate_content.is_some()
156            || self.max_output_bytes.is_some()
157    }
158}
159
160static SHAPE: Mutex<Option<ShapeConfig>> = Mutex::new(None);
161
162fn lock_shape() -> std::sync::MutexGuard<'static, Option<ShapeConfig>> {
163    SHAPE.lock().unwrap_or_else(|poisoned| {
164        tracing::warn!("agent-shape mutex was poisoned; recovering (one-shot CLI)");
165        poisoned.into_inner()
166    })
167}
168
169/// Installs the process-wide shaping configuration (called once after argv parse).
170pub fn set_shape(cfg: ShapeConfig) {
171    *lock_shape() = if cfg.is_active() { Some(cfg) } else { None };
172}
173
174/// Whether shaping is active for this process.
175#[must_use]
176pub fn is_active() -> bool {
177    lock_shape().is_some()
178}
179
180/// Returns a clone of the active configuration, if any.
181#[must_use]
182pub fn current() -> Option<ShapeConfig> {
183    lock_shape().clone()
184}
185
186/// Resolves a dotted path such as `host.port` against a value.
187fn lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
188    let mut cur = value;
189    for segment in path.split('.') {
190        cur = cur.as_object()?.get(segment)?;
191    }
192    Some(cur)
193}
194
195/// Renders a scalar for comparison; containers never match a predicate.
196fn scalar_to_string(v: &Value) -> String {
197    match v {
198        Value::String(s) => s.clone(),
199        Value::Number(n) => n.to_string(),
200        Value::Bool(b) => b.to_string(),
201        Value::Null => "null".to_string(),
202        other => other.to_string(),
203    }
204}
205
206/// Orders two values, comparing numbers numerically and everything else as text.
207fn compare(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
208    use std::cmp::Ordering;
209    match (a, b) {
210        // Elements missing the sort key sink to the end instead of pretending to be
211        // the smallest value.
212        (None, None) => Ordering::Equal,
213        (None, Some(_)) => Ordering::Greater,
214        (Some(_), None) => Ordering::Less,
215        (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
216            (Some(nx), Some(ny)) => nx.partial_cmp(&ny).unwrap_or(Ordering::Equal),
217            _ => scalar_to_string(x).cmp(&scalar_to_string(y)),
218        },
219    }
220}
221
222/// Keeps only the selected dotted paths, preserving nesting.
223fn project(element: &Value, paths: &[String]) -> Value {
224    let mut out = Map::new();
225    for path in paths {
226        // A path that does not resolve is skipped rather than emitted as `null`;
227        // a null would be indistinguishable from a field that genuinely is null.
228        if let Some(found) = lookup(element, path) {
229            insert_path(&mut out, path, found.clone());
230        }
231    }
232    Value::Object(out)
233}
234
235fn insert_path(target: &mut Map<String, Value>, path: &str, value: Value) {
236    let mut segments = path.split('.').peekable();
237    let mut cursor = target;
238    while let Some(seg) = segments.next() {
239        if segments.peek().is_none() {
240            cursor.insert(seg.to_string(), value);
241            return;
242        }
243        let entry = cursor
244            .entry(seg.to_string())
245            .or_insert_with(|| Value::Object(Map::new()));
246        if !entry.is_object() {
247            *entry = Value::Object(Map::new());
248        }
249        match entry.as_object_mut() {
250            Some(next) => cursor = next,
251            None => return,
252        }
253    }
254}
255
256/// Shortens every string longer than `max` characters, on char boundaries.
257fn truncate_strings(value: &mut Value, max: usize, changed: &mut bool) {
258    match value {
259        Value::String(s) => {
260            // Count characters, never bytes: cutting at a byte offset would split a
261            // multi-byte sequence and produce invalid UTF-8 in the envelope.
262            if s.chars().count() > max {
263                let cut: String = s.chars().take(max).collect();
264                *s = cut;
265                *changed = true;
266            }
267        }
268        Value::Array(items) => {
269            for item in items {
270                truncate_strings(item, max, changed);
271            }
272        }
273        Value::Object(map) => {
274            for (_, v) in map.iter_mut() {
275                truncate_strings(v, max, changed);
276            }
277        }
278        _ => {}
279    }
280}
281
282/// Locates the shapeable array inside an envelope.
283fn find_array_key(map: &Map<String, Value>) -> Option<String> {
284    ARRAY_KEYS
285        .iter()
286        .find(|k| map.get(**k).is_some_and(Value::is_array))
287        .map(|k| (*k).to_string())
288}
289
290/// Outcome of shaping a record collection.
291#[derive(Debug, Clone, Copy, Default)]
292struct ShapeReport {
293    input_count: usize,
294    output_count: usize,
295    content_truncated: bool,
296}
297
298impl ShapeReport {
299    fn dropped(&self) -> usize {
300        self.input_count.saturating_sub(self.output_count)
301    }
302
303    fn changed_anything(&self) -> bool {
304        self.dropped() > 0 || self.content_truncated
305    }
306}
307
308/// Applies filter → sort → dedupe → limit → select → truncate to a record list.
309fn shape_items(items: &mut Vec<Value>, cfg: &ShapeConfig) -> ShapeReport {
310    let input_count = items.len();
311    let mut content_truncated = false;
312
313    if !cfg.filters.is_empty() {
314        items.retain(|item| cfg.filters.iter().all(|f| f.matches(item)));
315    }
316
317    if let Some(path) = &cfg.sort {
318        items.sort_by(|a, b| compare(lookup(a, path), lookup(b, path)));
319    }
320
321    if let Some(path) = &cfg.dedupe_by {
322        let mut seen = std::collections::HashSet::new();
323        items.retain(|item| match lookup(item, path) {
324            // Elements without the key are always kept: dropping them would delete
325            // records for lacking a field rather than for being duplicates.
326            None => true,
327            Some(v) => seen.insert(scalar_to_string(v)),
328        });
329    }
330
331    if let Some(limit) = cfg.limit {
332        items.truncate(limit);
333    }
334
335    if !cfg.select.is_empty() {
336        for item in items.iter_mut() {
337            *item = project(item, &cfg.select);
338        }
339    }
340
341    if let Some(max) = cfg.truncate_content {
342        for item in items.iter_mut() {
343            truncate_strings(item, max, &mut content_truncated);
344        }
345    }
346
347    ShapeReport {
348        input_count,
349        output_count: items.len(),
350        content_truncated,
351    }
352}
353
354/// Applies the active shaping to a serialized payload, in the documented order.
355///
356/// Handles both payload shapes this CLI emits: a bare top-level array (`vps list`,
357/// `sftp ls`) and an envelope object wrapping a named collection (the batch events).
358/// Missing the bare-array case would have left shaping inert on the most frequently
359/// used commands.
360///
361/// Returns `true` when the payload was modified.
362pub fn apply(root: &mut Value, cfg: &ShapeConfig) -> bool {
363    match root {
364        Value::Array(items) => {
365            let report = shape_items(items, cfg);
366            if cfg.count_only {
367                *root = Value::Object({
368                    let mut m = Map::new();
369                    m.insert("count".to_string(), Value::from(report.output_count));
370                    m
371                });
372                return true;
373            }
374            let byte_capped = cap_output_bytes(root, cfg);
375            // A bare array has nowhere to carry a report object without changing its
376            // type and breaking every consumer that indexes it. stdout stays a pure
377            // array and the accounting goes to stderr, where diagnostics belong.
378            if report.changed_anything() || byte_capped {
379                tracing::info!(
380                    input_count = report.input_count,
381                    output_count = report.output_count,
382                    dropped = report.dropped(),
383                    content_truncated = report.content_truncated,
384                    output_truncated = byte_capped,
385                    "agent-shape reduced the payload"
386                );
387            }
388            true
389        }
390        Value::Object(_) => apply_to_envelope(root, cfg),
391        _ => false,
392    }
393}
394
395/// Shapes the collection inside an envelope object and attaches the report inline.
396fn apply_to_envelope(root: &mut Value, cfg: &ShapeConfig) -> bool {
397    let Some(map) = root.as_object_mut() else {
398        return false;
399    };
400    let Some(key) = find_array_key(map) else {
401        // Envelopes without a collection (single-record reads, ready events) are left
402        // untouched; there is nothing to reduce and rewriting them would only risk
403        // breaking their schema.
404        return false;
405    };
406    let Some(Value::Array(items)) = map.get_mut(&key) else {
407        return false;
408    };
409
410    let report = shape_items(items, cfg);
411    let mut output_count = report.output_count;
412
413    let mut byte_capped = false;
414    if cfg.count_only {
415        // Counted after every other stage, so the number describes what *would* have
416        // been emitted rather than the raw input. The collection itself is dropped.
417        map.remove(&key);
418        map.insert("count".to_string(), Value::from(output_count));
419    } else if let Some(max_bytes) = cfg.max_output_bytes {
420        // Drop whole trailing elements rather than slicing the serialized text: a
421        // byte-sliced envelope would not parse as JSON at all.
422        loop {
423            let too_big = serde_json::to_string(&Value::Object(map.clone()))
424                .map(|s| s.len() > max_bytes)
425                .unwrap_or(false);
426            if !too_big {
427                break;
428            }
429            let Some(Value::Array(items)) = map.get_mut(&key) else {
430                break;
431            };
432            if items.pop().is_none() {
433                break;
434            }
435            byte_capped = true;
436            output_count = items.len();
437        }
438    }
439
440    let mut out = Map::new();
441    out.insert("input_count".to_string(), Value::from(report.input_count));
442    out.insert("output_count".to_string(), Value::from(output_count));
443    out.insert(
444        "dropped".to_string(),
445        Value::from(report.input_count.saturating_sub(output_count)),
446    );
447    if report.content_truncated {
448        out.insert("content_truncated".to_string(), Value::Bool(true));
449    }
450    if byte_capped {
451        out.insert("output_truncated".to_string(), Value::Bool(true));
452    }
453    map.insert(REPORT_KEY.to_string(), Value::Object(out));
454    true
455}
456
457/// Drops trailing elements of a top-level array until it fits `max_output_bytes`.
458fn cap_output_bytes(root: &mut Value, cfg: &ShapeConfig) -> bool {
459    let Some(max_bytes) = cfg.max_output_bytes else {
460        return false;
461    };
462    let mut capped = false;
463    loop {
464        let too_big = serde_json::to_string(&*root)
465            .map(|s| s.len() > max_bytes)
466            .unwrap_or(false);
467        if !too_big {
468            return capped;
469        }
470        let Some(items) = root.as_array_mut() else {
471            return capped;
472        };
473        if items.pop().is_none() {
474            return capped;
475        }
476        capped = true;
477    }
478}
479
480#[cfg(test)]
481#[path = "agent_shape_tests.rs"]
482mod tests;