From f33ad7f024115aee0ee45ae5ed32117ea3889f4e Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 19:48:07 -0500 Subject: [PATCH 1/4] feat(format): Implement truncation methods --- rust/src/truncation.rs | 151 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 rust/src/truncation.rs diff --git a/rust/src/truncation.rs b/rust/src/truncation.rs new file mode 100644 index 0000000..c17623c --- /dev/null +++ b/rust/src/truncation.rs @@ -0,0 +1,151 @@ +//! Context-safe truncation for agent-facing command output. +//! +//! Large lists and payloads are capped before being rendered so a single +//! command can't blow an agent's context window; the untruncated data is +//! written to a side-channel file under `$TMPDIR/godaddy-cli/` instead. + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::Value; + +const MAX_LIST_ITEMS: usize = 50; +const MAX_STRING_LENGTH: usize = 1000; +const MAX_SERIALIZED_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TruncationMetadata { + pub(crate) truncated: bool, + pub(crate) total: usize, + pub(crate) shown: usize, + pub(crate) full_output: Option, +} + +pub(crate) struct ListTruncationResult { + pub(crate) items: Vec, + pub(crate) metadata: TruncationMetadata, +} + +pub(crate) struct PayloadTruncationResult { + pub(crate) value: Value, + pub(crate) metadata: Option, +} + +fn slugify(command_id: &str) -> String { + command_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) + .collect() +} + +fn estimate_bytes(value: &Value) -> usize { + serde_json::to_string(value).map(|s| s.len()).unwrap_or(0) +} + +/// Best-effort dump of the untruncated payload to a `0600` file under a +/// `0700` directory. Returns `None` if the filesystem write fails, in which +/// case the caller simply omits `full_output` from the truncation metadata. +fn write_full_output(command_id: &str, payload: &Value) -> Option { + let dir = std::env::temp_dir().join("godaddy-cli"); + std::fs::create_dir_all(&dir).ok()?; + + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis(); + let path = dir.join(format!("{millis}-{}.json", slugify(command_id))); + let contents = serde_json::to_string_pretty(payload).ok()?; + std::fs::write(&path, contents).ok()?; + + Some(path) +} + +fn truncate_strings(value: &Value) -> Value { + match value { + Value::String(s) if s.chars().count() > MAX_STRING_LENGTH => { + let truncated: String = s.chars().take(MAX_STRING_LENGTH).collect(); + Value::String(format!("{truncated}...(truncated)")) + } + Value::Array(items) => Value::Array(items.iter().map(truncate_strings).collect()), + Value::Object(obj) => Value::Object( + obj.iter() + .map(|(key, nested)| (key.clone(), truncate_strings(nested))) + .collect(), + ), + other => other.clone(), + } +} + +/// Caps a list at [`MAX_LIST_ITEMS`], dumping the full list to a side-channel +/// file when it was truncated. +pub(crate) fn truncate_list( + items: Vec, + command_id: &str, +) -> ListTruncationResult { + let total = items.len(); + let truncated = total > MAX_LIST_ITEMS; + + if !truncated { + return ListTruncationResult { + items, + metadata: TruncationMetadata { + truncated: false, + total, + full_output: None, + }, + }; + } + + let full_output = serde_json::to_value(&items) + .ok() + .and_then(|value| write_full_output(command_id, &value)) + .map(|path| path.display().to_string()); + let mut items = items; + items.truncate(MAX_LIST_ITEMS); + ListTruncationResult { + items, + metadata: TruncationMetadata { + truncated: true, + total, + full_output, + }, + } +} + +/// Truncates long strings within `value`, then falls back to a `{ truncated, +/// summary }` stub if the result still exceeds [`MAX_SERIALIZED_BYTES`]. +/// Dumps the original, untouched `value` to a side-channel file whenever +/// truncation occurred. +pub(crate) fn protect_payload(value: Value, command_id: &str) -> PayloadTruncationResult { + let total_bytes = estimate_bytes(&value); + let mut candidate = truncate_strings(&value); + let mut shown_bytes = estimate_bytes(&candidate); + let mut truncated = total_bytes != shown_bytes; + + + if !truncated { + return PayloadTruncationResult { + value: candidate, + metadata: None, + }; + } + + let full_output = write_full_output(command_id, &value).map(|path| path.display().to_string()); + PayloadTruncationResult { + value: candidate, + metadata: Some(TruncationMetadata { + truncated: true, + total: total_bytes, + shown: shown_bytes, + full_output, + }), + } +} + From a47f5ea70ea19c1071b4a79606cdd4a824728efc Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 19:48:31 -0500 Subject: [PATCH 2/4] chore(tests): Add tests and multi platform support --- rust/src/truncation.rs | 104 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/rust/src/truncation.rs b/rust/src/truncation.rs index c17623c..2b3ee0e 100644 --- a/rust/src/truncation.rs +++ b/rust/src/truncation.rs @@ -55,6 +55,12 @@ fn estimate_bytes(value: &Value) -> usize { fn write_full_output(command_id: &str, payload: &Value) -> Option { let dir = std::env::temp_dir().join("godaddy-cli"); std::fs::create_dir_all(&dir).ok()?; + #[cfg(unix)] + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, Permissions::from_mode(0o700)).ok(); + } let millis = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -63,6 +69,12 @@ fn write_full_output(command_id: &str, payload: &Value) -> Option { let path = dir.join(format!("{millis}-{}.json", slugify(command_id))); let contents = serde_json::to_string_pretty(payload).ok()?; std::fs::write(&path, contents).ok()?; + #[cfg(unix)] + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, Permissions::from_mode(0o600)).ok(); + } Some(path) } @@ -90,6 +102,7 @@ pub(crate) fn truncate_list( command_id: &str, ) -> ListTruncationResult { let total = items.len(); + let shown = total.min(MAX_LIST_ITEMS); let truncated = total > MAX_LIST_ITEMS; if !truncated { @@ -98,6 +111,7 @@ pub(crate) fn truncate_list( metadata: TruncationMetadata { truncated: false, total, + shown, full_output: None, }, }; @@ -114,6 +128,7 @@ pub(crate) fn truncate_list( metadata: TruncationMetadata { truncated: true, total, + shown, full_output, }, } @@ -129,6 +144,14 @@ pub(crate) fn protect_payload(value: Value, command_id: &str) -> PayloadTruncati let mut shown_bytes = estimate_bytes(&candidate); let mut truncated = total_bytes != shown_bytes; + if shown_bytes > MAX_SERIALIZED_BYTES { + truncated = true; + candidate = serde_json::json!({ + "truncated": true, + "summary": "Output too large for inline payload", + }); + shown_bytes = estimate_bytes(&candidate); + } if !truncated { return PayloadTruncationResult { @@ -149,3 +172,84 @@ pub(crate) fn protect_payload(value: Value, command_id: &str) -> PayloadTruncati } } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn truncate_list_under_limit_is_untouched() { + let items: Vec = (0..10).collect(); + let result = truncate_list(items, "test-under"); + assert_eq!(result.items.len(), 10); + assert!(!result.metadata.truncated); + assert_eq!(result.metadata.total, 10); + assert_eq!(result.metadata.shown, 10); + assert!(result.metadata.full_output.is_none()); + } + + #[test] + fn truncate_list_over_limit_caps_and_writes_full_output() { + let items: Vec = (0..75).collect(); + let result = truncate_list(items, "test-over"); + assert_eq!(result.items.len(), MAX_LIST_ITEMS); + assert!(result.metadata.truncated); + assert_eq!(result.metadata.total, 75); + assert_eq!(result.metadata.shown, MAX_LIST_ITEMS); + let full_output = result.metadata.full_output.expect("full_output path"); + let contents = std::fs::read_to_string(&full_output).expect("full output file exists"); + let parsed: Vec = serde_json::from_str(&contents).expect("valid json"); + assert_eq!(parsed.len(), 75); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&full_output) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn truncate_strings_truncates_long_values_recursively() { + let long = "a".repeat(1500); + let value = json!({ "nested": [long.clone()] }); + let truncated = truncate_strings(&value); + let nested = truncated["nested"][0].as_str().expect("string"); + assert!(nested.ends_with("...(truncated)")); + assert_eq!(nested.len(), MAX_STRING_LENGTH + "...(truncated)".len()); + } + + #[test] + fn protect_payload_passes_through_small_values_unchanged() { + let value = json!({ "name": "short" }); + let result = protect_payload(value.clone(), "test-small"); + assert_eq!(result.value, value); + assert!(result.metadata.is_none()); + } + + #[test] + fn protect_payload_truncates_long_strings_and_reports_metadata() { + let long = "a".repeat(1500); + let value = json!({ "name": long }); + let result = protect_payload(value, "test-strings"); + let metadata = result.metadata.expect("metadata present"); + assert!(metadata.truncated); + assert!(metadata.full_output.is_some()); + } + + #[test] + fn protect_payload_falls_back_to_summary_when_still_too_large() { + let big_array: Vec = (0..2000).map(|i| format!("item-{i}")).collect(); + let value = json!({ "items": big_array }); + let result = protect_payload(value, "test-huge"); + let metadata = result.metadata.expect("metadata present"); + assert!(metadata.truncated); + assert_eq!(result.value["truncated"], json!(true)); + assert!(result.value.get("summary").is_some()); + let full_output = metadata.full_output.expect("full_output path"); + std::fs::remove_file(full_output).ok(); + } +} From ec18f1fa24f3ef40b579e632acb98cbe455da6bb Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 19:49:08 -0500 Subject: [PATCH 3/4] chore(format): Wire truncate methods on mod.rs --- rust/src/actions_catalog/mod.rs | 35 +++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/rust/src/actions_catalog/mod.rs b/rust/src/actions_catalog/mod.rs index 96dd6b6..f1c5256 100644 --- a/rust/src/actions_catalog/mod.rs +++ b/rust/src/actions_catalog/mod.rs @@ -2,9 +2,10 @@ use cli_engine::{ CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; -use serde_json::json; +use serde_json::{Value, json}; use crate::next_action::next_action; +use crate::truncation::{protect_payload, truncate_list}; /// Static catalog of available GoDaddy action names. /// Loaded from the manifest at compile time. @@ -32,7 +33,7 @@ const ACTIONS: &[(&str, &str)] = &[ /// Raw action schema JSON files embedded at compile time. /// Key is the action name; value is the full JSON schema. -fn load_action_schema(name: &str) -> Option { +fn load_action_schema(name: &str) -> Option { let json_str = match name { "location.address.verify" => { include_str!("../../schemas/actions/location-address-verify.json") @@ -88,7 +89,17 @@ pub fn group() -> RuntimeGroupSpec { .iter() .map(|(name, description)| json!({ "name": name, "description": description })) .collect(); - Ok(CommandResult::new(json!({ "actions": actions })).with_next_actions(vec![ + let result = truncate_list(actions, "actions-list"); + let mut payload = json!({ + "actions": result.items, + "total": result.metadata.total, + "shown": result.metadata.shown, + "truncated": result.metadata.truncated, + }); + if let Some(full_output) = result.metadata.full_output { + payload["full_output"] = json!(full_output); + } + Ok(CommandResult::new(payload).with_next_actions(vec![ next_action( "platform actions describe ", "See an action's full schema", @@ -128,7 +139,23 @@ pub fn group() -> RuntimeGroupSpec { "action {name:?} not found; run `gddy platform actions list` to see available actions" )) })?; - Ok(CommandResult::new(schema)) + let result = protect_payload(schema, &format!("actions-describe-{name}")); + let mut payload = result.value; + if let Value::Object(ref mut map) = payload { + map.insert("truncated".to_string(), json!(result.metadata.is_some())); + if let Some(metadata) = result.metadata { + map.insert("total".to_string(), json!(metadata.total)); + map.insert("shown".to_string(), json!(metadata.shown)); + + // Truncation should always produce a full_output file, but the write + // is best-effort and can fail (e.g. disk full, permission denied), so + // this stays an `if let` rather than an unconditional insert. + if let Some(full_output) = metadata.full_output { + map.insert("full_output".to_string(), json!(full_output)); + } + } + } + Ok(CommandResult::new(payload)) }, )) } From 114cc873401aeaf3aae36944b980c903ec0d1f34 Mon Sep 17 00:00:00 2001 From: Mateo Guerrero Date: Mon, 3 Aug 2026 19:49:44 -0500 Subject: [PATCH 4/4] chore(tests): Add behavior tests and import truncation for crate --- rust/src/actions_catalog/mod.rs | 48 +++++++++++++++++++++++++++++++++ rust/src/main.rs | 1 + 2 files changed, 49 insertions(+) diff --git a/rust/src/actions_catalog/mod.rs b/rust/src/actions_catalog/mod.rs index f1c5256..d684c98 100644 --- a/rust/src/actions_catalog/mod.rs +++ b/rust/src/actions_catalog/mod.rs @@ -159,3 +159,51 @@ pub fn group() -> RuntimeGroupSpec { }, )) } + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig, Stage}; + + fn test_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Experimental) + .with_modules(crate::all_modules()), + ) + } + + #[tokio::test] + async fn list_reports_truncation_metadata_and_no_full_output_under_the_cap() { + let cli = test_cli(); + let output = cli + .run(["gddy", "platform", "actions", "list", "--output", "json"]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("stdout should contain json"); + let data = &rendered["data"]; + assert_eq!(data["truncated"], serde_json::json!(false)); + assert_eq!(data["total"], data["shown"]); + assert!(data.get("full_output").is_none()); + } + + #[tokio::test] + async fn describe_reports_protection_metadata() { + let cli = test_cli(); + let output = cli + .run([ + "gddy", + "platform", + "actions", + "describe", + "location.address.verify", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("stdout should contain json"); + assert_eq!(rendered["data"]["truncated"], serde_json::json!(false)); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 2ad887f..50841df 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -20,6 +20,7 @@ mod platform; mod quote_cache; mod scopes; mod scopes_cmd; +mod truncation; mod update; mod webhook;