Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions rust/src/actions_catalog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<serde_json::Value> {
fn load_action_schema(name: &str) -> Option<Value> {
let json_str = match name {
"location.address.verify" => {
include_str!("../../schemas/actions/location-address-verify.json")
Expand Down Expand Up @@ -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 <action>",
"See an action's full schema",
Expand Down Expand Up @@ -128,7 +139,71 @@ 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))
Comment on lines +142 to +158
},
))
}

#[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));
}
}
1 change: 1 addition & 0 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod platform;
mod quote_cache;
mod scopes;
mod scopes_cmd;
mod truncation;
mod update;
mod webhook;

Expand Down
255 changes: 255 additions & 0 deletions rust/src/truncation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
//! 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<String>,
}
Comment on lines +18 to +23

pub(crate) struct ListTruncationResult<T> {
pub(crate) items: Vec<T>,
pub(crate) metadata: TruncationMetadata,
}

pub(crate) struct PayloadTruncationResult {
pub(crate) value: Value,
pub(crate) metadata: Option<TruncationMetadata>,
}

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)
}
Comment on lines +48 to +50

/// 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<PathBuf> {
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)
.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()?;
Comment on lines +65 to +71
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, Permissions::from_mode(0o600)).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)"))
}
Comment on lines +84 to +87
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<T: Serialize>(
items: Vec<T>,
command_id: &str,
) -> ListTruncationResult<T> {
let total = items.len();
let shown = total.min(MAX_LIST_ITEMS);
let truncated = total > MAX_LIST_ITEMS;

if !truncated {
return ListTruncationResult {
items,
metadata: TruncationMetadata {
truncated: false,
total,
shown,
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,
shown,
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 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 {
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,
}),
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn truncate_list_under_limit_is_untouched() {
let items: Vec<u32> = (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<u32> = (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<u32> = 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);
}
}
Comment on lines +211 to +213

#[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<String> = (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();
}
}
Loading