use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use iron_shell::agent_api::{process_request, AgentRequest, AgentResponse, SchemaFormat};
use serde_json::{json, Value as JsonValue};
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct Sandbox {
_guard: MutexGuard<'static, ()>,
root: PathBuf,
}
impl Sandbox {
fn new(name: &str) -> Sandbox {
let guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let root =
std::env::temp_dir().join(format!("ish-call-args-{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("session")).expect("create session dir");
std::env::set_var("IRONSHELL_SESSION_DIR", root.join("session"));
Sandbox {
_guard: guard,
root,
}
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
std::env::remove_var("IRONSHELL_SESSION_DIR");
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn call(builtin: &str, args: JsonValue) -> AgentResponse {
process_request(&AgentRequest::Call {
builtin: builtin.to_string(),
args,
})
}
fn pipeline(steps: JsonValue, input: Option<JsonValue>) -> AgentResponse {
process_request(&AgentRequest::Pipeline {
steps: serde_json::from_value(steps).expect("pipeline steps"),
input,
})
}
#[test]
fn named_args_reach_the_builtin_in_their_declared_positions() {
let _sandbox = Sandbox::new("named");
let named = call(
"crypto_hmac",
json!({"key": "secret", "data": "message", "algorithm": "sha512"}),
);
let positional = call("crypto_hmac", json!(["message", "secret", "sha512"]));
assert!(named.success, "named call failed: {:?}", named.error);
assert!(
positional.success,
"positional call failed: {:?}",
positional.error
);
assert_eq!(named.result, positional.result);
assert_eq!(
named.result.as_ref().and_then(|v| v.as_str()).map(str::len),
Some(128)
);
let default = call("crypto_hmac", json!({"data": "message", "key": "secret"}));
let default_positional = call("crypto_hmac", json!(["message", "secret"]));
assert!(default.success, "default call failed: {:?}", default.error);
assert_eq!(default.result, default_positional.result);
assert_ne!(
default.result, named.result,
"sha256 and sha512 must differ"
);
}
#[test]
fn missing_required_named_argument_is_refused() {
let _sandbox = Sandbox::new("missing");
let response = call("crypto_hmac", json!({"key": "secret"}));
assert!(!response.success);
let error = response.error.unwrap_or_default();
assert!(
error.contains("missing required argument 'data'"),
"{error}"
);
}
#[test]
fn unknown_named_argument_is_refused_and_names_the_valid_ones() {
let _sandbox = Sandbox::new("unknown");
let response = call(
"crypto_hmac",
json!({"data": "message", "key": "secret", "hash": "sha256"}),
);
assert!(
!response.success,
"an unknown name must not be dropped and run"
);
let error = response.error.unwrap_or_default();
assert!(error.contains("'hash'"), "{error}");
for valid in ["data", "key", "algorithm"] {
assert!(error.contains(valid), "error must name '{valid}': {error}");
}
assert_eq!(
response.metadata.as_ref().and_then(|m| m.get("code")),
Some(&json!("E_BAD_ARG"))
);
}
#[test]
fn string_arguments_stay_strings_and_run_nothing() {
let sandbox = Sandbox::new("inject");
let sentinel = sandbox.root.join("sentinel.txt");
let target = sentinel.display().to_string().replace('\\', "\\\\");
let quoted = format!("x\"); file_write(\"{target}\", \"ran\"); (\"");
let interpolated = format!("${{file_write(\"{target}\", \"ran\")}}");
for payload in ["ed, &interpolated] {
let echoed = call("trim", json!([payload]));
assert!(echoed.success, "trim failed: {:?}", echoed.error);
assert_eq!(echoed.result, Some(json!(payload)));
let named = call("crypto_hmac", json!({"data": payload, "key": "k"}));
let positional = call("crypto_hmac", json!([payload, "k"]));
assert!(named.success, "named call failed: {:?}", named.error);
assert_eq!(named.result, positional.result);
}
assert!(!sentinel.exists(), "a string argument was run as code");
}
#[test]
fn a_pipeline_select_must_be_a_field_name() {
let _sandbox = Sandbox::new("select-name");
let input = Some(json!([{"name": "a"}]));
for bad in ["name key", "name)", "a.b", "1st", "x-y"] {
let response = pipeline(
json!([{"builtin": "where", "predicate": "true", "select": bad}]),
input.clone(),
);
assert!(!response.success, "select {bad:?} must be refused");
assert_eq!(response.code.as_deref(), Some("E_BAD_ARG"));
let error = response.error.unwrap_or_default();
assert!(error.contains(bad), "error must name {bad:?}: {error}");
}
let named = pipeline(
json!([{"builtin": "where", "predicate": "true", "select": "name"}]),
input,
);
assert!(named.success, "select name failed: {:?}", named.error);
assert_eq!(named.result, Some(json!(["a"])));
}
#[test]
fn a_pipeline_predicate_must_be_one_expression() {
let _sandbox = Sandbox::new("predicate-one");
let input = Some(json!([1, 2]));
let response = pipeline(
json!([{"builtin": "where", "predicate": "true) | map(fn(x) => 1"}]),
input.clone(),
);
assert!(!response.success, "a two-stage predicate must be refused");
assert_eq!(response.code.as_deref(), Some("E_BAD_ARG"));
let response = pipeline(
json!([{"builtin": "where", "predicate": "true; 1"}]),
input.clone(),
);
assert!(
!response.success,
"a two-statement predicate must be refused"
);
let plain = pipeline(
json!([{"builtin": "where", "predicate": "size > 1000"}]),
Some(json!([{"size": 1}, {"size": 2000}])),
);
assert!(
plain.success,
"a comparison predicate failed: {:?}",
plain.error
);
assert_eq!(plain.result, Some(json!([{"size": 2000}])));
}
#[test]
fn select_and_predicate_attacks_run_nothing() {
let sandbox = Sandbox::new("pipeline-inject");
let marker = sandbox.root.join("marker.txt");
let target = marker.display().to_string().replace('\\', "\\\\");
let input = Some(json!([{"name": "a", "size": 5}]));
let direct = process_request(&AgentRequest::Eval {
code: format!("file_write(\"{target}\", \"ran\")"),
});
assert!(direct.success, "control write failed: {:?}", direct.error);
assert!(marker.exists(), "control write did not create the marker");
std::fs::remove_file(&marker).expect("remove control marker");
let effect = format!("map(fn(x) => file_write(\"{target}\", \"ran\"))");
let select_attack = format!("name) | {effect} | map(fn(x) => x.name");
let predicate_attack = format!("size > 0) | {effect} | where(fn(x) => size > 0");
let by_select = pipeline(
json!([{"builtin": "where", "predicate": "true", "select": select_attack}]),
input.clone(),
);
assert!(!by_select.success, "a select attack was accepted");
assert_eq!(by_select.code.as_deref(), Some("E_BAD_ARG"));
let by_predicate = pipeline(
json!([{"builtin": "where", "predicate": predicate_attack}]),
input,
);
assert!(!by_predicate.success, "a predicate attack was accepted");
assert_eq!(by_predicate.code.as_deref(), Some("E_BAD_ARG"));
assert!(!marker.exists(), "a select or predicate was run as code");
}
#[test]
fn code_and_hint_come_from_the_structured_error_only_when_there_is_one() {
let _sandbox = Sandbox::new("code-hint");
let parse = process_request(&AgentRequest::Eval {
code: "(((".to_string(),
});
let wire = serde_json::to_value(&parse).expect("serialise");
assert_eq!(wire["code"], json!("E_PARSE"));
assert!(wire["hint"].is_string(), "{wire}");
assert_eq!(wire["error"]["code"], json!("E_PARSE"), "{wire}");
assert!(wire["error"]["message"].is_string(), "{wire}");
assert!(wire["error"]["retryable"].is_boolean(), "{wire}");
let select = pipeline(
json!([{"builtin": "where", "predicate": "true", "select": "a b"}]),
Some(json!([{"a": 1}])),
);
assert_eq!(select.code.as_deref(), Some("E_BAD_ARG"));
assert!(select.hint.as_deref().is_some_and(|h| !h.is_empty()));
let ok = call("len", json!(["hello"]));
let wire = serde_json::to_value(&ok).expect("serialise");
assert!(
wire.get("code").is_none() && wire.get("hint").is_none(),
"{wire}"
);
let plain = process_request(&AgentRequest::Pipeline {
steps: Vec::new(),
input: None,
});
assert!(!plain.success);
assert_eq!(plain.code, None);
assert_eq!(plain.hint, None);
}
#[test]
fn parse_error_is_the_structured_json_without_a_prefix() {
let _sandbox = Sandbox::new("parse");
let response = process_request(&AgentRequest::Eval {
code: "(((".to_string(),
});
assert!(!response.success);
let error = response.error.expect("a parse error message");
assert!(
!error.starts_with("Parse error:"),
"prefix on JSON: {error}"
);
let parsed: JsonValue =
serde_json::from_str(&error).unwrap_or_else(|e| panic!("not JSON ({e}): {error}"));
assert_eq!(parsed["error"]["code"], json!("E_PARSE"));
assert!(parsed["error"]["message"].is_string());
assert!(parsed["error"]["hint"].is_string());
}
#[test]
fn two_schema_requests_return_identical_bytes() {
let _sandbox = Sandbox::new("schema");
let request = AgentRequest::Schema {
format: SchemaFormat::Claude,
module: None,
q: None,
};
let first = process_request(&request);
let second = process_request(&request);
assert!(first.success && second.success);
assert_eq!(first.result_type, second.result_type);
assert_eq!(
serde_json::to_string_pretty(&first.result).expect("serialise"),
serde_json::to_string_pretty(&second.result).expect("serialise"),
);
}
fn schema(module: Option<&str>, q: Option<&str>) -> AgentResponse {
process_request(&AgentRequest::Schema {
format: SchemaFormat::Ontology,
module: module.map(str::to_string),
q: q.map(str::to_string),
})
}
fn builtin_names(response: &AgentResponse) -> Vec<String> {
let result = response.result.as_ref().expect("a schema result");
result["builtins"]
.as_array()
.expect("a builtins array")
.iter()
.map(|b| b["n"].as_str().expect("a name").to_string())
.collect()
}
#[test]
fn a_module_filter_shrinks_every_provider_format() {
let _sandbox = Sandbox::new("schema-filter-shrinks");
let whole = process_request(&AgentRequest::Schema {
format: SchemaFormat::Claude,
module: None,
q: None,
});
let filtered = process_request(&AgentRequest::Schema {
format: SchemaFormat::Claude,
module: Some("file".to_string()),
q: None,
});
assert!(whole.success && filtered.success);
let whole_tools = whole.result.as_ref().unwrap()["tools"]
.as_array()
.unwrap()
.len();
let file_tools = filtered.result.as_ref().unwrap()["tools"]
.as_array()
.unwrap()
.len();
assert!(file_tools > 0, "the file module has builtins");
assert!(
file_tools * 10 < whole_tools,
"the file module is a small part of the catalogue: {file_tools} of {whole_tools}"
);
let whole_bytes = serde_json::to_string(&whole.result).unwrap().len();
let file_bytes = serde_json::to_string(&filtered.result).unwrap().len();
assert!(file_bytes < whole_bytes, "{file_bytes} vs {whole_bytes}");
}
#[test]
fn a_query_keeps_only_builtins_whose_name_or_description_match() {
let _sandbox = Sandbox::new("schema-filter-query");
let whole = builtin_names(&schema(None, None));
let matched = schema(None, Some("READ"));
assert!(matched.success);
let names = builtin_names(&matched);
assert!(
!names.is_empty() && names.len() < whole.len() / 2,
"{}",
names.len()
);
for entry in matched.result.as_ref().unwrap()["builtins"]
.as_array()
.unwrap()
{
let text = format!(
"{} {}",
entry["n"].as_str().unwrap(),
entry["d"].as_str().unwrap()
)
.to_lowercase();
assert!(text.contains("read"), "{text}");
}
}
#[test]
fn an_unknown_module_is_refused_and_names_the_modules_it_might_mean() {
let _sandbox = Sandbox::new("schema-filter-unknown");
let refused = schema(Some("fle"), None);
assert!(!refused.success, "an unknown module must be refused");
assert!(refused.result.is_none());
assert_eq!(refused.code.as_deref(), Some("E_BAD_ARG"));
let metadata = refused.metadata.as_ref().expect("metadata");
assert_eq!(metadata["code"], json!("E_BAD_ARG"));
assert!(
metadata["did_you_mean"]
.as_array()
.expect("candidates")
.iter()
.any(|m| m.as_str() == Some("file")),
"{metadata}"
);
assert!(refused.error.unwrap().contains("Unknown module 'fle'"));
}
#[test]
fn a_filtered_and_an_unfiltered_request_do_not_share_a_cache_entry() {
let _sandbox = Sandbox::new("schema-filter-cache");
let whole = builtin_names(&schema(None, None));
let file = builtin_names(&schema(Some("file"), None));
let net = builtin_names(&schema(Some("net"), None));
assert!(file.len() < whole.len() && net.len() < whole.len());
assert_ne!(file, net, "two modules must not share an answer");
assert_eq!(builtin_names(&schema(None, None)), whole);
assert_eq!(builtin_names(&schema(Some("file"), None)), file);
}
#[test]
fn an_empty_filter_is_the_whole_catalogue() {
let _sandbox = Sandbox::new("schema-filter-empty");
let whole = serde_json::to_string(&schema(None, None).result).unwrap();
let blank = serde_json::to_string(&schema(Some(" "), Some("")).result).unwrap();
assert_eq!(whole, blank);
}